add kia ai chat tools10
CI/CD Pipeline / build-and-deploy (push) Successful in 47s
Details
CI/CD Pipeline / build-and-deploy (push) Successful in 47s
Details
This commit is contained in:
parent
368d3f2943
commit
4037689675
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue