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

This commit is contained in:
vahidrezvani 2026-02-25 13:08:57 +03:30
parent 368d3f2943
commit 4037689675
2 changed files with 87 additions and 42 deletions

View File

@ -210,8 +210,12 @@ def chatbot_workflow(bot, now):
# Direct httpx call is the only reliable path. # Direct httpx call is the only reliable path.
if is_kia: if is_kia:
from .kiaai import KiaAIService from .kiaai import KiaAIService
print(f"[KiaAI] Calling via KiaAIService.invoke() | model='{bot.model}'") print(f"[KiaAI] Calling via KiaAIService.invoke() | model='{bot.model}' | web_search={bot.web_search}")
response = await KiaAIService().invoke(model=bot.model, messages=input_messages) response = await KiaAIService().invoke(
model=bot.model,
messages=input_messages,
google_search=bool(bot.web_search),
)
else: else:
response = await model.ainvoke(input=input_messages, **kwargs) response = await model.ainvoke(input=input_messages, **kwargs)

View File

@ -13,55 +13,74 @@ from ...config import settings
KIA_BASE_URL = "https://api.kie.ai" KIA_BASE_URL = "https://api.kie.ai"
# Google Search tool shorthand — mutually exclusive with function calling
KIA_GOOGLE_SEARCH_TOOL = {"type": "function", "function": {"name": "googleSearch"}}
def _to_kia_messages(messages: list[BaseMessage]) -> list[dict]: def _to_kia_messages(messages: list[BaseMessage]) -> list[dict]:
""" """
Convert LangChain messages to KIA message format. Convert LangChain messages to KIA message format.
HumanMessage.content can be: Supported content formats per KIA docs:
- str {"role": "user", "content": "..."} - Plain text str
- list {"role": "user", "content": [{"type": "text", ...}, {"type": "image_url", ...}]} - Text-only list joined into plain string
- Multimodal (image/video/audio/pdf) list with unified format:
[{"type": "image_url", "image_url": {"url": "..."}}]
All media types (image, video, audio, PDF) use the same image_url structure.
""" """
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": _normalize_content(msg.content)})
elif isinstance(msg, HumanMessage): elif isinstance(msg, HumanMessage):
# Multimodal content (text + image) stays as list; plain string stays as string result.append({"role": "user", "content": _normalize_content(msg.content)})
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": _normalize_content(msg.content)})
elif isinstance(msg, ToolMessage): elif isinstance(msg, ToolMessage):
result.append({ result.append({
"role": "tool", "role": "tool",
"content": msg.content, "content": msg.content,
"tool_call_id": msg.tool_call_id, "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
def _normalize_content(content) -> str | list:
"""
- str returned as-is
- list of text-only dicts joined into plain string
- list with image_url/media returned as-is (multimodal)
"""
if isinstance(content, str):
return content
if isinstance(content, list):
is_text_only = all(
isinstance(item, dict) and item.get("type") == "text"
for item in content
)
if is_text_only:
return " ".join(item.get("text", "") for item in content)
return content # multimodal — keep as list
return str(content)
class KiaAIService: class KiaAIService:
""" """
Calls KIA AI API directly via httpx streaming (SSE). Calls KIA AI API (https://api.kie.ai) directly via httpx SSE streaming.
Based on the official Python sample: Supported parameters (per KIA docs):
url = "https://api.kie.ai/{model}/v1/chat/completions" - messages (required) list of role/content objects
payload = {"messages": [...], "stream": True, ...} - stream always True KIA streams by default
response = requests.post(url, headers=headers, data=json.dumps(payload), stream=True) - tools googleSearch OR function calling (mutually exclusive)
for line in response.iter_lines(): - include_thoughts include model's internal reasoning in response
if line: - reasoning_effort 'low' | 'high' (default: 'high')
decoded_line = line.decode('utf-8') - response_format JSON schema (mutually exclusive with function calling)
if decoded_line.startswith('data: '):
data = json.loads(decoded_line[6:])
Retries on KIA 500 errors and returns a fully assembled AIMessage. Note: tools + response_format are mutually exclusive.
""" """
async def invoke( async def invoke(
@ -70,18 +89,22 @@ class KiaAIService:
messages: list[BaseMessage], messages: list[BaseMessage],
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
include_thoughts: bool = False, include_thoughts: bool = False,
google_search: bool = False,
tools: list[dict] | None = None, tools: list[dict] | None = None,
response_format: dict | None = None,
max_retries: int = 3, max_retries: int = 3,
retry_delay: float = 2.0, retry_delay: float = 2.0,
) -> AIMessage: ) -> AIMessage:
""" """
Args: Args:
model: e.g. 'kia/gemini-3-flash' or 'gemini-3-flash' model: 'kia/gemini-3-flash', 'kia/gpt-5-2', etc.
messages: LangChain message list messages: LangChain message list
reasoning_effort: 'low' | 'medium' | 'high' reasoning_effort: 'low' | 'high'
include_thoughts: stream internal reasoning steps include_thoughts: include model reasoning steps in response
tools: list of OpenAI-style tool dicts google_search: enable Google Search tool (mutually exclusive with tools/response_format)
max_retries: retry count on 500 errors tools: custom function-calling tools (mutually exclusive with google_search/response_format)
response_format: JSON schema for structured output (mutually exclusive with tools)
max_retries: retry count on 5xx errors
retry_delay: seconds between retries retry_delay: seconds between retries
""" """
model_name = model.removeprefix("kia/") model_name = model.removeprefix("kia/")
@ -90,25 +113,33 @@ class KiaAIService:
kia_messages = _to_kia_messages(messages) kia_messages = _to_kia_messages(messages)
print(f"[KiaAI] POST {url}") print(f"[KiaAI] POST {url}")
print(f"[KiaAI] messages_count={len(kia_messages)} | reasoning_effort={reasoning_effort} | include_thoughts={include_thoughts}") print(f"[KiaAI] messages_count={len(kia_messages)} | reasoning_effort={reasoning_effort} | include_thoughts={include_thoughts} | google_search={google_search}")
for i, m in enumerate(kia_messages): for i, m in enumerate(kia_messages):
preview = m["content"][:120] if isinstance(m["content"], str) else str(m["content"])[:120] preview = m["content"][:120] if isinstance(m["content"], str) else str(m["content"])[:120]
print(f"[KiaAI] msg[{i}] role={m['role']} | content={preview!r}") print(f"[KiaAI] msg[{i}] role={m['role']} | content={preview!r}")
# Build payload exactly like the official sample # --- Build payload per KIA docs ---
payload: dict = { payload: dict = {
"messages": kia_messages, "messages": kia_messages,
"stream": True, "stream": True,
} }
# reasoning_effort: 'low' | 'high'
if reasoning_effort: if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort payload["reasoning_effort"] = reasoning_effort
# include_thoughts: show internal model reasoning
if include_thoughts: if include_thoughts:
payload["include_thoughts"] = True payload["include_thoughts"] = True
if tools: # tools — google_search and function calling are mutually exclusive
# both are mutually exclusive with response_format
if google_search:
payload["tools"] = [KIA_GOOGLE_SEARCH_TOOL]
elif tools:
payload["tools"] = tools payload["tools"] = tools
elif response_format:
payload["response_format"] = response_format
print(f"[KiaAI] payload keys: {[k for k in payload if k != 'messages']}") print(f"[KiaAI] payload keys: {[k for k in payload if k != 'messages']}")
@ -129,9 +160,10 @@ class KiaAIService:
""" """
Execute one SSE request and assemble the full AIMessage. Execute one SSE request and assemble the full AIMessage.
Mirrors the official sample's per-line handling: Per KIA docs, delta chunks arrive as:
if decoded_line.startswith('data: '): data: {"choices":[{"delta":{"content":"..."},"index":0}], ...}
data = json.loads(decoded_line[6:]) data: {"choices":[],"usage":{...}}
data: [DONE]
""" """
full_content = "" full_content = ""
usage: dict = {} usage: dict = {}
@ -140,7 +172,7 @@ class KiaAIService:
async with client.stream( async with client.stream(
"POST", "POST",
url, url,
content=json.dumps(payload), # mirrors: data=json.dumps(payload) content=json.dumps(payload),
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {settings.kia_api_key}", "Authorization": f"Bearer {settings.kia_api_key}",
@ -153,11 +185,20 @@ class KiaAIService:
if not line: if not line:
continue continue
# mirrors: if decoded_line.startswith('data: '): # Non-data lines may carry KIA error JSON
if not line.startswith("data:"): if not line.startswith("data:"):
try:
err = json.loads(line)
if err.get("code") and int(err["code"]) >= 400:
msg = err.get("msg", "Unknown error")
print(f"[KiaAI] Error line: code={err['code']} msg={msg!r}")
raise RuntimeError(f"KIA error {err['code']}: {msg}")
except (json.JSONDecodeError, ValueError):
pass
print(f"[KiaAI] non-data line: {line!r}") print(f"[KiaAI] non-data line: {line!r}")
continue continue
# Strip "data: " prefix (6 chars) or "data:" (5 chars)
raw_data = line[6:] if line.startswith("data: ") else line[5:] raw_data = line[6:] if line.startswith("data: ") else line[5:]
if raw_data.strip() == "[DONE]": if raw_data.strip() == "[DONE]":
@ -165,23 +206,23 @@ class KiaAIService:
break break
try: try:
# mirrors: data = json.loads(decoded_line[6:])
data = json.loads(raw_data) data = json.loads(raw_data)
except json.JSONDecodeError: except json.JSONDecodeError:
print(f"[KiaAI] JSON decode error: {raw_data!r}") print(f"[KiaAI] JSON decode error: {raw_data!r}")
continue continue
# KIA server error embedded in stream # KIA server error embedded in stream body
if data.get("code") and int(data["code"]) >= 500: if data.get("code") and int(data["code"]) >= 500:
msg = data.get("msg", "Unknown error") msg = data.get("msg", "Unknown error")
print(f"[KiaAI] Error chunk: code={data['code']} msg={msg!r}") print(f"[KiaAI] Error chunk: code={data['code']} msg={msg!r}")
raise RuntimeError(f"KIA error {data['code']}: {msg}") raise RuntimeError(f"KIA error {data['code']}: {msg}")
# Accumulate delta content # Accumulate delta content from choices
for choice in (data.get("choices") or []): for choice in (data.get("choices") or []):
delta = choice.get("delta") or {} delta = choice.get("delta") or {}
full_content += delta.get("content") or "" full_content += delta.get("content") or ""
# Capture usage from the final summary chunk
if data.get("usage"): if data.get("usage"):
usage = data["usage"] usage = data["usage"]
@ -197,6 +238,6 @@ class KiaAIService:
return ai_msg return ai_msg
# Compatibility stub # Compatibility stub — KIA models bypass LangChain and use KiaAIService.invoke() directly
def get_kia_model(model: str): def get_kia_model(model: str):
return None return None