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/__init__.py +3 -0
- limbo/__main__.py +6 -0
- limbo/agent.py +456 -0
- limbo/app.py +107 -0
- limbo/config.py +99 -0
- limbo/history.py +90 -0
- limbo/llm/__init__.py +1 -0
- limbo/llm/anthropic_client.py +320 -0
- limbo/llm/catalog.py +234 -0
- limbo/llm/client.py +21 -0
- limbo/llm/factory.py +46 -0
- limbo/llm/openai_client.py +261 -0
- limbo/models.py +81 -0
- limbo/sessions.py +258 -0
- limbo/skills.py +89 -0
- limbo/tools/__init__.py +1 -0
- limbo/tools/base.py +102 -0
- limbo/tools/bash.py +172 -0
- limbo/tools/edit.py +70 -0
- limbo/tools/find.py +71 -0
- limbo/tools/grep.py +182 -0
- limbo/tools/ignore.py +111 -0
- limbo/tools/ls.py +41 -0
- limbo/tools/read.py +105 -0
- limbo/tools/registry.py +66 -0
- limbo/tools/write.py +34 -0
- limbo/trace.py +100 -0
- limbo/ui/__init__.py +1 -0
- limbo/ui/app.py +52 -0
- limbo/ui/app.tcss +174 -0
- limbo/ui/banner.py +83 -0
- limbo/ui/commands.py +61 -0
- limbo/ui/screens/__init__.py +1 -0
- limbo/ui/screens/game2048.py +213 -0
- limbo/ui/screens/main.py +369 -0
- limbo/ui/screens/session_picker.py +59 -0
- limbo/ui/widgets/__init__.py +1 -0
- limbo/ui/widgets/chat.py +149 -0
- limbo/ui/widgets/command_menu.py +45 -0
- limbo/ui/widgets/input.py +199 -0
- limbo/ui/widgets/status_bar.py +32 -0
- limbo/ui/widgets/tool_card.py +179 -0
- limbo_code-0.1.0.dist-info/METADATA +16 -0
- limbo_code-0.1.0.dist-info/RECORD +46 -0
- limbo_code-0.1.0.dist-info/WHEEL +4 -0
- limbo_code-0.1.0.dist-info/entry_points.txt +2 -0
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]: ...
|