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"
|
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]:
|
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 = []
|
result = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if isinstance(msg, SystemMessage):
|
if isinstance(msg, SystemMessage):
|
||||||
result.append({"role": "system", "content": msg.content})
|
result.append({"role": "system", "content": msg.content})
|
||||||
|
|
||||||
elif isinstance(msg, HumanMessage):
|
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})
|
result.append({"role": "user", "content": msg.content})
|
||||||
|
else:
|
||||||
|
result.append({"role": "user", "content": msg.content})
|
||||||
|
|
||||||
elif isinstance(msg, AIMessage):
|
elif isinstance(msg, AIMessage):
|
||||||
result.append({"role": "assistant", "content": msg.content})
|
result.append({"role": "assistant", "content": msg.content})
|
||||||
|
|
||||||
elif isinstance(msg, ToolMessage):
|
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:
|
else:
|
||||||
result.append({"role": "user", "content": str(msg.content)})
|
result.append({"role": "user", "content": str(msg.content)})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class KiaAIService:
|
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
|
KIA always responds with SSE stream regardless of stream=false in payload.
|
||||||
'TypeError: object of type NoneType has no len()' bug caused by
|
We collect all delta chunks and return a complete AIMessage.
|
||||||
KIA API returning chunks with choices=null.
|
|
||||||
|
|
||||||
Endpoint pattern:
|
Endpoint pattern:
|
||||||
POST https://api.kie.ai/{modelName}/v1/chat/completions
|
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:
|
Args:
|
||||||
model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2')
|
model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2')
|
||||||
messages: List of LangChain BaseMessage objects
|
messages: LangChain message list
|
||||||
|
reasoning_effort: 'low' | 'medium' | 'high' — only for supported models
|
||||||
Returns:
|
|
||||||
AIMessage with content and usage_metadata populated
|
|
||||||
"""
|
"""
|
||||||
model_name = model.removeprefix("kia/")
|
model_name = model.removeprefix("kia/")
|
||||||
url = f"{KIA_BASE_URL}/{model_name}/v1/chat/completions"
|
url = f"{KIA_BASE_URL}/{model_name}/v1/chat/completions"
|
||||||
|
|
||||||
payload = {
|
payload: dict = {
|
||||||
"model": model_name,
|
|
||||||
"messages": _to_openai_messages(messages),
|
"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:
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
resp = await client.post(
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
url,
|
url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {settings.kia_api_key}",
|
"Authorization": f"Bearer {settings.kia_api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
)
|
) as resp:
|
||||||
|
|
||||||
print(f"[KiaAI] HTTP {resp.status_code}")
|
print(f"[KiaAI] HTTP {resp.status_code}")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
raw_text = resp.text
|
|
||||||
|
|
||||||
print(f"[KiaAI] Raw response text:\n{raw_text}\n---")
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.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
|
continue
|
||||||
|
|
||||||
if data is None:
|
# SSE lines: "data: {...}" or "data: [DONE]" or raw JSON
|
||||||
# Fallback: try parsing the whole text as one JSON
|
raw_data = line.removeprefix("data:").strip()
|
||||||
data = json.loads(raw_text)
|
|
||||||
|
|
||||||
print(f"[KiaAI] Parsed response keys: {list(data.keys())}")
|
if raw_data == "[DONE]":
|
||||||
|
print("[KiaAI] Stream [DONE]")
|
||||||
|
break
|
||||||
|
|
||||||
content = data["choices"][0]["message"]["content"]
|
try:
|
||||||
usage = data.get("usage") or {}
|
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}")
|
print(f"[KiaAI] usage={usage}")
|
||||||
|
|
||||||
ai_msg = AIMessage(content=content)
|
ai_msg = AIMessage(content=full_content)
|
||||||
ai_msg.usage_metadata = {
|
ai_msg.usage_metadata = {
|
||||||
"input_tokens": usage.get("prompt_tokens", 0),
|
"input_tokens": usage.get("prompt_tokens", 0),
|
||||||
"output_tokens": usage.get("completion_tokens", 0),
|
"output_tokens": usage.get("completion_tokens", 0),
|
||||||
|
|
@ -115,7 +149,6 @@ class KiaAIService:
|
||||||
return ai_msg
|
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):
|
def get_kia_model(model: str):
|
||||||
"""Compatibility stub — returns None for KIA models since they use direct HTTP."""
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue