add kia ai chat tools3
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
8301329aca
commit
5d64e0ca1a
|
|
@ -56,7 +56,8 @@ async def web_search_tool(query: str) -> str:
|
||||||
|
|
||||||
def get_model(model: str):
|
def get_model(model: str):
|
||||||
if model.startswith("kia/"):
|
if model.startswith("kia/"):
|
||||||
return get_kia_model(model)
|
# KIA models use KiaAIService.invoke() directly in call_model — no LangChain model needed
|
||||||
|
return None
|
||||||
|
|
||||||
if "claude" in model:
|
if "claude" in model:
|
||||||
# return ChatAnthropic(model=model, api_key=settings.anthropic_api_key)
|
# return ChatAnthropic(model=model, api_key=settings.anthropic_api_key)
|
||||||
|
|
@ -176,10 +177,10 @@ def chatbot_workflow(bot, now):
|
||||||
print(f"[chatbot_workflow] START | model='{bot.model}' | is_kia={is_kia_model} | web_search={bot.web_search}")
|
print(f"[chatbot_workflow] START | model='{bot.model}' | is_kia={is_kia_model} | web_search={bot.web_search}")
|
||||||
|
|
||||||
model = get_model(model=bot.model)
|
model = get_model(model=bot.model)
|
||||||
print(f"[chatbot_workflow] Model initialized: {type(model).__name__}")
|
print(f"[chatbot_workflow] Model initialized: {type(model).__name__ if model else 'KiaAIService (direct http)'}")
|
||||||
trimmer = get_trimmer()
|
trimmer = get_trimmer()
|
||||||
|
|
||||||
if bot.web_search:
|
if bot.web_search and model is not None:
|
||||||
model = model.bind_tools([web_search_tool])
|
model = model.bind_tools([web_search_tool])
|
||||||
|
|
||||||
async def call_model(state: MessagesState):
|
async def call_model(state: MessagesState):
|
||||||
|
|
@ -203,12 +204,14 @@ def chatbot_workflow(bot, now):
|
||||||
)
|
)
|
||||||
] + trimmed_messages
|
] + trimmed_messages
|
||||||
|
|
||||||
# KIA API returns choices=null in streaming chunks which breaks LangChain.
|
# KIA models must bypass LangChain entirely:
|
||||||
# Even with streaming=False, ainvoke() still uses _astream() internally in async path.
|
# both ainvoke() and invoke() internally use _astream()/_stream() in this
|
||||||
# asyncio.to_thread forces sync model.invoke() which calls the real non-streaming endpoint.
|
# version of LangChain which fails on KIA's choices=null streaming chunks.
|
||||||
|
# Direct httpx call is the only reliable path.
|
||||||
if is_kia:
|
if is_kia:
|
||||||
print(f"[KiaAI] Calling model via asyncio.to_thread (non-streaming) | model='{bot.model}'")
|
from .kiaai import KiaAIService
|
||||||
response = await asyncio.to_thread(model.invoke, input_messages)
|
print(f"[KiaAI] Calling via KiaAIService.invoke() | model='{bot.model}'")
|
||||||
|
response = await KiaAIService().invoke(model=bot.model, messages=input_messages)
|
||||||
else:
|
else:
|
||||||
response = await model.ainvoke(input=input_messages, **kwargs)
|
response = await model.ainvoke(input=input_messages, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,101 @@
|
||||||
from langchain_openai import ChatOpenAI
|
import httpx
|
||||||
|
from langchain_core.messages import (
|
||||||
|
AIMessage,
|
||||||
|
BaseMessage,
|
||||||
|
HumanMessage,
|
||||||
|
SystemMessage,
|
||||||
|
ToolMessage,
|
||||||
|
)
|
||||||
|
|
||||||
from ...config import settings
|
from ...config import settings
|
||||||
|
|
||||||
KIA_BASE_URL = "https://api.kie.ai"
|
KIA_BASE_URL = "https://api.kie.ai"
|
||||||
|
|
||||||
# Models that support reasoning_effort parameter
|
|
||||||
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."""
|
||||||
|
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})
|
||||||
|
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})
|
||||||
|
else:
|
||||||
|
result.append({"role": "user", "content": str(msg.content)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class KiaAIService:
|
class KiaAIService:
|
||||||
"""
|
"""
|
||||||
Service for interacting with KIA AI models via OpenAI-compatible API.
|
Service for calling KIA AI models directly via httpx (non-streaming).
|
||||||
|
|
||||||
KIA AI endpoint pattern:
|
Bypasses LangChain's streaming infrastructure entirely to avoid the
|
||||||
https://api.kie.ai/{modelName}/v1/chat/completions
|
'TypeError: object of type NoneType has no len()' bug caused by
|
||||||
|
KIA API returning chunks with choices=null.
|
||||||
|
|
||||||
Usage:
|
Endpoint pattern:
|
||||||
service = KiaAIService()
|
POST https://api.kie.ai/{modelName}/v1/chat/completions
|
||||||
model = service.get_model("kia/gpt-4o")
|
|
||||||
model = service.get_model("kia/o3", reasoning_effort="high")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_model(
|
async def invoke(self, model: str, messages: list[BaseMessage]) -> AIMessage:
|
||||||
self,
|
|
||||||
model: str,
|
|
||||||
reasoning_effort: str | None = None,
|
|
||||||
) -> ChatOpenAI:
|
|
||||||
"""
|
"""
|
||||||
Returns a LangChain-compatible ChatOpenAI model configured for KIA AI API.
|
Call KIA API directly and return an AIMessage.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Model identifier with optional 'kia/' prefix
|
model: Model name with optional 'kia/' prefix (e.g. 'kia/gpt-5-2')
|
||||||
(e.g. 'kia/gpt-4o', 'kia/claude-3-5-sonnet', 'kia/o3')
|
messages: List of LangChain BaseMessage objects
|
||||||
reasoning_effort: Reasoning effort level for supported models.
|
|
||||||
One of: 'low', 'medium', 'high'
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ChatOpenAI instance pointing to https://api.kie.ai/{modelName}/v1
|
AIMessage with content and usage_metadata populated
|
||||||
"""
|
"""
|
||||||
model_name = model.removeprefix("kia/")
|
model_name = model.removeprefix("kia/")
|
||||||
base_url = f"{KIA_BASE_URL}/{model_name}/v1"
|
url = f"{KIA_BASE_URL}/{model_name}/v1/chat/completions"
|
||||||
|
|
||||||
model_kwargs: dict = {}
|
payload = {
|
||||||
|
"model": model_name,
|
||||||
|
"messages": _to_openai_messages(messages),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
|
||||||
# Add reasoning_effort for models that support it
|
print(f"[KiaAI] POST {url} | messages_count={len(messages)}")
|
||||||
if reasoning_effort and model_name in REASONING_MODELS:
|
|
||||||
model_kwargs["reasoning_effort"] = reasoning_effort
|
|
||||||
|
|
||||||
print(f"[KiaAI] Initializing model='{model_name}' | base_url='{base_url}' | reasoning_effort='{reasoning_effort}'")
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
# streaming=False is REQUIRED: KIA API returns chunks with choices=null
|
url,
|
||||||
# which causes TypeError in LangChain's _convert_chunk_to_generation_chunk.
|
json=payload,
|
||||||
# Disabling streaming forces LangChain to use the non-streaming endpoint.
|
headers={
|
||||||
return ChatOpenAI(
|
"Authorization": f"Bearer {settings.kia_api_key}",
|
||||||
model=model_name,
|
"Content-Type": "application/json",
|
||||||
api_key=settings.kia_api_key,
|
},
|
||||||
base_url=base_url,
|
|
||||||
model_kwargs=model_kwargs,
|
|
||||||
streaming=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print(f"[KiaAI] HTTP {resp.status_code}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
def get_kia_model(
|
print(f"[KiaAI] Raw response keys: {list(data.keys())}")
|
||||||
model: str,
|
|
||||||
reasoning_effort: str | None = None,
|
|
||||||
) -> ChatOpenAI:
|
|
||||||
"""
|
|
||||||
Convenience function to get a KIA AI LangChain model instance.
|
|
||||||
|
|
||||||
Args:
|
content = data["choices"][0]["message"]["content"]
|
||||||
model: Model identifier with optional 'kia/' prefix
|
usage = data.get("usage") or {}
|
||||||
(e.g. 'kia/gpt-4o', 'kia/o3')
|
|
||||||
reasoning_effort: Optional reasoning effort ('low', 'medium', 'high')
|
|
||||||
|
|
||||||
Returns:
|
print(f"[KiaAI] content='{content[:200]}'")
|
||||||
ChatOpenAI configured for KIA AI API
|
print(f"[KiaAI] usage={usage}")
|
||||||
"""
|
|
||||||
service = KiaAIService()
|
ai_msg = AIMessage(content=content)
|
||||||
return service.get_model(model=model, reasoning_effort=reasoning_effort)
|
ai_msg.usage_metadata = {
|
||||||
|
"input_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"output_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ai_msg
|
||||||
|
|
||||||
|
|
||||||
|
# Keep get_kia_model for compatibility — but actual chat calls should use KiaAIService.invoke()
|
||||||
|
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