add kia ai chat tools5
CI/CD Pipeline / build-and-deploy (push) Successful in 17s Details

This commit is contained in:
vahidrezvani 2026-02-25 12:24:17 +03:30
parent 5c5e411641
commit 3c60e3ecfe
1 changed files with 79 additions and 46 deletions

View File

@ -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):
# 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
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()
raw_text = resp.text
print(f"[KiaAI] Raw response text:\n{raw_text}\n---")
# 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:
async for line in resp.aiter_lines():
if not line.strip():
continue
if data is None:
# Fallback: try parsing the whole text as one JSON
data = json.loads(raw_text)
# SSE lines: "data: {...}" or "data: [DONE]" or raw JSON
raw_data = line.removeprefix("data:").strip()
print(f"[KiaAI] Parsed response keys: {list(data.keys())}")
if raw_data == "[DONE]":
print("[KiaAI] Stream [DONE]")
break
content = data["choices"][0]["message"]["content"]
usage = data.get("usage") or {}
try:
chunk = json.loads(raw_data)
except json.JSONDecodeError:
print(f"[KiaAI] Skipping non-JSON line: {raw_data!r}")
continue
print(f"[KiaAI] content='{content[:200]}'")
choices = chunk.get("choices") or []
for choice in choices:
delta = choice.get("delta") or {}
content_piece = delta.get("content") or ""
full_content += content_piece
if chunk.get("usage"):
usage = chunk["usage"]
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