ai_client.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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. # Keep enough of the prompt to debug what the AI was actually
  60. # asked (BC + experience cases live in the user message); 500
  61. # chars truncated before the user message, hiding this.
  62. prompt_preview=json.dumps(messages, ensure_ascii=False)[:8000],
  63. response_preview=(
  64. json.dumps(response, ensure_ascii=False)[:2000]
  65. if response else None
  66. ),
  67. error=error,
  68. duration_ms=duration_ms,
  69. prompt_tokens=prompt_tokens,
  70. completion_tokens=completion_tokens,
  71. total_tokens=total_tokens,
  72. status="success" if not error else "failed",
  73. created_at=datetime.utcnow(),
  74. )
  75. db.add(log)
  76. db.commit()
  77. db.close()
  78. except Exception as e:
  79. logger.warning(f"Failed to log AI call: {e}")
  80. def chat(
  81. self,
  82. messages: List[Dict[str, str]],
  83. model: Optional[str] = None,
  84. temperature: Optional[float] = None,
  85. max_tokens: Optional[int] = None,
  86. system_prompt: Optional[str] = None,
  87. ) -> Dict[str, Any]:
  88. """Non-streaming chat completion.
  89. Args:
  90. messages: List of message dicts with role and content.
  91. model: Override default model.
  92. temperature: Sampling temperature.
  93. max_tokens: Max output tokens.
  94. system_prompt: Optional system prompt prepended to messages.
  95. Returns:
  96. Dict with content, reasoning_content, usage, etc.
  97. """
  98. if not self.is_configured:
  99. raise RuntimeError("KIMI_API_KEY is not configured")
  100. _model = model or self.model
  101. _messages = list(messages)
  102. if system_prompt:
  103. _messages.insert(0, {"role": "system", "content": system_prompt})
  104. payload = {
  105. "model": _model,
  106. "messages": _messages,
  107. "temperature": temperature if temperature is not None else KIMI_TEMPERATURE,
  108. }
  109. # Default to the configured KIMI_MAX_TOKENS when the caller does not
  110. # specify one. Reasoning models (k3) spend part of the budget on
  111. # reasoning_content, so too small a cap (e.g. 2000) can starve the
  112. # actual content and yield an empty response.
  113. payload["max_tokens"] = max_tokens if max_tokens else KIMI_MAX_TOKENS
  114. url = f"{self.base_url}/chat/completions"
  115. start = time.time()
  116. last_error = None
  117. for attempt in range(KIMI_MAX_RETRIES):
  118. try:
  119. resp = self._client.post(url, headers=self._headers(), json=payload)
  120. resp.raise_for_status()
  121. data = resp.json()
  122. duration_ms = int((time.time() - start) * 1000)
  123. choice = data.get("choices", [{}])[0]
  124. msg = choice.get("message", {})
  125. usage = data.get("usage", {})
  126. result = {
  127. "content": msg.get("content", ""),
  128. "reasoning_content": msg.get("reasoning_content", ""),
  129. "model": data.get("model", _model),
  130. "usage": usage,
  131. "finish_reason": choice.get("finish_reason"),
  132. }
  133. # Warn when the output budget was exhausted: reasoning models
  134. # may return empty content with finish_reason=length. Callers
  135. # should treat empty content as a failure signal.
  136. if result["finish_reason"] == "length" and not result["content"]:
  137. logger.warning(
  138. "Kimi response truncated: finish_reason=length with empty "
  139. "content (max_tokens=%s). Consider raising KIMI_MAX_TOKENS.",
  140. payload.get("max_tokens"),
  141. )
  142. self._log_call(
  143. endpoint="/chat/completions",
  144. model=_model,
  145. messages=_messages,
  146. response=result,
  147. error=None,
  148. duration_ms=duration_ms,
  149. prompt_tokens=usage.get("prompt_tokens", 0),
  150. completion_tokens=usage.get("completion_tokens", 0),
  151. total_tokens=usage.get("total_tokens", 0),
  152. )
  153. return result
  154. except httpx.HTTPStatusError as e:
  155. last_error = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
  156. if e.response.status_code in (401, 403, 404):
  157. break # Don't retry auth/not-found errors
  158. if attempt < KIMI_MAX_RETRIES - 1:
  159. time.sleep(2 ** attempt)
  160. except Exception as e:
  161. last_error = str(e)
  162. if attempt < KIMI_MAX_RETRIES - 1:
  163. time.sleep(2 ** attempt)
  164. duration_ms = int((time.time() - start) * 1000)
  165. self._log_call(
  166. endpoint="/chat/completions",
  167. model=_model,
  168. messages=_messages,
  169. response=None,
  170. error=last_error,
  171. duration_ms=duration_ms,
  172. )
  173. raise RuntimeError(f"Kimi API call failed after {KIMI_MAX_RETRIES} retries: {last_error}")
  174. def chat_json(
  175. self,
  176. messages: List[Dict[str, str]],
  177. system_prompt: Optional[str] = None,
  178. **kwargs,
  179. ) -> Dict[str, Any]:
  180. """Chat completion that parses JSON response.
  181. Injects instruction to return valid JSON, then parses the response.
  182. """
  183. _messages = list(messages)
  184. _messages.append({
  185. "role": "user",
  186. "content": "Return your response as valid JSON only. Do not include markdown code fences or any text outside the JSON object."
  187. })
  188. result = self.chat(_messages, system_prompt=system_prompt, **kwargs)
  189. content = result.get("content", "").strip()
  190. # Strip code fences if present
  191. if content.startswith("```"):
  192. lines = content.split("\n")
  193. if lines[0].startswith("```"):
  194. lines = lines[1:]
  195. if lines and lines[-1].strip() == "```":
  196. lines = lines[:-1]
  197. content = "\n".join(lines).strip()
  198. try:
  199. parsed = json.loads(content)
  200. result["parsed_json"] = parsed
  201. return result
  202. except json.JSONDecodeError as e:
  203. result["json_parse_error"] = str(e)
  204. result["raw_content"] = content
  205. logger.warning(f"Failed to parse JSON response: {e}\nContent: {content[:300]}")
  206. return result
  207. def list_models(self) -> List[Dict[str, Any]]:
  208. """List available models."""
  209. if not self.is_configured:
  210. raise RuntimeError("KIMI_API_KEY is not configured")
  211. url = f"{self.base_url}/models"
  212. resp = self._client.get(url, headers=self._headers())
  213. resp.raise_for_status()
  214. return resp.json().get("data", [])
  215. def health_check(self) -> Dict[str, Any]:
  216. """Test API key validity and return model info."""
  217. try:
  218. models = self.list_models()
  219. return {
  220. "status": "ok",
  221. "configured": self.is_configured,
  222. "base_url": self.base_url,
  223. "default_model": self.model,
  224. "available_models": [
  225. {"id": m.get("id"), "display_name": m.get("display_name"),
  226. "context_length": m.get("context_length")}
  227. for m in models
  228. ],
  229. }
  230. except Exception as e:
  231. return {
  232. "status": "error",
  233. "configured": self.is_configured,
  234. "base_url": self.base_url,
  235. "default_model": self.model,
  236. "error": str(e),
  237. }
  238. # Global singleton
  239. _kimi_client: Optional[KimiClient] = None
  240. def get_kimi_client() -> KimiClient:
  241. """Get or create global KimiClient singleton."""
  242. global _kimi_client
  243. if _kimi_client is None:
  244. _kimi_client = KimiClient()
  245. return _kimi_client