Coverage for baibu/chat/llm.py: 100%
86 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Calling a language model.
3Every call goes through :func:`complete`. Real models are reached through
4LiteLLM, so a deployment can use any model and endpoint LiteLLM supports.
5The model name ``mock`` answers locally (see :func:`_mock_completion`), so
6the platform runs and can be tested without any model.
7"""
9import json
10import os
11import time
12from dataclasses import dataclass
13from dataclasses import field
15from django.conf import settings
18class ModelError(Exception):
19 """The model call failed. ``code`` is safe to show and store."""
21 def __init__(self, message: str, *, code: str = "model_error"):
22 super().__init__(message)
23 self.code = code
26@dataclass
27class ModelConfig:
28 model: str
29 api_base: str = ""
30 api_key: str = ""
31 parameters: dict = field(default_factory=dict)
33 @classmethod
34 def from_variant(cls, variant) -> ModelConfig:
35 if variant is None:
36 return cls(model=settings.CHAT_MODEL)
37 api_key = os.environ.get(variant.api_key_env, "") if variant.api_key_env else ""
38 return cls(
39 model=variant.model, api_base=variant.api_base, api_key=api_key, parameters=dict(variant.parameters)
40 )
43@dataclass
44class ToolCall:
45 id: str
46 name: str
47 arguments: dict
50@dataclass
51class Completion:
52 content: str
53 model: str
54 tool_calls: list[ToolCall] = field(default_factory=list)
55 usage: dict = field(default_factory=dict)
56 latency_ms: int = 0
57 # The assistant turn as the model sent it, for replaying tool calls.
58 raw_message: dict = field(default_factory=dict)
61def complete(config: ModelConfig, messages: list[dict], *, tools: list[dict] | None = None) -> Completion:
62 started = time.monotonic()
63 if config.model == "mock":
64 result = _mock_completion(messages, tools=tools)
65 else:
66 result = _litellm_completion(config, messages, tools=tools)
67 result.latency_ms = int((time.monotonic() - started) * 1000)
68 return result
71def _litellm_completion(config: ModelConfig, messages, *, tools):
72 import litellm
74 kwargs = {
75 **config.parameters,
76 "model": config.model,
77 "messages": messages,
78 "timeout": settings.CHAT_MODEL_TIMEOUT,
79 }
80 if config.api_base:
81 kwargs["api_base"] = config.api_base
82 if config.api_key:
83 kwargs["api_key"] = config.api_key
84 if tools:
85 kwargs["tools"] = tools
86 try:
87 response = litellm.completion(**kwargs)
88 except Exception as exc:
89 code = "timeout" if "timeout" in type(exc).__name__.lower() else "model_error"
90 msg = f"Model call failed: {type(exc).__name__}"
91 raise ModelError(msg, code=code) from exc
92 try:
93 message = response.choices[0].message
94 except (AttributeError, IndexError) as exc:
95 msg = "Model returned no choices."
96 raise ModelError(msg, code="empty_response") from exc
97 tool_calls = [
98 ToolCall(id=call.id, name=call.function.name, arguments=_json_arguments(call.function.arguments))
99 for call in (getattr(message, "tool_calls", None) or [])
100 ]
101 usage = getattr(response, "usage", None)
102 return Completion(
103 content=message.content or "",
104 model=getattr(response, "model", None) or config.model,
105 tool_calls=tool_calls,
106 usage={
107 "prompt_tokens": getattr(usage, "prompt_tokens", None),
108 "completion_tokens": getattr(usage, "completion_tokens", None),
109 }
110 if usage
111 else {},
112 raw_message={
113 "role": "assistant",
114 "content": message.content or "",
115 **(
116 {
117 "tool_calls": [
118 {
119 "id": c.id,
120 "type": "function",
121 "function": {"name": c.name, "arguments": json.dumps(c.arguments)},
122 }
123 for c in tool_calls
124 ]
125 }
126 if tool_calls
127 else {}
128 ),
129 },
130 )
133def _json_arguments(arguments) -> dict:
134 if isinstance(arguments, dict):
135 return arguments
136 try:
137 value = json.loads(arguments or "{}")
138 except json.JSONDecodeError:
139 return {}
140 return value if isinstance(value, dict) else {}
143MOCK_FAIL = "[mock-fail]"
144MOCK_TOOL_PREFIX = "[mock-tool "
147def _mock_completion(messages, *, tools):
148 """Deterministic replies for development and tests.
150 - A user message containing ``[mock-fail]`` makes the call fail.
151 - ``[mock-tool name] text`` asks for tool ``name`` with ``{"query": text}``,
152 if that tool is offered; the reply after the tool result quotes it.
153 - Otherwise the reply echoes the last user message.
154 """
155 last = messages[-1]
156 if last["role"] == "tool":
157 reply = f"Here is what I found: {last['content'][:500]}"
158 return Completion(content=reply, model="mock", raw_message={"role": "assistant", "content": reply})
159 text = last.get("content") or ""
160 if MOCK_FAIL in text:
161 msg = "Mock model failure."
162 raise ModelError(msg, code="mock_failure")
163 if text.startswith(MOCK_TOOL_PREFIX) and "]" in text:
164 name, _, query = text[len(MOCK_TOOL_PREFIX) :].partition("]")
165 if any(tool["function"]["name"] == name for tool in tools or []):
166 call = ToolCall(id="mock-call-1", name=name, arguments={"query": query.strip()})
167 raw = {
168 "role": "assistant",
169 "content": "",
170 "tool_calls": [
171 {
172 "id": call.id,
173 "type": "function",
174 "function": {"name": name, "arguments": json.dumps(call.arguments)},
175 }
176 ],
177 }
178 return Completion(content="", model="mock", tool_calls=[call], raw_message=raw)
179 reply = f"You said: {text}"
180 return Completion(content=reply, model="mock", raw_message={"role": "assistant", "content": reply})