diff --git a/src/services/kiaai/chat.py b/src/services/kiaai/chat.py index bbb8c2c..998e419 100644 --- a/src/services/kiaai/chat.py +++ b/src/services/kiaai/chat.py @@ -12,100 +12,134 @@ from ...config import settings KIA_BASE_URL = "https://api.kie.ai" +# Models that support reasoning_effort +REASONING_MODELS = { + "gpt-5-2", "claude-sonnet-4-5", "claude-opus-4-5", + "gemini-3-pro", "gemini-2.5-flash", "gemini-2.5-pro", +} + def _to_openai_messages(messages: list[BaseMessage]) -> list[dict]: - """Convert LangChain messages to OpenAI-compatible dict format.""" + """ + Convert LangChain messages to KIA/OpenAI-compatible dict format. + + Supports multimodal HumanMessages where content is already a list + (e.g. [{"type": "text", "text": "..."}, {"type": "image_url", ...}]). + """ result = [] for msg in messages: if isinstance(msg, SystemMessage): result.append({"role": "system", "content": msg.content}) + elif isinstance(msg, HumanMessage): - result.append({"role": "user", "content": msg.content}) + # content may be a plain string or a multimodal list + if isinstance(msg.content, list): + result.append({"role": "user", "content": msg.content}) + else: + result.append({"role": "user", "content": msg.content}) + elif isinstance(msg, AIMessage): result.append({"role": "assistant", "content": msg.content}) + elif isinstance(msg, ToolMessage): - result.append({"role": "tool", "content": msg.content, "tool_call_id": msg.tool_call_id}) + result.append({ + "role": "tool", + "content": msg.content, + "tool_call_id": msg.tool_call_id, + }) + else: result.append({"role": "user", "content": str(msg.content)}) + return result class KiaAIService: """ - Service for calling KIA AI models directly via httpx (non-streaming). + Service for calling KIA AI models directly via httpx SSE streaming. - Bypasses LangChain's streaming infrastructure entirely to avoid the - 'TypeError: object of type NoneType has no len()' bug caused by - KIA API returning chunks with choices=null. + KIA always responds with SSE stream regardless of stream=false in payload. + We collect all delta chunks and return a complete AIMessage. Endpoint pattern: POST https://api.kie.ai/{modelName}/v1/chat/completions """ - async def invoke(self, model: str, messages: list[BaseMessage]) -> AIMessage: + async def invoke( + self, + model: str, + messages: list[BaseMessage], + reasoning_effort: str | None = None, + ) -> AIMessage: """ - Call KIA API directly and return an AIMessage. + Call KIA API and return an assembled AIMessage. Args: - model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2') - messages: List of LangChain BaseMessage objects - - Returns: - AIMessage with content and usage_metadata populated + model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2') + messages: LangChain message list + reasoning_effort: 'low' | 'medium' | 'high' — only for supported models """ model_name = model.removeprefix("kia/") url = f"{KIA_BASE_URL}/{model_name}/v1/chat/completions" - payload = { - "model": model_name, + payload: dict = { "messages": _to_openai_messages(messages), - "stream": False, } - print(f"[KiaAI] POST {url} | messages_count={len(messages)}") + # Add reasoning_effort only for models that support it + if reasoning_effort and model_name in REASONING_MODELS: + payload["reasoning_effort"] = reasoning_effort + + print(f"[KiaAI] POST {url}") + print(f"[KiaAI] messages_count={len(messages)} | reasoning_effort={reasoning_effort}") + print(f"[KiaAI] payload (no content): { {k: v for k, v in payload.items() if k != 'messages'} }") + + full_content = "" + usage: dict = {} async with httpx.AsyncClient(timeout=180.0) as client: - resp = await client.post( + async with client.stream( + "POST", url, json=payload, headers={ "Authorization": f"Bearer {settings.kia_api_key}", "Content-Type": "application/json", }, - ) + ) as resp: + print(f"[KiaAI] HTTP {resp.status_code}") + resp.raise_for_status() - print(f"[KiaAI] HTTP {resp.status_code}") - resp.raise_for_status() - raw_text = resp.text + async for line in resp.aiter_lines(): + if not line.strip(): + continue - print(f"[KiaAI] Raw response text:\n{raw_text}\n---") + # SSE lines: "data: {...}" or "data: [DONE]" or raw JSON + raw_data = line.removeprefix("data:").strip() - # KIA may return NDJSON (multiple JSON lines) instead of a single JSON object. - # We pick the last non-empty line that contains "choices" as the final completion chunk. - data = None - lines = [line.strip() for line in raw_text.strip().splitlines() if line.strip()] - for line in reversed(lines): - try: - candidate = json.loads(line) - if "choices" in candidate and candidate["choices"]: - data = candidate - break - except json.JSONDecodeError: - continue + if raw_data == "[DONE]": + print("[KiaAI] Stream [DONE]") + break - if data is None: - # Fallback: try parsing the whole text as one JSON - data = json.loads(raw_text) + try: + chunk = json.loads(raw_data) + except json.JSONDecodeError: + print(f"[KiaAI] Skipping non-JSON line: {raw_data!r}") + continue - print(f"[KiaAI] Parsed response keys: {list(data.keys())}") + choices = chunk.get("choices") or [] + for choice in choices: + delta = choice.get("delta") or {} + content_piece = delta.get("content") or "" + full_content += content_piece - content = data["choices"][0]["message"]["content"] - usage = data.get("usage") or {} + if chunk.get("usage"): + usage = chunk["usage"] - print(f"[KiaAI] content='{content[:200]}'") + print(f"[KiaAI] Final content (first 200): '{full_content[:200]}'") print(f"[KiaAI] usage={usage}") - ai_msg = AIMessage(content=content) + ai_msg = AIMessage(content=full_content) ai_msg.usage_metadata = { "input_tokens": usage.get("prompt_tokens", 0), "output_tokens": usage.get("completion_tokens", 0), @@ -115,7 +149,6 @@ class KiaAIService: return ai_msg -# Keep get_kia_model for compatibility — but actual chat calls should use KiaAIService.invoke() +# Compatibility stub — KIA models bypass LangChain and use KiaAIService.invoke() directly def get_kia_model(model: str): - """Compatibility stub — returns None for KIA models since they use direct HTTP.""" return None