From 4037689675b0cd3312384491faa016a3e4009a79 Mon Sep 17 00:00:00 2001 From: vahidrezvani Date: Wed, 25 Feb 2026 13:08:57 +0330 Subject: [PATCH] add kia ai chat tools10 --- src/services/chatbot.py | 8 ++- src/services/kiaai/chat.py | 121 +++++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 42 deletions(-) diff --git a/src/services/chatbot.py b/src/services/chatbot.py index 2485547..3bcabca 100644 --- a/src/services/chatbot.py +++ b/src/services/chatbot.py @@ -210,8 +210,12 @@ def chatbot_workflow(bot, now): # Direct httpx call is the only reliable path. if is_kia: from .kiaai import KiaAIService - print(f"[KiaAI] Calling via KiaAIService.invoke() | model='{bot.model}'") - response = await KiaAIService().invoke(model=bot.model, messages=input_messages) + 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, + google_search=bool(bot.web_search), + ) else: response = await model.ainvoke(input=input_messages, **kwargs) diff --git a/src/services/kiaai/chat.py b/src/services/kiaai/chat.py index a27dff6..5b9f44c 100644 --- a/src/services/kiaai/chat.py +++ b/src/services/kiaai/chat.py @@ -13,55 +13,74 @@ from ...config import settings 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]: """ Convert LangChain messages to KIA message format. - HumanMessage.content can be: - - str → {"role": "user", "content": "..."} - - list → {"role": "user", "content": [{"type": "text", ...}, {"type": "image_url", ...}]} + Supported content formats per KIA docs: + - Plain text → str + - 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 = [] for msg in messages: if isinstance(msg, SystemMessage): - result.append({"role": "system", "content": msg.content}) - + result.append({"role": "system", "content": _normalize_content(msg.content)}) elif isinstance(msg, HumanMessage): - # Multimodal content (text + image) stays as list; plain string stays as string - result.append({"role": "user", "content": msg.content}) - + result.append({"role": "user", "content": _normalize_content(msg.content)}) elif isinstance(msg, AIMessage): - result.append({"role": "assistant", "content": msg.content}) - + result.append({"role": "assistant", "content": _normalize_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 +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: """ - 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: - url = "https://api.kie.ai/{model}/v1/chat/completions" - payload = {"messages": [...], "stream": True, ...} - response = requests.post(url, headers=headers, data=json.dumps(payload), stream=True) - for line in response.iter_lines(): - if line: - decoded_line = line.decode('utf-8') - if decoded_line.startswith('data: '): - data = json.loads(decoded_line[6:]) + Supported parameters (per KIA docs): + - messages (required) list of role/content objects + - stream always True — KIA streams by default + - tools googleSearch OR function calling (mutually exclusive) + - include_thoughts include model's internal reasoning in response + - reasoning_effort 'low' | 'high' (default: 'high') + - response_format JSON schema (mutually exclusive with function calling) - Retries on KIA 500 errors and returns a fully assembled AIMessage. + Note: tools + response_format are mutually exclusive. """ async def invoke( @@ -70,18 +89,22 @@ class KiaAIService: messages: list[BaseMessage], reasoning_effort: str | None = None, include_thoughts: bool = False, + google_search: bool = False, tools: list[dict] | None = None, + response_format: dict | None = None, max_retries: int = 3, retry_delay: float = 2.0, ) -> AIMessage: """ 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 - reasoning_effort: 'low' | 'medium' | 'high' - include_thoughts: stream internal reasoning steps - tools: list of OpenAI-style tool dicts - max_retries: retry count on 500 errors + reasoning_effort: 'low' | 'high' + include_thoughts: include model reasoning steps in response + google_search: enable Google Search tool (mutually exclusive with tools/response_format) + 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 """ model_name = model.removeprefix("kia/") @@ -90,25 +113,33 @@ class KiaAIService: kia_messages = _to_kia_messages(messages) 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): 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}") - # Build payload exactly like the official sample + # --- Build payload per KIA docs --- payload: dict = { "messages": kia_messages, "stream": True, } + # reasoning_effort: 'low' | 'high' if reasoning_effort: payload["reasoning_effort"] = reasoning_effort + # include_thoughts: show internal model reasoning if include_thoughts: 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 + elif response_format: + payload["response_format"] = response_format 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. - Mirrors the official sample's per-line handling: - if decoded_line.startswith('data: '): - data = json.loads(decoded_line[6:]) + Per KIA docs, delta chunks arrive as: + data: {"choices":[{"delta":{"content":"..."},"index":0}], ...} + data: {"choices":[],"usage":{...}} + data: [DONE] """ full_content = "" usage: dict = {} @@ -140,7 +172,7 @@ class KiaAIService: async with client.stream( "POST", url, - content=json.dumps(payload), # mirrors: data=json.dumps(payload) + content=json.dumps(payload), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {settings.kia_api_key}", @@ -153,11 +185,20 @@ class KiaAIService: if not line: continue - # mirrors: if decoded_line.startswith('data: '): + # Non-data lines may carry KIA error JSON 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}") continue + # Strip "data: " prefix (6 chars) or "data:" (5 chars) raw_data = line[6:] if line.startswith("data: ") else line[5:] if raw_data.strip() == "[DONE]": @@ -165,23 +206,23 @@ class KiaAIService: break try: - # mirrors: data = json.loads(decoded_line[6:]) data = json.loads(raw_data) except json.JSONDecodeError: print(f"[KiaAI] JSON decode error: {raw_data!r}") continue - # KIA server error embedded in stream + # KIA server error embedded in stream body if data.get("code") and int(data["code"]) >= 500: msg = data.get("msg", "Unknown error") print(f"[KiaAI] Error chunk: code={data['code']} msg={msg!r}") raise RuntimeError(f"KIA error {data['code']}: {msg}") - # Accumulate delta content + # Accumulate delta content from choices for choice in (data.get("choices") or []): delta = choice.get("delta") or {} full_content += delta.get("content") or "" + # Capture usage from the final summary chunk if data.get("usage"): usage = data["usage"] @@ -197,6 +238,6 @@ class KiaAIService: return ai_msg -# Compatibility stub +# Compatibility stub — KIA models bypass LangChain and use KiaAIService.invoke() directly def get_kia_model(model: str): return None