diff --git a/src/services/chatbot.py b/src/services/chatbot.py index d113080..c9865c5 100644 --- a/src/services/chatbot.py +++ b/src/services/chatbot.py @@ -56,7 +56,8 @@ async def web_search_tool(query: str) -> str: def get_model(model: str): if model.startswith("kia/"): - return get_kia_model(model) + # KIA models use KiaAIService.invoke() directly in call_model — no LangChain model needed + return None if "claude" in model: # return ChatAnthropic(model=model, api_key=settings.anthropic_api_key) @@ -176,10 +177,10 @@ def chatbot_workflow(bot, now): print(f"[chatbot_workflow] START | model='{bot.model}' | is_kia={is_kia_model} | web_search={bot.web_search}") model = get_model(model=bot.model) - print(f"[chatbot_workflow] Model initialized: {type(model).__name__}") + print(f"[chatbot_workflow] Model initialized: {type(model).__name__ if model else 'KiaAIService (direct http)'}") trimmer = get_trimmer() - if bot.web_search: + if bot.web_search and model is not None: model = model.bind_tools([web_search_tool]) async def call_model(state: MessagesState): @@ -203,12 +204,14 @@ def chatbot_workflow(bot, now): ) ] + trimmed_messages - # KIA API returns choices=null in streaming chunks which breaks LangChain. - # Even with streaming=False, ainvoke() still uses _astream() internally in async path. - # asyncio.to_thread forces sync model.invoke() which calls the real non-streaming endpoint. + # KIA models must bypass LangChain entirely: + # both ainvoke() and invoke() internally use _astream()/_stream() in this + # version of LangChain which fails on KIA's choices=null streaming chunks. + # Direct httpx call is the only reliable path. if is_kia: - print(f"[KiaAI] Calling model via asyncio.to_thread (non-streaming) | model='{bot.model}'") - response = await asyncio.to_thread(model.invoke, input_messages) + from .kiaai import KiaAIService + print(f"[KiaAI] Calling via KiaAIService.invoke() | model='{bot.model}'") + response = await KiaAIService().invoke(model=bot.model, messages=input_messages) else: response = await model.ainvoke(input=input_messages, **kwargs) diff --git a/src/services/kiaai/chat.py b/src/services/kiaai/chat.py index 9973913..50136ea 100644 --- a/src/services/kiaai/chat.py +++ b/src/services/kiaai/chat.py @@ -1,80 +1,101 @@ -from langchain_openai import ChatOpenAI +import httpx +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, + ToolMessage, +) from ...config import settings KIA_BASE_URL = "https://api.kie.ai" -# Models that support reasoning_effort parameter -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.""" + 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}) + 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}) + else: + result.append({"role": "user", "content": str(msg.content)}) + return result class KiaAIService: """ - Service for interacting with KIA AI models via OpenAI-compatible API. + Service for calling KIA AI models directly via httpx (non-streaming). - KIA AI endpoint pattern: - https://api.kie.ai/{modelName}/v1/chat/completions + 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. - Usage: - service = KiaAIService() - model = service.get_model("kia/gpt-4o") - model = service.get_model("kia/o3", reasoning_effort="high") + Endpoint pattern: + POST https://api.kie.ai/{modelName}/v1/chat/completions """ - def get_model( - self, - model: str, - reasoning_effort: str | None = None, - ) -> ChatOpenAI: + async def invoke(self, model: str, messages: list[BaseMessage]) -> AIMessage: """ - Returns a LangChain-compatible ChatOpenAI model configured for KIA AI API. + Call KIA API directly and return an AIMessage. Args: - model: Model identifier with optional 'kia/' prefix - (e.g. 'kia/gpt-4o', 'kia/claude-3-5-sonnet', 'kia/o3') - reasoning_effort: Reasoning effort level for supported models. - One of: 'low', 'medium', 'high' + model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2') + messages: List of LangChain BaseMessage objects Returns: - ChatOpenAI instance pointing to https://api.kie.ai/{modelName}/v1 + AIMessage with content and usage_metadata populated """ model_name = model.removeprefix("kia/") - base_url = f"{KIA_BASE_URL}/{model_name}/v1" + url = f"{KIA_BASE_URL}/{model_name}/v1/chat/completions" - model_kwargs: dict = {} + payload = { + "model": model_name, + "messages": _to_openai_messages(messages), + "stream": False, + } - # Add reasoning_effort for models that support it - if reasoning_effort and model_name in REASONING_MODELS: - model_kwargs["reasoning_effort"] = reasoning_effort + print(f"[KiaAI] POST {url} | messages_count={len(messages)}") - print(f"[KiaAI] Initializing model='{model_name}' | base_url='{base_url}' | reasoning_effort='{reasoning_effort}'") + async with httpx.AsyncClient(timeout=180.0) as client: + resp = await client.post( + url, + json=payload, + headers={ + "Authorization": f"Bearer {settings.kia_api_key}", + "Content-Type": "application/json", + }, + ) - # streaming=False is REQUIRED: KIA API returns chunks with choices=null - # which causes TypeError in LangChain's _convert_chunk_to_generation_chunk. - # Disabling streaming forces LangChain to use the non-streaming endpoint. - return ChatOpenAI( - model=model_name, - api_key=settings.kia_api_key, - base_url=base_url, - model_kwargs=model_kwargs, - streaming=False, - ) + print(f"[KiaAI] HTTP {resp.status_code}") + resp.raise_for_status() + data = resp.json() + + print(f"[KiaAI] Raw response keys: {list(data.keys())}") + + content = data["choices"][0]["message"]["content"] + usage = data.get("usage") or {} + + print(f"[KiaAI] content='{content[:200]}'") + print(f"[KiaAI] usage={usage}") + + ai_msg = AIMessage(content=content) + ai_msg.usage_metadata = { + "input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } + + return ai_msg -def get_kia_model( - model: str, - reasoning_effort: str | None = None, -) -> ChatOpenAI: - """ - Convenience function to get a KIA AI LangChain model instance. - - Args: - model: Model identifier with optional 'kia/' prefix - (e.g. 'kia/gpt-4o', 'kia/o3') - reasoning_effort: Optional reasoning effort ('low', 'medium', 'high') - - Returns: - ChatOpenAI configured for KIA AI API - """ - service = KiaAIService() - return service.get_model(model=model, reasoning_effort=reasoning_effort) +# Keep get_kia_model for compatibility — but actual chat calls should use KiaAIService.invoke() +def get_kia_model(model: str): + """Compatibility stub — returns None for KIA models since they use direct HTTP.""" + return None