ai_client.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """Kimi AI Client - OpenAI compatible API wrapper.
  2. P3-M1: AI service layer foundation.
  3. Supports chat completions, streaming, retries, and call logging.
  4. Endpoint: https://api.kimi.com/coding/v1 (Kimi Code Plan)
  5. Model: k3 (1M context, reasoning enabled)
  6. """
  7. import json
  8. import time
  9. import logging
  10. from datetime import datetime
  11. from typing import Optional, List, Dict, Any, AsyncIterator
  12. import httpx
  13. from ..config import (
  14. KIMI_API_KEY, KIMI_BASE_URL, KIMI_MODEL,
  15. KIMI_MAX_TOKENS, KIMI_TEMPERATURE, KIMI_TIMEOUT, KIMI_MAX_RETRIES
  16. )
  17. from ..database import SessionLocal
  18. from ..models.ai_call_log import AICallLog
  19. logger = logging.getLogger(__name__)
  20. class KimiClient:
  21. """Kimi AI API client with retry and logging."""
  22. def __init__(
  23. self,
  24. api_key: Optional[str] = None,
  25. base_url: Optional[str] = None,
  26. model: Optional[str] = None,
  27. ):
  28. self.api_key = api_key or KIMI_API_KEY
  29. self.base_url = (base_url or KIMI_BASE_URL).rstrip("/")
  30. self.model = model or KIMI_MODEL
  31. self._client = httpx.Client(timeout=KIMI_TIMEOUT)
  32. @property
  33. def is_configured(self) -> bool:
  34. """Check if API key is configured."""
  35. return bool(self.api_key)
  36. def _headers(self) -> Dict[str, str]:
  37. return {
  38. "Content-Type": "application/json",
  39. "Authorization": f"Bearer {self.api_key}",
  40. }
  41. def _log_call(
  42. self,
  43. endpoint: str,
  44. model: str,
  45. messages: List[Dict[str, str]],
  46. response: Optional[Dict[str, Any]],
  47. error: Optional[str],
  48. duration_ms: int,
  49. prompt_tokens: int = 0,
  50. completion_tokens: int = 0,
  51. total_tokens: int = 0,
  52. ):
  53. """Log AI call to database."""
  54. try:
  55. db = SessionLocal()
  56. log = AICallLog(
  57. endpoint=endpoint,
  58. model=model,
  59. prompt_preview=json.dumps(messages[:3], ensure_ascii=False)[:500],
  60. response_preview=(
  61. json.dumps(response, ensure_ascii=False)[:1000]
  62. if response else None
  63. ),
  64. error=error,
  65. duration_ms=duration_ms,
  66. prompt_tokens=prompt_tokens,
  67. completion_tokens=completion_tokens,
  68. total_tokens=total_tokens,
  69. status="success" if not error else "failed",
  70. created_at=datetime.utcnow(),
  71. )
  72. db.add(log)
  73. db.commit()
  74. db.close()
  75. except Exception as e:
  76. logger.warning(f"Failed to log AI call: {e}")
  77. def chat(
  78. self,
  79. messages: List[Dict[str, str]],
  80. model: Optional[str] = None,
  81. temperature: Optional[float] = None,
  82. max_tokens: Optional[int] = None,
  83. system_prompt: Optional[str] = None,
  84. ) -> Dict[str, Any]:
  85. """Non-streaming chat completion.
  86. Args:
  87. messages: List of message dicts with role and content.
  88. model: Override default model.
  89. temperature: Sampling temperature.
  90. max_tokens: Max output tokens.
  91. system_prompt: Optional system prompt prepended to messages.
  92. Returns:
  93. Dict with content, reasoning_content, usage, etc.
  94. """
  95. if not self.is_configured:
  96. raise RuntimeError("KIMI_API_KEY is not configured")
  97. _model = model or self.model
  98. _messages = list(messages)
  99. if system_prompt:
  100. _messages.insert(0, {"role": "system", "content": system_prompt})
  101. payload = {
  102. "model": _model,
  103. "messages": _messages,
  104. "temperature": temperature if temperature is not None else KIMI_TEMPERATURE,
  105. }
  106. if max_tokens:
  107. payload["max_tokens"] = max_tokens
  108. url = f"{self.base_url}/chat/completions"
  109. start = time.time()
  110. last_error = None
  111. for attempt in range(KIMI_MAX_RETRIES):
  112. try:
  113. resp = self._client.post(url, headers=self._headers(), json=payload)
  114. resp.raise_for_status()
  115. data = resp.json()
  116. duration_ms = int((time.time() - start) * 1000)
  117. choice = data.get("choices", [{}])[0]
  118. msg = choice.get("message", {})
  119. usage = data.get("usage", {})
  120. result = {
  121. "content": msg.get("content", ""),
  122. "reasoning_content": msg.get("reasoning_content", ""),
  123. "model": data.get("model", _model),
  124. "usage": usage,
  125. "finish_reason": choice.get("finish_reason"),
  126. }
  127. self._log_call(
  128. endpoint="/chat/completions",
  129. model=_model,
  130. messages=_messages,
  131. response=result,
  132. error=None,
  133. duration_ms=duration_ms,
  134. prompt_tokens=usage.get("prompt_tokens", 0),
  135. completion_tokens=usage.get("completion_tokens", 0),
  136. total_tokens=usage.get("total_tokens", 0),
  137. )
  138. return result
  139. except httpx.HTTPStatusError as e:
  140. last_error = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
  141. if e.response.status_code in (401, 403, 404):
  142. break # Don't retry auth/not-found errors
  143. if attempt < KIMI_MAX_RETRIES - 1:
  144. time.sleep(2 ** attempt)
  145. except Exception as e:
  146. last_error = str(e)
  147. if attempt < KIMI_MAX_RETRIES - 1:
  148. time.sleep(2 ** attempt)
  149. duration_ms = int((time.time() - start) * 1000)
  150. self._log_call(
  151. endpoint="/chat/completions",
  152. model=_model,
  153. messages=_messages,
  154. response=None,
  155. error=last_error,
  156. duration_ms=duration_ms,
  157. )
  158. raise RuntimeError(f"Kimi API call failed after {KIMI_MAX_RETRIES} retries: {last_error}")
  159. def chat_json(
  160. self,
  161. messages: List[Dict[str, str]],
  162. system_prompt: Optional[str] = None,
  163. **kwargs,
  164. ) -> Dict[str, Any]:
  165. """Chat completion that parses JSON response.
  166. Injects instruction to return valid JSON, then parses the response.
  167. """
  168. _messages = list(messages)
  169. _messages.append({
  170. "role": "user",
  171. "content": "Return your response as valid JSON only. Do not include markdown code fences or any text outside the JSON object."
  172. })
  173. result = self.chat(_messages, system_prompt=system_prompt, **kwargs)
  174. content = result.get("content", "").strip()
  175. # Strip code fences if present
  176. if content.startswith("```"):
  177. lines = content.split("\n")
  178. if lines[0].startswith("```"):
  179. lines = lines[1:]
  180. if lines and lines[-1].strip() == "```":
  181. lines = lines[:-1]
  182. content = "\n".join(lines).strip()
  183. try:
  184. parsed = json.loads(content)
  185. result["parsed_json"] = parsed
  186. return result
  187. except json.JSONDecodeError as e:
  188. result["json_parse_error"] = str(e)
  189. result["raw_content"] = content
  190. logger.warning(f"Failed to parse JSON response: {e}\nContent: {content[:300]}")
  191. return result
  192. def list_models(self) -> List[Dict[str, Any]]:
  193. """List available models."""
  194. if not self.is_configured:
  195. raise RuntimeError("KIMI_API_KEY is not configured")
  196. url = f"{self.base_url}/models"
  197. resp = self._client.get(url, headers=self._headers())
  198. resp.raise_for_status()
  199. return resp.json().get("data", [])
  200. def health_check(self) -> Dict[str, Any]:
  201. """Test API key validity and return model info."""
  202. try:
  203. models = self.list_models()
  204. return {
  205. "status": "ok",
  206. "configured": self.is_configured,
  207. "base_url": self.base_url,
  208. "default_model": self.model,
  209. "available_models": [
  210. {"id": m.get("id"), "display_name": m.get("display_name"),
  211. "context_length": m.get("context_length")}
  212. for m in models
  213. ],
  214. }
  215. except Exception as e:
  216. return {
  217. "status": "error",
  218. "configured": self.is_configured,
  219. "base_url": self.base_url,
  220. "default_model": self.model,
  221. "error": str(e),
  222. }
  223. # Global singleton
  224. _kimi_client: Optional[KimiClient] = None
  225. def get_kimi_client() -> KimiClient:
  226. """Get or create global KimiClient singleton."""
  227. global _kimi_client
  228. if _kimi_client is None:
  229. _kimi_client = KimiClient()
  230. return _kimi_client