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/llm/factory.py ADDED
@@ -0,0 +1,46 @@
1
+ """LLM client factory.
2
+
3
+ Selects the client implementation for the model's provider API dialect
4
+ (``ProviderSpec.api``). New dialects (e.g. ``anthropic-messages`` for Kimi's
5
+ coding endpoint) plug in via ``register_client`` without touching call sites.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from limbo.config import Config
13
+ from limbo.llm.anthropic_client import AnthropicMessagesClient
14
+ from limbo.llm.catalog import (
15
+ API_ANTHROPIC_MESSAGES,
16
+ API_OPENAI_COMPLETIONS,
17
+ resolve_model,
18
+ )
19
+ from limbo.llm.client import LLMClient
20
+ from limbo.llm.openai_client import OpenAICompatibleClient
21
+
22
+ ClientFactory = Callable[[Config], LLMClient]
23
+
24
+ _FACTORIES: dict[str, ClientFactory] = {
25
+ API_OPENAI_COMPLETIONS: OpenAICompatibleClient,
26
+ API_ANTHROPIC_MESSAGES: AnthropicMessagesClient,
27
+ }
28
+
29
+
30
+ def register_client(api: str, factory: ClientFactory) -> None:
31
+ """Register a client factory for a provider API dialect."""
32
+ _FACTORIES[api] = factory
33
+
34
+
35
+ def create_llm_client(config: Config) -> LLMClient:
36
+ """Create the LLM client for the configured model's provider."""
37
+ spec = resolve_model(config.llm.model)
38
+ factory = _FACTORIES.get(spec.provider.api)
39
+ if factory is None:
40
+ supported = ", ".join(sorted(_FACTORIES))
41
+ raise ValueError(
42
+ f"No LLM client implementation for API dialect "
43
+ f"{spec.provider.api!r} (model {spec.id!r}, provider "
44
+ f"{spec.provider.id!r}). Supported dialects: {supported}."
45
+ )
46
+ return factory(config)
@@ -0,0 +1,261 @@
1
+ """OpenAI-compatible LLM client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import warnings
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any
10
+
11
+ from openai import AsyncOpenAI, BadRequestError
12
+
13
+ from limbo.config import Config
14
+ from limbo.llm.catalog import (
15
+ ModelSpec,
16
+ resolve_api_key,
17
+ resolve_base_url,
18
+ resolve_model,
19
+ )
20
+ from limbo.llm.client import RequestHook
21
+ from limbo.models import (
22
+ CompletionMeta,
23
+ LLMEvent,
24
+ Message,
25
+ TextChunk,
26
+ ThinkingChunk,
27
+ ToolCallEvent,
28
+ )
29
+
30
+
31
+ class OpenAICompatibleClient:
32
+ """Client for OpenAI-compatible chat completions with streaming.
33
+
34
+ Adapts request parameters and stream parsing to the model's catalog spec
35
+ (see ``limbo.llm.catalog``): per-model ``max_tokens``, thinking-parameter
36
+ format (``reasoning_effort`` vs ``thinking: {type: ...}``), and
37
+ ``reasoning_content`` deltas/replay for reasoning models such as Kimi K3.
38
+ """
39
+
40
+ def __init__(self, config: Config):
41
+ self.config = config
42
+ self.spec: ModelSpec = resolve_model(config.llm.model)
43
+ self._client: AsyncOpenAI | None = None
44
+ # Whether to ask for streamed token usage. Disabled permanently after
45
+ # a provider rejects the stream_options parameter.
46
+ self._include_usage = True
47
+
48
+ @property
49
+ def client(self) -> AsyncOpenAI:
50
+ if self._client is None:
51
+ api_key = resolve_api_key(self.spec, self.config.llm.api_key)
52
+ if not api_key:
53
+ env = self.spec.provider.api_key_env
54
+ raise ValueError(
55
+ f"No API key for provider {self.spec.provider.id!r}. "
56
+ f"Set [llm] api_key in ~/.limbo/config.toml"
57
+ + (f" or the ${env} environment variable." if env else ".")
58
+ )
59
+ self._client = AsyncOpenAI(
60
+ api_key=api_key,
61
+ base_url=resolve_base_url(self.spec, self.config.llm.base_url),
62
+ )
63
+ return self._client
64
+
65
+ async def close(self) -> None:
66
+ """Close the underlying HTTP client if it has been created."""
67
+ if self._client is not None:
68
+ await self._client.close()
69
+ self._client = None
70
+
71
+ def _thinking_params(self) -> dict[str, Any]:
72
+ """Build thinking-control parameters for the model's dialect."""
73
+ spec = self.spec
74
+ if not spec.reasoning or spec.thinking_format is None:
75
+ return {}
76
+ effort = self.config.llm.thinking_effort
77
+ if effort is None:
78
+ return {}
79
+ if spec.thinking_format == "openai":
80
+ value = spec.thinking_levels.get(effort)
81
+ if value is None:
82
+ warnings.warn(
83
+ f"thinking_effort={effort!r} is not supported by {spec.id} "
84
+ f"(supported: {sorted(spec.thinking_levels)}); ignoring.",
85
+ stacklevel=2,
86
+ )
87
+ return {}
88
+ return {"reasoning_effort": value}
89
+ if spec.thinking_format == "deepseek":
90
+ if effort == "off":
91
+ if not spec.thinking_can_disable:
92
+ warnings.warn(
93
+ f"{spec.id} does not support disabling thinking; ignoring.",
94
+ stacklevel=2,
95
+ )
96
+ return {}
97
+ return {"thinking": {"type": "disabled"}}
98
+ return {"thinking": {"type": "enabled"}}
99
+ return {}
100
+
101
+ async def chat(
102
+ self,
103
+ messages: list[Message],
104
+ tools: list[dict[str, Any]],
105
+ on_request: RequestHook | None = None,
106
+ ) -> AsyncIterator[LLMEvent]:
107
+ include_reasoning = self.spec.requires_reasoning_content
108
+ request_messages = [
109
+ _message_to_openai(m, include_reasoning=include_reasoning)
110
+ for m in messages
111
+ ]
112
+ kwargs: dict[str, Any] = {
113
+ "model": self.config.llm.model,
114
+ "messages": request_messages,
115
+ "temperature": self.config.llm.temperature,
116
+ "max_tokens": self.config.llm.max_tokens or self.spec.max_tokens,
117
+ "stream": True,
118
+ }
119
+ thinking = self._thinking_params()
120
+ if self.spec.thinking_format == "deepseek" and thinking:
121
+ # `thinking` is not an OpenAI SDK parameter; pass it through the
122
+ # request body untouched (Kimi K2 thinking dialect).
123
+ kwargs["extra_body"] = thinking
124
+ else:
125
+ kwargs.update(thinking)
126
+ if tools:
127
+ kwargs["tools"] = tools
128
+ if self._include_usage:
129
+ kwargs["stream_options"] = {"include_usage": True}
130
+
131
+ start = time.monotonic()
132
+ try:
133
+ stream = await self._create(kwargs, on_request)
134
+ except BadRequestError as e:
135
+ # Some providers reject stream_options; retry once without it and
136
+ # stop asking for usage on later requests.
137
+ if not self._include_usage or not _is_stream_options_error(e):
138
+ raise
139
+ self._include_usage = False
140
+ kwargs.pop("stream_options", None)
141
+ stream = await self._create(kwargs, on_request)
142
+
143
+ usage: dict[str, Any] | None = None
144
+ finish_reason: str | None = None
145
+ ttft: float | None = None
146
+ active_calls: dict[int, dict[str, Any]] = {}
147
+ async for chunk in stream:
148
+ if ttft is None:
149
+ ttft = time.monotonic() - start
150
+ if chunk.usage is not None:
151
+ # The final chunk carries usage when include_usage is on.
152
+ # model_dump keeps provider extras such as DeepSeek's
153
+ # prompt_cache_hit_tokens / prompt_cache_miss_tokens.
154
+ try:
155
+ usage = chunk.usage.model_dump()
156
+ except Exception: # noqa: BLE001
157
+ usage = {"raw": str(chunk.usage)}
158
+ if not chunk.choices:
159
+ continue
160
+ if chunk.choices[0].finish_reason:
161
+ finish_reason = chunk.choices[0].finish_reason
162
+ delta = chunk.choices[0].delta
163
+ # Reasoning models stream thinking in a separate field; the name
164
+ # varies by provider (Moonshot/DeepSeek: reasoning_content).
165
+ reasoning = None
166
+ for attr in ("reasoning_content", "reasoning"):
167
+ value = getattr(delta, attr, None)
168
+ if isinstance(value, str) and value:
169
+ reasoning = value
170
+ break
171
+ if reasoning:
172
+ yield ThinkingChunk(text=reasoning)
173
+ if delta.content:
174
+ yield TextChunk(text=delta.content)
175
+
176
+ if delta.tool_calls:
177
+ for tc in delta.tool_calls:
178
+ idx = tc.index
179
+ if idx not in active_calls:
180
+ active_calls[idx] = {
181
+ "id": tc.id or "",
182
+ "name": "",
183
+ "arguments": "",
184
+ }
185
+ current = active_calls[idx]
186
+ if tc.id:
187
+ current["id"] = tc.id
188
+ if tc.function:
189
+ if tc.function.name:
190
+ current["name"] += tc.function.name
191
+ if tc.function.arguments:
192
+ current["arguments"] += tc.function.arguments
193
+
194
+ for idx in sorted(active_calls):
195
+ call = active_calls[idx]
196
+ raw_arguments = call["arguments"]
197
+ try:
198
+ args = json.loads(raw_arguments) if raw_arguments else {}
199
+ except json.JSONDecodeError as e:
200
+ args = {
201
+ "raw_arguments": raw_arguments,
202
+ "parse_error": str(e),
203
+ }
204
+ yield ToolCallEvent(
205
+ id=call["id"] or f"call_{idx}",
206
+ name=call["name"],
207
+ arguments=args,
208
+ )
209
+
210
+ yield CompletionMeta(
211
+ usage=usage,
212
+ finish_reason=finish_reason,
213
+ ttft=ttft,
214
+ duration=time.monotonic() - start,
215
+ )
216
+
217
+ async def _create(
218
+ self, kwargs: dict[str, Any], on_request: RequestHook | None
219
+ ):
220
+ """Fire one HTTP attempt, reporting the exact body via the hook."""
221
+ if on_request is not None:
222
+ on_request(kwargs)
223
+ return await self.client.chat.completions.create(**kwargs)
224
+
225
+
226
+ def _is_stream_options_error(error: BadRequestError) -> bool:
227
+ message = str(error).lower()
228
+ return "stream_options" in message or "include_usage" in message
229
+
230
+
231
+ def _message_to_openai(
232
+ message: Message, *, include_reasoning: bool = False
233
+ ) -> dict[str, Any]:
234
+ m: dict[str, Any] = {"role": message.role}
235
+ # OpenAI requires `content` on assistant and tool messages, even when empty.
236
+ m["content"] = message.content or ""
237
+ if include_reasoning and message.role == "assistant":
238
+ # Kimi K3 requires reasoning_content on replayed assistant messages;
239
+ # send the stored thinking or an empty string.
240
+ m["reasoning_content"] = message.reasoning or ""
241
+ if message.tool_calls:
242
+ # The OpenAI SDK requires function.arguments to be a JSON string.
243
+ # Internal messages keep arguments as a dict for validation; serialize
244
+ # only when building the API request.
245
+ m["tool_calls"] = [
246
+ {
247
+ **tc,
248
+ "function": {
249
+ **tc["function"],
250
+ "arguments": (
251
+ json.dumps(tc["function"]["arguments"])
252
+ if isinstance(tc["function"]["arguments"], dict)
253
+ else tc["function"]["arguments"]
254
+ ),
255
+ },
256
+ }
257
+ for tc in message.tool_calls
258
+ ]
259
+ if message.tool_call_id:
260
+ m["tool_call_id"] = message.tool_call_id
261
+ return m
limbo/models.py ADDED
@@ -0,0 +1,81 @@
1
+ """Shared data models for Limbo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class Message(BaseModel):
12
+ """A chat message in the conversation."""
13
+
14
+ role: str # system | user | assistant | tool
15
+ content: str | None = None
16
+ tool_calls: list[dict[str, Any]] | None = None
17
+ tool_call_id: str | None = None
18
+ # Reasoning/thinking text produced by reasoning models. Stored so it can
19
+ # be replayed to APIs that require it (e.g. Kimi K3) on later turns.
20
+ reasoning: str | None = None
21
+ # Signature authenticating a replayed thinking block (Anthropic dialect).
22
+ reasoning_signature: str | None = None
23
+
24
+
25
+ class ToolCall(BaseModel):
26
+ """A tool call requested by the LLM."""
27
+
28
+ id: str
29
+ name: str
30
+ arguments: dict[str, Any]
31
+
32
+
33
+ class ToolResult(BaseModel):
34
+ """Result of executing a tool."""
35
+
36
+ success: bool
37
+ output: str | None = None
38
+ error: str | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TextChunk:
43
+ text: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ThinkingChunk:
48
+ """A streamed reasoning/thinking delta from a reasoning model."""
49
+
50
+ text: str
51
+ # Thinking-block signature (Anthropic dialect); carried on an empty-text
52
+ # chunk at the end of a thinking block.
53
+ signature: str | None = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ToolCallEvent:
58
+ id: str
59
+ name: str
60
+ arguments: dict[str, Any]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CompletionMeta:
65
+ """Response-level metadata emitted once per LLM call, after all chunks.
66
+
67
+ Carries whatever the provider returned: token usage (including cache-hit
68
+ counters where the provider exposes them), the finish/stop reason, and
69
+ client-measured timing. All fields may be None — providers differ widely
70
+ in what they report.
71
+ """
72
+
73
+ usage: dict[str, Any] | None = None
74
+ finish_reason: str | None = None
75
+ # Seconds from request start to the first streamed chunk.
76
+ ttft: float | None = None
77
+ # Seconds from request start to the end of the stream.
78
+ duration: float | None = None
79
+
80
+
81
+ LLMEvent = TextChunk | ThinkingChunk | ToolCallEvent | CompletionMeta
limbo/sessions.py ADDED
@@ -0,0 +1,258 @@
1
+ """Session storage: save, load, list, find, and export sessions.
2
+
3
+ Sessions are stored as JSONL files. The first line is a metadata record
4
+ (``{"type": "meta", ...}``) followed by one message per line. Files written
5
+ by older versions (no meta line) still load — the id falls back to the file
6
+ stem and the title is empty.
7
+
8
+ This module has no UI dependencies.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from limbo.models import Message
22
+ from limbo.trace import read_trace
23
+
24
+ META_TYPE = "meta"
25
+ SNAPSHOT_TYPE = "messages_snapshot"
26
+
27
+
28
+ class SessionNotFoundError(Exception):
29
+ """Raised when no session matches a lookup."""
30
+
31
+
32
+ class AmbiguousSessionError(Exception):
33
+ """Raised when an id prefix matches multiple sessions."""
34
+
35
+ def __init__(self, prefix: str, matches: list[str]):
36
+ self.prefix = prefix
37
+ self.matches = matches
38
+ super().__init__(
39
+ f"Session id prefix '{prefix}' is ambiguous, matches: "
40
+ + ", ".join(matches)
41
+ )
42
+
43
+
44
+ class SessionMeta(BaseModel):
45
+ """Metadata describing a session (stored as the first JSONL line)."""
46
+
47
+ id: str
48
+ workdir: str = ""
49
+ model: str = ""
50
+ title: str = ""
51
+ created_at: str = ""
52
+ updated_at: str = ""
53
+ # Populated by list/load; not written to disk.
54
+ path: Path | None = None
55
+
56
+
57
+ def _utc_now_iso() -> str:
58
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
59
+
60
+
61
+ def derive_title(messages: list[Message], max_length: int = 50) -> str:
62
+ """Derive a session title from the first user message."""
63
+ for msg in messages:
64
+ if msg.role == "user" and msg.content:
65
+ title = " ".join(msg.content.split())
66
+ if len(title) > max_length:
67
+ title = title[: max_length - 1].rstrip() + "…"
68
+ return title
69
+ return ""
70
+
71
+
72
+ def save_session(path: Path, meta: SessionMeta, messages: list[Message]) -> None:
73
+ """Atomically write the whole session (meta line + message lines)."""
74
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
75
+ meta.updated_at = _utc_now_iso()
76
+ meta_line = json.dumps(
77
+ {"type": META_TYPE, **meta.model_dump(exclude={"path"})},
78
+ ensure_ascii=False,
79
+ )
80
+ tmp_file = path.with_suffix(".tmp")
81
+ file_existed = path.exists()
82
+ with tmp_file.open("w", encoding="utf-8") as f:
83
+ f.write(meta_line + "\n")
84
+ for msg in messages:
85
+ f.write(msg.model_dump_json() + "\n")
86
+ os.replace(tmp_file, path)
87
+ if not file_existed:
88
+ path.chmod(0o600)
89
+
90
+
91
+ def _parse_meta(raw: dict[str, Any], fallback_id: str, path: Path) -> SessionMeta:
92
+ data = {k: v for k, v in raw.items() if k != "type"}
93
+ data.setdefault("id", fallback_id)
94
+ return SessionMeta(path=path, **data)
95
+
96
+
97
+ def load_session(path: Path) -> tuple[SessionMeta, list[Message]]:
98
+ """Load a session file, tolerating legacy files and malformed lines."""
99
+ meta: SessionMeta | None = None
100
+ messages: list[Message] = []
101
+ with path.open("r", encoding="utf-8") as f:
102
+ for line in f:
103
+ line = line.strip()
104
+ if not line:
105
+ continue
106
+ try:
107
+ record = json.loads(line)
108
+ except json.JSONDecodeError:
109
+ continue
110
+ if record.get("type") == META_TYPE:
111
+ meta = _parse_meta(record, path.stem, path)
112
+ continue
113
+ try:
114
+ messages.append(Message(**record))
115
+ except ValueError:
116
+ continue
117
+ if meta is None:
118
+ # Legacy file without a meta line.
119
+ meta = SessionMeta(id=path.stem, path=path)
120
+ return meta, messages
121
+
122
+
123
+ def _read_meta_line(path: Path) -> SessionMeta:
124
+ """Read only the first line of a session file (O(1) listing)."""
125
+ try:
126
+ with path.open("r", encoding="utf-8") as f:
127
+ first = f.readline().strip()
128
+ except OSError:
129
+ first = ""
130
+ if first:
131
+ try:
132
+ record = json.loads(first)
133
+ if record.get("type") == META_TYPE:
134
+ return _parse_meta(record, path.stem, path)
135
+ except (json.JSONDecodeError, ValueError):
136
+ pass
137
+ # Legacy file: fall back to stem + mtime for sorting.
138
+ mtime = path.stat().st_mtime
139
+ updated = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(
140
+ timespec="seconds"
141
+ )
142
+ return SessionMeta(id=path.stem, updated_at=updated, path=path)
143
+
144
+
145
+ def list_sessions(
146
+ session_dir: Path, workdir: Path | None = None
147
+ ) -> list[SessionMeta]:
148
+ """List sessions, most recently updated first.
149
+
150
+ When ``workdir`` is given, only sessions recorded for that directory are
151
+ returned.
152
+ """
153
+ if not session_dir.is_dir():
154
+ return []
155
+ wanted = str(workdir.resolve()) if workdir is not None else None
156
+ sessions: list[SessionMeta] = []
157
+ for path in session_dir.glob("*.jsonl"):
158
+ try:
159
+ meta = _read_meta_line(path)
160
+ except OSError:
161
+ continue
162
+ if wanted is not None and meta.workdir != wanted:
163
+ continue
164
+ sessions.append(meta)
165
+ sessions.sort(key=lambda m: m.updated_at, reverse=True)
166
+ return sessions
167
+
168
+
169
+ def latest_session(
170
+ session_dir: Path, workdir: Path | None = None
171
+ ) -> Path | None:
172
+ """Return the path of the most recently updated session, if any."""
173
+ sessions = list_sessions(session_dir, workdir=workdir)
174
+ if not sessions:
175
+ return None
176
+ return sessions[0].path
177
+
178
+
179
+ def find_session(session_dir: Path, id_prefix: str) -> Path:
180
+ """Find a session by exact id or unique id prefix."""
181
+ if not session_dir.is_dir():
182
+ raise SessionNotFoundError(f"No sessions in {session_dir}")
183
+ matches = sorted(p.stem for p in session_dir.glob(f"{id_prefix}*.jsonl"))
184
+ if not matches:
185
+ raise SessionNotFoundError(f"No session matching '{id_prefix}'")
186
+ if len(matches) > 1:
187
+ raise AmbiguousSessionError(id_prefix, matches)
188
+ return session_dir / f"{matches[0]}.jsonl"
189
+
190
+
191
+ def export_markdown(
192
+ meta: SessionMeta, messages: list[Message], path: Path
193
+ ) -> None:
194
+ """Export the conversation (user/assistant text) as a Markdown file."""
195
+ lines = [
196
+ f"# {meta.title or meta.id}",
197
+ "",
198
+ f"- workdir: `{meta.workdir}`",
199
+ f"- model: `{meta.model}`",
200
+ f"- updated: {meta.updated_at}",
201
+ "",
202
+ ]
203
+ for msg in messages:
204
+ if msg.role == "user" and msg.content:
205
+ lines += ["## User", "", msg.content, ""]
206
+ elif msg.role == "assistant" and msg.content:
207
+ lines += ["## Assistant", "", msg.content, ""]
208
+ path.parent.mkdir(parents=True, exist_ok=True)
209
+ path.write_text("\n".join(lines), encoding="utf-8")
210
+
211
+
212
+ def export_jsonl(
213
+ meta: SessionMeta,
214
+ messages: list[Message],
215
+ path: Path,
216
+ trace_path: Path | None = None,
217
+ ) -> None:
218
+ """Export the full-fidelity session log as a single JSONL file.
219
+
220
+ Layout:
221
+
222
+ 1. a ``meta`` record (session metadata + export timestamp),
223
+ 2. every trace record in chronological order (LLM request bodies,
224
+ usage, tool calls/results, errors) when the trace
225
+ file exists — otherwise the raw conversation messages,
226
+ 3. a final ``messages_snapshot`` record with the exact message history
227
+ as persisted.
228
+ """
229
+ meta_line = json.dumps(
230
+ {
231
+ "type": META_TYPE,
232
+ **meta.model_dump(exclude={"path"}),
233
+ "exported_at": _utc_now_iso(),
234
+ },
235
+ ensure_ascii=False,
236
+ )
237
+ path.parent.mkdir(parents=True, exist_ok=True)
238
+ with path.open("w", encoding="utf-8") as f:
239
+ f.write(meta_line + "\n")
240
+ trace_records = read_trace(trace_path) if trace_path is not None else []
241
+ if trace_records:
242
+ for record in trace_records:
243
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
244
+ else:
245
+ # Sessions from before tracing existed still export their messages.
246
+ for msg in messages:
247
+ f.write(msg.model_dump_json() + "\n")
248
+ snapshot = json.dumps(
249
+ {
250
+ "type": SNAPSHOT_TYPE,
251
+ "count": len(messages),
252
+ "messages": [
253
+ json.loads(msg.model_dump_json()) for msg in messages
254
+ ],
255
+ },
256
+ ensure_ascii=False,
257
+ )
258
+ f.write(snapshot + "\n")