add kia ai chat tools5
CI/CD Pipeline / build-and-deploy (push) Successful in 17s
Details
CI/CD Pipeline / build-and-deploy (push) Successful in 17s
Details
This commit is contained in:
parent
5c5e411641
commit
3c60e3ecfe
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue