limbo-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
limbo/history.py ADDED
@@ -0,0 +1,90 @@
1
+ """Message-history bookkeeping for tool calls.
2
+
3
+ Owns the invariant that keeps the OpenAI API happy: every assistant
4
+ ``tool_call`` must have exactly one ``role="tool"`` result message.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from limbo.models import Message
10
+
11
+ INTERRUPTED_CONTENT = "[session restored: tool call interrupted]"
12
+ NOT_EXECUTED_AFTER_FAILURE = "Action not executed: earlier tool failed."
13
+
14
+
15
+ class ToolHistory:
16
+ """Bookkeeper for tool-call results inside a message list."""
17
+
18
+ def __init__(self, messages: list[Message]):
19
+ self.messages = messages
20
+
21
+ def record_result(self, tool_call_id: str, content: str) -> None:
22
+ """Replace the existing result for a call, or append a new one."""
23
+ for idx, msg in enumerate(self.messages):
24
+ if msg.role == "tool" and msg.tool_call_id == tool_call_id:
25
+ self.messages[idx] = msg.model_copy(update={"content": content})
26
+ break
27
+ else:
28
+ self.messages.append(
29
+ Message(role="tool", content=content, tool_call_id=tool_call_id)
30
+ )
31
+
32
+ def record_error(
33
+ self,
34
+ assistant: Message,
35
+ start_idx: int,
36
+ crashed_id: str,
37
+ error_message: str,
38
+ ) -> None:
39
+ """Record an error for a crashed call and cancel its later siblings."""
40
+ tool_calls = assistant.tool_calls or []
41
+ for idx in range(start_idx, len(tool_calls)):
42
+ tc = tool_calls[idx]
43
+ content = (
44
+ error_message
45
+ if tc["id"] == crashed_id
46
+ else NOT_EXECUTED_AFTER_FAILURE
47
+ )
48
+ self.record_result(tc["id"], content)
49
+
50
+
51
+ def repair(messages: list[Message]) -> list[Message]:
52
+ """Prepare persisted messages for reuse as LLM history.
53
+
54
+ - Drops the leading system message (a fresh one is generated on resume).
55
+ - Repairs dangling tool_calls: if the previous session crashed between a
56
+ tool call and its result, placeholder tool messages are inserted
57
+ immediately after the assistant message so the API history stays valid.
58
+ """
59
+ history = list(messages)
60
+ if history and history[0].role == "system":
61
+ history = history[1:]
62
+
63
+ restored: list[Message] = []
64
+ pending_tool_ids: list[str] = []
65
+
66
+ def flush_pending() -> None:
67
+ for tool_id in pending_tool_ids:
68
+ restored.append(
69
+ Message(
70
+ role="tool",
71
+ tool_call_id=tool_id,
72
+ content=INTERRUPTED_CONTENT,
73
+ )
74
+ )
75
+ pending_tool_ids.clear()
76
+
77
+ for msg in history:
78
+ if msg.role == "assistant" and msg.tool_calls:
79
+ flush_pending()
80
+ pending_tool_ids.extend(
81
+ tc["id"] for tc in msg.tool_calls if tc.get("id")
82
+ )
83
+ elif msg.role == "tool":
84
+ if msg.tool_call_id in pending_tool_ids:
85
+ pending_tool_ids.remove(msg.tool_call_id)
86
+ else:
87
+ flush_pending()
88
+ restored.append(msg)
89
+ flush_pending()
90
+ return restored
limbo/llm/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """LLM client package."""
@@ -0,0 +1,320 @@
1
+ """Anthropic Messages API client (e.g. Kimi For Coding).
2
+
3
+ Implemented directly on httpx SSE rather than the anthropic SDK so that
4
+ newer dialect features — adaptive thinking (``thinking: {type: adaptive}``
5
+ plus ``output_config.effort``), thinking signatures, and interleaved
6
+ thinking deltas — work regardless of the installed SDK version.
7
+
8
+ Conventions follow pi's anthropic-messages provider:
9
+
10
+ - API keys authenticate via the ``x-api-key`` header.
11
+ - Reasoning models use adaptive thinking; ``temperature`` is omitted while
12
+ thinking is enabled.
13
+ - Assistant messages replay their thinking block first, carrying the stored
14
+ signature (Kimi accepts empty signatures).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import time
21
+ import warnings
22
+ from collections.abc import AsyncIterator
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from limbo.config import Config
28
+ from limbo.llm.catalog import (
29
+ ModelSpec,
30
+ resolve_api_key,
31
+ resolve_base_url,
32
+ resolve_model,
33
+ )
34
+ from limbo.llm.client import RequestHook
35
+ from limbo.models import (
36
+ CompletionMeta,
37
+ LLMEvent,
38
+ Message,
39
+ TextChunk,
40
+ ThinkingChunk,
41
+ ToolCallEvent,
42
+ )
43
+
44
+ ANTHROPIC_VERSION = "2023-06-01"
45
+
46
+
47
+ class AnthropicMessagesClient:
48
+ """Client for the Anthropic Messages API with SSE streaming."""
49
+
50
+ def __init__(self, config: Config):
51
+ self.config = config
52
+ self.spec: ModelSpec = resolve_model(config.llm.model)
53
+ self._client: httpx.AsyncClient | None = None
54
+
55
+ @property
56
+ def client(self) -> httpx.AsyncClient:
57
+ if self._client is None:
58
+ api_key = resolve_api_key(self.spec, self.config.llm.api_key)
59
+ if not api_key:
60
+ env = self.spec.provider.api_key_env
61
+ raise ValueError(
62
+ f"No API key for provider {self.spec.provider.id!r}. "
63
+ f"Set [llm] api_key in ~/.limbo/config.toml"
64
+ + (f" or the ${env} environment variable." if env else ".")
65
+ )
66
+ self._client = httpx.AsyncClient(
67
+ base_url=resolve_base_url(self.spec, self.config.llm.base_url).rstrip("/"),
68
+ headers={
69
+ "x-api-key": api_key,
70
+ "anthropic-version": ANTHROPIC_VERSION,
71
+ "accept": "application/json",
72
+ **self.spec.provider.headers,
73
+ },
74
+ timeout=httpx.Timeout(600.0, connect=30.0),
75
+ )
76
+ return self._client
77
+
78
+ async def close(self) -> None:
79
+ """Close the underlying HTTP client if it has been created."""
80
+ if self._client is not None:
81
+ await self._client.aclose()
82
+ self._client = None
83
+
84
+ # -- request building ----------------------------------------------------
85
+
86
+ def _thinking_params(self) -> dict[str, Any]:
87
+ """Adaptive thinking config for reasoning models."""
88
+ spec = self.spec
89
+ if not spec.reasoning or spec.thinking_format != "anthropic-adaptive":
90
+ return {}
91
+ params: dict[str, Any] = {
92
+ "thinking": {"type": "adaptive", "display": "summarized"}
93
+ }
94
+ effort = self.config.llm.thinking_effort
95
+ if effort is not None:
96
+ value = spec.thinking_levels.get(effort)
97
+ if value is None:
98
+ warnings.warn(
99
+ f"thinking_effort={effort!r} is not supported by {spec.id} "
100
+ f"(supported: {sorted(spec.thinking_levels)}); ignoring.",
101
+ stacklevel=2,
102
+ )
103
+ else:
104
+ params["output_config"] = {"effort": value}
105
+ return params
106
+
107
+ def _build_body(
108
+ self, messages: list[Message], tools: list[dict[str, Any]]
109
+ ) -> dict[str, Any]:
110
+ system, request_messages = _messages_to_anthropic(messages)
111
+ body: dict[str, Any] = {
112
+ "model": self.config.llm.model,
113
+ "max_tokens": self.config.llm.max_tokens or self.spec.max_tokens,
114
+ "messages": request_messages,
115
+ "stream": True,
116
+ }
117
+ if system:
118
+ body["system"] = system
119
+ body.update(self._thinking_params())
120
+ if "thinking" not in body:
121
+ # Temperature is incompatible with extended thinking.
122
+ body["temperature"] = self.config.llm.temperature
123
+ if tools:
124
+ body["tools"] = [_tool_to_anthropic(t) for t in tools]
125
+ return body
126
+
127
+ # -- streaming -----------------------------------------------------------
128
+
129
+ async def chat(
130
+ self,
131
+ messages: list[Message],
132
+ tools: list[dict[str, Any]],
133
+ on_request: RequestHook | None = None,
134
+ ) -> AsyncIterator[LLMEvent]:
135
+ body = self._build_body(messages, tools)
136
+ if on_request is not None:
137
+ on_request(body)
138
+ start = time.monotonic()
139
+ ttft: float | None = None
140
+ usage: dict[str, Any] = {}
141
+ finish_reason: str | None = None
142
+ async with self.client.stream("POST", "/v1/messages", json=body) as resp:
143
+ if resp.status_code != 200:
144
+ error_body = (await resp.aread()).decode("utf-8", "replace")
145
+ raise RuntimeError(f"Error code: {resp.status_code} - {error_body}")
146
+
147
+ # Tool-use blocks accumulate streamed partial JSON by index.
148
+ tool_blocks: dict[int, dict[str, Any]] = {}
149
+ async for event in _iter_sse(resp):
150
+ if ttft is None:
151
+ ttft = time.monotonic() - start
152
+ event_type = event.get("type")
153
+ if event_type == "message_start":
154
+ # usage here carries input tokens, incl. Anthropic cache
155
+ # counters (cache_read_input_tokens = cache hits).
156
+ message_usage = event.get("message", {}).get("usage")
157
+ if isinstance(message_usage, dict):
158
+ usage.update(message_usage)
159
+ elif event_type == "message_delta":
160
+ delta_usage = event.get("usage")
161
+ if isinstance(delta_usage, dict):
162
+ usage.update(delta_usage)
163
+ stop = event.get("delta", {}).get("stop_reason")
164
+ if stop:
165
+ finish_reason = stop
166
+ elif event_type == "content_block_start":
167
+ block = event.get("content_block", {})
168
+ if block.get("type") == "tool_use":
169
+ tool_blocks[event["index"]] = {
170
+ "id": block.get("id", ""),
171
+ "name": block.get("name", ""),
172
+ "json": "",
173
+ }
174
+ elif event_type == "content_block_delta":
175
+ delta = event.get("delta", {})
176
+ delta_type = delta.get("type")
177
+ if delta_type == "thinking_delta":
178
+ yield ThinkingChunk(text=delta.get("thinking", ""))
179
+ elif delta_type == "signature_delta":
180
+ yield ThinkingChunk(
181
+ text="", signature=delta.get("signature", "")
182
+ )
183
+ elif delta_type == "text_delta":
184
+ yield TextChunk(text=delta.get("text", ""))
185
+ elif delta_type == "input_json_delta":
186
+ block = tool_blocks.get(event["index"])
187
+ if block is not None:
188
+ block["json"] += delta.get("partial_json", "")
189
+ elif event_type == "content_block_stop":
190
+ block = tool_blocks.pop(event["index"], None)
191
+ if block is not None:
192
+ yield _tool_call_event(block)
193
+ elif event_type == "error":
194
+ error = event.get("error", {})
195
+ raise RuntimeError(
196
+ f"{error.get('type', 'error')}: "
197
+ f"{error.get('message', 'unknown stream error')}"
198
+ )
199
+
200
+ # A truncated stream may leave tool blocks open; flush them so the
201
+ # agent loop can surface the (possibly partial) call.
202
+ for index in sorted(tool_blocks):
203
+ yield _tool_call_event(tool_blocks[index])
204
+
205
+ yield CompletionMeta(
206
+ usage=usage or None,
207
+ finish_reason=finish_reason,
208
+ ttft=ttft,
209
+ duration=time.monotonic() - start,
210
+ )
211
+
212
+
213
+ def _tool_call_event(block: dict[str, Any]) -> ToolCallEvent:
214
+ raw = block["json"]
215
+ try:
216
+ args = json.loads(raw) if raw else {}
217
+ except json.JSONDecodeError as e:
218
+ args = {"raw_arguments": raw, "parse_error": str(e)}
219
+ return ToolCallEvent(id=block["id"], name=block["name"], arguments=args)
220
+
221
+
222
+ async def _iter_sse(resp: httpx.Response) -> AsyncIterator[dict[str, Any]]:
223
+ """Yield parsed ``data:`` payloads from an Anthropic SSE stream."""
224
+ data_lines: list[str] = []
225
+ async for line in resp.aiter_lines():
226
+ if not line:
227
+ if data_lines:
228
+ payload = "\n".join(data_lines)
229
+ data_lines = []
230
+ try:
231
+ yield json.loads(payload)
232
+ except json.JSONDecodeError:
233
+ continue
234
+ continue
235
+ if line.startswith("data:"):
236
+ data_lines.append(line[len("data:"):].strip())
237
+ # event:/id:/retry: lines and comments (: ping) are ignored; the
238
+ # payload's own "type" field drives dispatch.
239
+
240
+
241
+ def _tool_to_anthropic(tool: dict[str, Any]) -> dict[str, Any]:
242
+ """Convert an OpenAI function tool definition to Anthropic's shape."""
243
+ function = tool.get("function", tool)
244
+ return {
245
+ "name": function["name"],
246
+ "description": function.get("description", ""),
247
+ "input_schema": function.get("parameters", {"type": "object"}),
248
+ }
249
+
250
+
251
+ def _messages_to_anthropic(
252
+ messages: list[Message],
253
+ ) -> tuple[str, list[dict[str, Any]]]:
254
+ """Convert internal messages to Anthropic's system/messages split.
255
+
256
+ System messages are joined into the top-level ``system`` parameter.
257
+ Consecutive tool results are merged into a single user message, as the
258
+ API requires tool_result blocks inside a user turn.
259
+ """
260
+ system_parts: list[str] = []
261
+ converted: list[dict[str, Any]] = []
262
+
263
+ for message in messages:
264
+ if message.role == "system":
265
+ if message.content:
266
+ system_parts.append(message.content)
267
+ elif message.role == "user":
268
+ converted.append({"role": "user", "content": message.content or ""})
269
+ elif message.role == "assistant":
270
+ converted.append(
271
+ {"role": "assistant", "content": _assistant_blocks(message)}
272
+ )
273
+ elif message.role == "tool":
274
+ block = {
275
+ "type": "tool_result",
276
+ "tool_use_id": message.tool_call_id or "",
277
+ "content": message.content or "",
278
+ }
279
+ previous = converted[-1] if converted else None
280
+ if previous is not None and previous.get("role") == "user" and isinstance(
281
+ previous.get("content"), list
282
+ ):
283
+ previous["content"].append(block)
284
+ else:
285
+ converted.append({"role": "user", "content": [block]})
286
+
287
+ return "\n\n".join(system_parts), converted
288
+
289
+
290
+ def _assistant_blocks(message: Message) -> list[dict[str, Any]]:
291
+ blocks: list[dict[str, Any]] = []
292
+ if message.reasoning:
293
+ # Kimi accepts empty signatures on replayed thinking blocks; real
294
+ # Anthropic models get the stored signature.
295
+ blocks.append(
296
+ {
297
+ "type": "thinking",
298
+ "thinking": message.reasoning,
299
+ "signature": message.reasoning_signature or "",
300
+ }
301
+ )
302
+ if message.content:
303
+ blocks.append({"type": "text", "text": message.content})
304
+ for tc in message.tool_calls or []:
305
+ function = tc.get("function", {})
306
+ arguments = function.get("arguments", {})
307
+ blocks.append(
308
+ {
309
+ "type": "tool_use",
310
+ "id": tc.get("id", ""),
311
+ "name": function.get("name", ""),
312
+ "input": (
313
+ arguments
314
+ if isinstance(arguments, dict)
315
+ else json.loads(arguments or "{}")
316
+ ),
317
+ }
318
+ )
319
+ # The API rejects assistant messages with no content blocks.
320
+ return blocks or [{"type": "text", "text": ""}]
limbo/llm/catalog.py ADDED
@@ -0,0 +1,234 @@
1
+ """Provider/model catalog.
2
+
3
+ Follows pi's two-level metadata approach (built-in ``providers/data/*.json``
4
+ plus ``~/.pi/agent/models.json``):
5
+
6
+ - A **provider** owns the API dialect (``api``), endpoint (``base_url``), and
7
+ credential source (``api_key_env``). The ``api`` value decides which LLM
8
+ client implementation speaks to it (see ``limbo.llm.factory``).
9
+ - A **model** belongs to a provider and carries its own API characteristics —
10
+ context window, max output tokens, reasoning capability, thinking-parameter
11
+ format, and compatibility quirks — so the client adapts request/response
12
+ handling per model instead of assuming one universal dialect.
13
+
14
+ Unknown models fall back to a generic OpenAI-compatible provider with
15
+ conservative defaults, so any OpenAI-compatible endpoint still works via
16
+ ``[llm] base_url`` + ``model``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from dataclasses import dataclass, field
23
+
24
+ DEFAULT_BASE_URL = "https://api.deepseek.com/v1"
25
+ DEFAULT_CONTEXT_WINDOW = 128_000
26
+ DEFAULT_MAX_TOKENS = 16_384
27
+
28
+ # API dialects. Only "openai-completions" has a client implementation today;
29
+ # the factory raises a clear error for the rest until one is added.
30
+ API_OPENAI_COMPLETIONS = "openai-completions"
31
+ API_ANTHROPIC_MESSAGES = "anthropic-messages"
32
+
33
+ # Mainland-China Moonshot endpoint; select via an explicit [llm] base_url
34
+ # override (same API and model ids as the international endpoint).
35
+ MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ProviderSpec:
40
+ """An API provider: dialect + endpoint + credential source."""
41
+
42
+ id: str
43
+ api: str
44
+ base_url: str = ""
45
+ api_key_env: str | None = None
46
+ # Extra headers sent on every request (e.g. Kimi For Coding requires a
47
+ # specific User-Agent).
48
+ headers: dict[str, str] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ModelSpec:
53
+ """Per-model API characteristics."""
54
+
55
+ id: str
56
+ provider: ProviderSpec
57
+ context_window: int = DEFAULT_CONTEXT_WINDOW
58
+ max_tokens: int = DEFAULT_MAX_TOKENS
59
+ reasoning: bool = False
60
+ # How thinking is controlled: "openai" (reasoning_effort parameter),
61
+ # "deepseek" (thinking: {type: enabled|disabled}), or None (the model
62
+ # reasons but the API exposes no switch, e.g. deepseek-reasoner).
63
+ thinking_format: str | None = None
64
+ # Supported thinking levels mapped to provider values (openai format).
65
+ # A level absent from the map is unsupported by the model.
66
+ thinking_levels: dict[str, str] = field(default_factory=dict)
67
+ # Whether thinking can be turned off at all (deepseek format).
68
+ thinking_can_disable: bool = True
69
+ # Replay reasoning_content (empty string when absent) on assistant
70
+ # messages. Kimi K3 rejects tool-call replays without the field.
71
+ requires_reasoning_content: bool = False
72
+
73
+
74
+ DEEPSEEK = ProviderSpec(
75
+ id="deepseek",
76
+ api=API_OPENAI_COMPLETIONS,
77
+ base_url=DEFAULT_BASE_URL,
78
+ api_key_env="DEEPSEEK_API_KEY",
79
+ )
80
+
81
+ MOONSHOT = ProviderSpec(
82
+ id="moonshotai",
83
+ api=API_OPENAI_COMPLETIONS,
84
+ base_url="https://api.moonshot.ai/v1",
85
+ api_key_env="MOONSHOT_API_KEY",
86
+ )
87
+
88
+ # Fallback for models not in the catalog.
89
+ GENERIC_OPENAI = ProviderSpec(id="custom", api=API_OPENAI_COMPLETIONS)
90
+
91
+ # Kimi For Coding (subscription): Anthropic Messages dialect. Mirrors pi's
92
+ # kimi-coding provider (providers/data/kimi-coding.json).
93
+ KIMI_CODING = ProviderSpec(
94
+ id="kimi-coding",
95
+ api=API_ANTHROPIC_MESSAGES,
96
+ base_url="https://api.kimi.com/coding",
97
+ api_key_env="KIMI_API_KEY",
98
+ headers={"User-Agent": "KimiCLI/1.5"},
99
+ )
100
+
101
+
102
+ def _kimi_coding(
103
+ model_id: str,
104
+ *,
105
+ context_window: int = 262_144,
106
+ max_tokens: int = 32_768,
107
+ ) -> ModelSpec:
108
+ # All Kimi For Coding models reason with Anthropic adaptive thinking
109
+ # (thinking: {type: adaptive} + output_config.effort); thinking cannot
110
+ # be disabled. Replayed thinking blocks may carry an empty signature.
111
+ return ModelSpec(
112
+ id=model_id,
113
+ provider=KIMI_CODING,
114
+ context_window=context_window,
115
+ max_tokens=max_tokens,
116
+ reasoning=True,
117
+ thinking_format="anthropic-adaptive",
118
+ thinking_levels={"low": "low", "high": "high", "max": "max"},
119
+ thinking_can_disable=False,
120
+ )
121
+
122
+
123
+ def _moonshot(
124
+ model_id: str,
125
+ *,
126
+ context_window: int = 262_144,
127
+ max_tokens: int = 262_144,
128
+ reasoning: bool = False,
129
+ thinking_format: str | None = None,
130
+ thinking_levels: dict[str, str] | None = None,
131
+ thinking_can_disable: bool = True,
132
+ requires_reasoning_content: bool = False,
133
+ ) -> ModelSpec:
134
+ return ModelSpec(
135
+ id=model_id,
136
+ provider=MOONSHOT,
137
+ context_window=context_window,
138
+ max_tokens=max_tokens,
139
+ reasoning=reasoning,
140
+ thinking_format=thinking_format,
141
+ thinking_levels=thinking_levels or {},
142
+ thinking_can_disable=thinking_can_disable,
143
+ requires_reasoning_content=requires_reasoning_content,
144
+ )
145
+
146
+
147
+ # Values mirror pi's built-in moonshotai catalog (providers/data/moonshotai.json).
148
+ CATALOG: dict[str, ModelSpec] = {
149
+ "deepseek-chat": ModelSpec(
150
+ id="deepseek-chat", provider=DEEPSEEK, max_tokens=8_192
151
+ ),
152
+ "deepseek-reasoner": ModelSpec(
153
+ id="deepseek-reasoner",
154
+ provider=DEEPSEEK,
155
+ max_tokens=65_536,
156
+ reasoning=True,
157
+ ),
158
+ # -- Kimi (Moonshot AI) -------------------------------------------------
159
+ "kimi-k2-0711-preview": _moonshot(
160
+ "kimi-k2-0711-preview", context_window=131_072, max_tokens=16_384
161
+ ),
162
+ "kimi-k2-0905-preview": _moonshot("kimi-k2-0905-preview"),
163
+ "kimi-k2-turbo-preview": _moonshot("kimi-k2-turbo-preview"),
164
+ "kimi-k2-thinking": _moonshot(
165
+ "kimi-k2-thinking", reasoning=True, thinking_format="deepseek"
166
+ ),
167
+ "kimi-k2-thinking-turbo": _moonshot(
168
+ "kimi-k2-thinking-turbo", reasoning=True, thinking_format="deepseek"
169
+ ),
170
+ "kimi-k2.5": _moonshot(
171
+ "kimi-k2.5", reasoning=True, thinking_format="deepseek"
172
+ ),
173
+ "kimi-k2.6": _moonshot(
174
+ "kimi-k2.6", reasoning=True, thinking_format="deepseek"
175
+ ),
176
+ "kimi-k2.7-code": _moonshot(
177
+ "kimi-k2.7-code",
178
+ reasoning=True,
179
+ thinking_format="deepseek",
180
+ thinking_can_disable=False,
181
+ ),
182
+ "kimi-k2.7-code-highspeed": _moonshot(
183
+ "kimi-k2.7-code-highspeed",
184
+ reasoning=True,
185
+ thinking_format="deepseek",
186
+ thinking_can_disable=False,
187
+ ),
188
+ # Kimi K3: 1M context, OpenAI-style reasoning_effort, thinking cannot be
189
+ # disabled, and assistant replays must carry reasoning_content.
190
+ "kimi-k3": _moonshot(
191
+ "kimi-k3",
192
+ context_window=1_048_576,
193
+ max_tokens=131_072,
194
+ reasoning=True,
195
+ thinking_format="openai",
196
+ thinking_levels={"low": "low", "high": "high", "max": "max"},
197
+ thinking_can_disable=False,
198
+ requires_reasoning_content=True,
199
+ ),
200
+ # -- Kimi For Coding (Anthropic Messages dialect) ------------------------
201
+ "k3": _kimi_coding(
202
+ "k3", context_window=1_048_576, max_tokens=131_072
203
+ ),
204
+ "kimi-for-coding": _kimi_coding("kimi-for-coding"),
205
+ "kimi-for-coding-highspeed": _kimi_coding("kimi-for-coding-highspeed"),
206
+ }
207
+
208
+
209
+ def resolve_model(model_id: str) -> ModelSpec:
210
+ """Look up a model; unknown ids get a generic OpenAI-compatible spec."""
211
+ return CATALOG.get(model_id) or ModelSpec(id=model_id, provider=GENERIC_OPENAI)
212
+
213
+
214
+ def resolve_base_url(spec: ModelSpec, configured: str) -> str:
215
+ """Resolve the effective base URL for a model.
216
+
217
+ An explicitly configured ``base_url`` always wins. When it is left at the
218
+ global DeepSeek default but the catalog knows a different endpoint for the
219
+ model's provider (e.g. switching to ``kimi-k3`` without editing
220
+ ``base_url``), the provider endpoint is used so model switching works out
221
+ of the box.
222
+ """
223
+ if configured != DEFAULT_BASE_URL or not spec.provider.base_url:
224
+ return configured
225
+ return spec.provider.base_url
226
+
227
+
228
+ def resolve_api_key(spec: ModelSpec, configured: str | None) -> str | None:
229
+ """Resolve the API key: config first, then the provider's env var."""
230
+ if configured:
231
+ return configured
232
+ if spec.provider.api_key_env:
233
+ return os.environ.get(spec.provider.api_key_env)
234
+ return None
limbo/llm/client.py ADDED
@@ -0,0 +1,21 @@
1
+ """LLM client protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+ from typing import Any, Protocol
7
+
8
+ from limbo.models import LLMEvent, Message
9
+
10
+ # Called with the exact request body (or SDK kwargs) about to be sent, on
11
+ # every HTTP attempt. Used for trace logging; must not mutate the body.
12
+ RequestHook = Callable[[dict[str, Any]], None]
13
+
14
+
15
+ class LLMClient(Protocol):
16
+ def chat(
17
+ self,
18
+ messages: list[Message],
19
+ tools: list[dict[str, Any]],
20
+ on_request: RequestHook | None = None,
21
+ ) -> AsyncIterator[LLMEvent]: ...