lcode-cli 0.14.0__py3-none-any.whl → 0.16.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +13 -1
- lcode/backends.py +430 -0
- lcode/bench.py +10 -5
- lcode/cli.py +104 -19
- lcode/codesearch.py +6 -1
- lcode/config.py +23 -1
- lcode/extensions.py +18 -3
- lcode/hooks.py +241 -0
- lcode/ollama.py +6 -0
- lcode/permissions.py +19 -1
- lcode/repl.py +30 -3
- lcode/subagents.py +15 -4
- lcode/tools.py +46 -6
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.16.0.dist-info}/METADATA +6 -3
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.16.0.dist-info}/RECORD +19 -17
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.16.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.16.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.16.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -216,6 +216,9 @@ class Agent:
|
|
|
216
216
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
217
217
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
218
218
|
self.lsp = None # an lcode.lsp.Manager, set by the CLI when language servers are installed
|
|
219
|
+
from lcode.hooks import HookSet
|
|
220
|
+
|
|
221
|
+
self.hooks = HookSet() # set by the CLI from the settings (lcode.hooks)
|
|
219
222
|
self._repo_map: tuple[Path, repomap.RepoMap, str] | None = None
|
|
220
223
|
self._code_index: tuple[Path, codesearch.Index | None] | None = None
|
|
221
224
|
self.sandbox = (
|
|
@@ -567,7 +570,8 @@ class Agent:
|
|
|
567
570
|
self.console.print(f"[dim]{self.settings.model} does not support reasoning; continuing without.[/]")
|
|
568
571
|
yield from self.chat(messages, tools, False)
|
|
569
572
|
return
|
|
570
|
-
|
|
573
|
+
# Only Ollama loads the model with lcode's settings; other servers manage their own memory.
|
|
574
|
+
if getattr(self.ollama, "kind", "ollama") == "ollama" and any(m in error for m in GPU_MEMORY_ERRORS):
|
|
571
575
|
batch = self.settings.num_batch
|
|
572
576
|
if not started and batch and batch > SAFE_NUM_BATCH:
|
|
573
577
|
# Larger batches read prompts faster but need extra VRAM that isn't always free.
|
|
@@ -722,6 +726,14 @@ class Agent:
|
|
|
722
726
|
self._run_turn(user_text)
|
|
723
727
|
finally:
|
|
724
728
|
self.checkpoints.end_turn()
|
|
729
|
+
if self.hooks.hooks and self.allowed_tools is None:
|
|
730
|
+
payload = {"request": user_text[:2000], "cwd": str(self.cwd), "session": self.session_id}
|
|
731
|
+
for outcome in self.hooks.run("after_request", payload, self.cwd):
|
|
732
|
+
style = "dim" if outcome.code == 0 else "yellow"
|
|
733
|
+
if outcome.output or outcome.code:
|
|
734
|
+
self.console.print(
|
|
735
|
+
Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
|
|
736
|
+
)
|
|
725
737
|
|
|
726
738
|
def sandbox_root(self) -> Path | None:
|
|
727
739
|
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
lcode/backends.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Model servers other than Ollama: anything with an OpenAI-compatible API.
|
|
2
|
+
|
|
3
|
+
LM Studio, llama.cpp's llama-server, vLLM and MLX servers (mlx_lm.server, LM Studio's MLX engine)
|
|
4
|
+
all speak the OpenAI chat completions API, with streaming and tool calls. `OpenAICompatible` gives
|
|
5
|
+
lcode the same interface as its Ollama client, translating on the way: tool calls get ids and
|
|
6
|
+
JSON-string arguments, streamed tool calls arrive in pieces, reasoning text comes as
|
|
7
|
+
`reasoning_content`, and Ollama's `format` becomes `response_format`.
|
|
8
|
+
|
|
9
|
+
What only Ollama can do degrades cleanly: lcode can't unload the model to free the GPU, can't ask
|
|
10
|
+
for a context size (the server has its own; lcode reads it where the server says), and doesn't know
|
|
11
|
+
whether a model can see images unless you set `vision_model`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Iterator
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from lcode.ollama import OllamaError, redact
|
|
25
|
+
|
|
26
|
+
PRESETS = { # backend setting -> (the server's name, its default address)
|
|
27
|
+
"lmstudio": ("LM Studio", "http://localhost:1234/v1"),
|
|
28
|
+
"llama.cpp": ("llama-server", "http://localhost:8080/v1"),
|
|
29
|
+
"vllm": ("vLLM", "http://localhost:8000/v1"),
|
|
30
|
+
"mlx": ("MLX server", "http://localhost:8080/v1"),
|
|
31
|
+
"openai": ("OpenAI-compatible server", ""),
|
|
32
|
+
}
|
|
33
|
+
BACKENDS = ("ollama", *PRESETS)
|
|
34
|
+
DEFAULT_CONTEXT = 32768 # when the server doesn't say its context size
|
|
35
|
+
EMBEDDING_HINTS = ("embed", "bge-", "minilm", "e5-") # model ids that are embedding models, not chat models
|
|
36
|
+
GRAMMAR_ERRORS = ("grammar", "sampler", "response_format", "json_schema") # a server that can't use a schema
|
|
37
|
+
CONTEXT_ERRORS = ("context size", "context length", "context window", "maximum context", "exceeds the")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BackendError(OllamaError):
|
|
41
|
+
"""The model server failed; the agent handles it like an Ollama error."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
IMAGE_TYPES = {"iVBOR": "image/png", "/9j/": "image/jpeg", "R0lGOD": "image/gif", "UklGR": "image/webp"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def with_images(text: str, images: list[str]) -> list[dict]:
|
|
48
|
+
"""A message's text and base64 images (Ollama's `images`) as OpenAI content parts."""
|
|
49
|
+
parts: list[dict] = [{"type": "text", "text": text}]
|
|
50
|
+
for data in images:
|
|
51
|
+
mime = next((t for prefix, t in IMAGE_TYPES.items() if data.startswith(prefix)), "image/png")
|
|
52
|
+
parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}})
|
|
53
|
+
return parts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def to_openai(messages: list[dict]) -> list[dict]:
|
|
57
|
+
"""lcode's messages (Ollama's format) as OpenAI chat messages."""
|
|
58
|
+
out: list[dict] = []
|
|
59
|
+
pending: list[str] = [] # ids of the latest tool calls, for the tool results that follow
|
|
60
|
+
for n, m in enumerate(messages):
|
|
61
|
+
role = m.get("role")
|
|
62
|
+
if role in ("system", "user"):
|
|
63
|
+
text = m.get("content") or ""
|
|
64
|
+
out.append({"role": role, "content": with_images(text, m["images"]) if m.get("images") else text})
|
|
65
|
+
elif role == "assistant":
|
|
66
|
+
message: dict = {"role": "assistant", "content": m.get("content") or ""}
|
|
67
|
+
calls = m.get("tool_calls") or []
|
|
68
|
+
if calls:
|
|
69
|
+
ids = [c.get("id") or f"call_{n}_{i}" for i, c in enumerate(calls)]
|
|
70
|
+
message["tool_calls"] = []
|
|
71
|
+
for call_id, call in zip(ids, calls, strict=False):
|
|
72
|
+
fn = call.get("function") or {}
|
|
73
|
+
args = fn.get("arguments") or {}
|
|
74
|
+
message["tool_calls"].append(
|
|
75
|
+
{
|
|
76
|
+
"id": call_id,
|
|
77
|
+
"type": "function",
|
|
78
|
+
"function": {
|
|
79
|
+
"name": fn.get("name", ""),
|
|
80
|
+
"arguments": args if isinstance(args, str) else json.dumps(args),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
pending = list(ids)
|
|
85
|
+
out.append(message)
|
|
86
|
+
elif role == "tool":
|
|
87
|
+
call_id = m.get("tool_call_id") or (pending.pop(0) if pending else f"call_{n}")
|
|
88
|
+
if m.get("tool_call_id") and call_id in pending:
|
|
89
|
+
pending.remove(call_id)
|
|
90
|
+
out.append({"role": "tool", "tool_call_id": call_id, "content": m.get("content") or ""})
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ThinkSplitter:
|
|
95
|
+
"""Moves a leading <think>…</think> block out of the answer, for servers that don't separate reasoning.
|
|
96
|
+
|
|
97
|
+
vLLM without a reasoning parser, MLX's server and some LM Studio setups stream a reasoning model's
|
|
98
|
+
thinking as ordinary text. The tags can arrive split across chunks.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
OPEN, CLOSE = "<think>", "</think>"
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self.buffer = ""
|
|
105
|
+
self.state = "start" # start -> thinking -> answer, or start -> answer
|
|
106
|
+
self.trim = False # drop the blank lines between the thinking and the answer
|
|
107
|
+
|
|
108
|
+
def feed(self, text: str) -> list[tuple[str, str]]:
|
|
109
|
+
"""("thinking" or "content", text) pieces that are ready to show."""
|
|
110
|
+
self.buffer += text
|
|
111
|
+
out: list[tuple[str, str]] = []
|
|
112
|
+
if self.state == "start":
|
|
113
|
+
head = self.buffer.lstrip()
|
|
114
|
+
if head.startswith(self.OPEN):
|
|
115
|
+
self.state, self.buffer = "thinking", head[len(self.OPEN) :]
|
|
116
|
+
elif not head or self.OPEN.startswith(head):
|
|
117
|
+
return out # maybe the start of the tag: wait for more
|
|
118
|
+
else:
|
|
119
|
+
self.state = "answer"
|
|
120
|
+
if self.state == "thinking":
|
|
121
|
+
end = self.buffer.find(self.CLOSE)
|
|
122
|
+
if end >= 0:
|
|
123
|
+
out.append(("thinking", self.buffer[:end]))
|
|
124
|
+
self.state, self.trim, self.buffer = "answer", True, self.buffer[end + len(self.CLOSE) :]
|
|
125
|
+
else:
|
|
126
|
+
keep = next((n for n in range(len(self.CLOSE) - 1, 0, -1) if self.buffer.endswith(self.CLOSE[:n])), 0)
|
|
127
|
+
ready, self.buffer = self.buffer[: len(self.buffer) - keep], self.buffer[len(self.buffer) - keep :]
|
|
128
|
+
out.append(("thinking", ready))
|
|
129
|
+
if self.state == "answer":
|
|
130
|
+
if self.trim:
|
|
131
|
+
self.buffer = self.buffer.lstrip("\n")
|
|
132
|
+
self.trim = not self.buffer
|
|
133
|
+
out.append(("content", self.buffer))
|
|
134
|
+
self.buffer = ""
|
|
135
|
+
return [(kind, piece) for kind, piece in out if piece]
|
|
136
|
+
|
|
137
|
+
def flush(self) -> list[tuple[str, str]]:
|
|
138
|
+
rest, self.buffer = self.buffer, ""
|
|
139
|
+
return [("thinking" if self.state == "thinking" else "content", rest)] if rest else []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def strip_thinking(text: str) -> str:
|
|
143
|
+
"""An answer without its leading <think>…</think> block."""
|
|
144
|
+
splitter = ThinkSplitter()
|
|
145
|
+
pieces = splitter.feed(text) + splitter.flush()
|
|
146
|
+
return "".join(piece for kind, piece in pieces if kind == "content")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class OpenAICompatible:
|
|
150
|
+
"""A model server with an OpenAI-compatible API, behind the same interface as lcode's Ollama client."""
|
|
151
|
+
|
|
152
|
+
kind = "openai"
|
|
153
|
+
|
|
154
|
+
def __init__(self, base_url: str, api_key: str | None = None, name: str = "OpenAI-compatible server"):
|
|
155
|
+
self.name = name
|
|
156
|
+
self.host = base_url.rstrip("/")
|
|
157
|
+
self.root = self.host.removesuffix("/v1")
|
|
158
|
+
key = api_key if api_key is not None else os.environ.get("LCODE_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
159
|
+
self.headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
160
|
+
|
|
161
|
+
# -- plumbing
|
|
162
|
+
def _url(self, path: str) -> str:
|
|
163
|
+
return f"{self.host}{path}"
|
|
164
|
+
|
|
165
|
+
def describe(self) -> str:
|
|
166
|
+
return f"{self.name} at {redact(self.host)}"
|
|
167
|
+
|
|
168
|
+
def _get(self, url: str, timeout: float = 10) -> dict:
|
|
169
|
+
try:
|
|
170
|
+
r = requests.get(url, headers=self.headers, timeout=timeout)
|
|
171
|
+
except requests.RequestException as e:
|
|
172
|
+
raise BackendError(f"cannot reach the model server at {redact(self.host)}: {e}") from e
|
|
173
|
+
if r.status_code != 200:
|
|
174
|
+
raise BackendError(f"{url.removeprefix(self.root)} failed ({r.status_code}): {r.text[:300]}")
|
|
175
|
+
return r.json()
|
|
176
|
+
|
|
177
|
+
def _payload(self, payload: dict, stream: bool) -> dict:
|
|
178
|
+
body: dict = {"model": payload["model"], "messages": to_openai(payload.get("messages") or []), "stream": stream}
|
|
179
|
+
if payload.get("tools") and not payload.get("format"):
|
|
180
|
+
# An answer in a given format has no tool calls, and llama-server can't combine the two grammars.
|
|
181
|
+
body["tools"] = payload["tools"]
|
|
182
|
+
if stream:
|
|
183
|
+
body["stream_options"] = {"include_usage": True}
|
|
184
|
+
if payload.get("think") is False:
|
|
185
|
+
body["chat_template_kwargs"] = {"enable_thinking": False} # Qwen-style models on vLLM and llama.cpp
|
|
186
|
+
schema = payload.get("format")
|
|
187
|
+
if isinstance(schema, dict):
|
|
188
|
+
body["response_format"] = {"type": "json_schema", "json_schema": {"name": "answer", "schema": schema}}
|
|
189
|
+
elif schema == "json":
|
|
190
|
+
body["response_format"] = {"type": "json_object"}
|
|
191
|
+
if (payload.get("options") or {}).get("num_predict"):
|
|
192
|
+
body["max_tokens"] = payload["options"]["num_predict"]
|
|
193
|
+
return body
|
|
194
|
+
|
|
195
|
+
def _failed(self, status: int, text: str) -> BackendError:
|
|
196
|
+
message = f"{self.name} error {status}: {text[:500]}"
|
|
197
|
+
if any(marker in text.lower() for marker in CONTEXT_ERRORS):
|
|
198
|
+
message += (
|
|
199
|
+
"\nThe conversation no longer fits the server's context window. /compact summarizes it; lcode's "
|
|
200
|
+
"context setting (/ctx) should be no larger than the server's."
|
|
201
|
+
)
|
|
202
|
+
return BackendError(message)
|
|
203
|
+
|
|
204
|
+
# -- what the agent uses
|
|
205
|
+
def version(self) -> str:
|
|
206
|
+
self._get(self._url("/models"))
|
|
207
|
+
return self.name
|
|
208
|
+
|
|
209
|
+
def list_models(self) -> list[dict]:
|
|
210
|
+
return [{"name": m.get("id", ""), **m} for m in self._get(self._url("/models")).get("data") or []]
|
|
211
|
+
|
|
212
|
+
def installed_names(self) -> set[str]:
|
|
213
|
+
return {m["name"] for m in self.list_models()}
|
|
214
|
+
|
|
215
|
+
def chat_models(self) -> list[str]:
|
|
216
|
+
"""The models served for chat (servers also list their embedding models)."""
|
|
217
|
+
return sorted(n for n in self.installed_names() if not is_embedding(n))
|
|
218
|
+
|
|
219
|
+
def show(self, model: str) -> dict:
|
|
220
|
+
return {"capabilities": ["completion", "tools"]} # the API doesn't say; vision_model can name a model
|
|
221
|
+
|
|
222
|
+
def max_context(self, model: str) -> int | None:
|
|
223
|
+
"""The server's context window, where it tells: vLLM, LM Studio or llama-server."""
|
|
224
|
+
try:
|
|
225
|
+
for entry in self.list_models():
|
|
226
|
+
if entry.get("name") == model and entry.get("max_model_len"): # vLLM
|
|
227
|
+
return int(entry["max_model_len"])
|
|
228
|
+
except BackendError:
|
|
229
|
+
return None
|
|
230
|
+
for url, read in (
|
|
231
|
+
# LM Studio: the size the model was loaded with (it loads models with its own default otherwise)
|
|
232
|
+
(f"{self.root}/api/v0/models/{quote(model, safe='')}", lambda d: d.get("loaded_context_length")),
|
|
233
|
+
(f"{self.root}/props", lambda d: (d.get("default_generation_settings") or {}).get("n_ctx")), # llama-server
|
|
234
|
+
): # fmt: skip
|
|
235
|
+
try:
|
|
236
|
+
value = read(self._get(url, timeout=5))
|
|
237
|
+
except (BackendError, ValueError, AttributeError):
|
|
238
|
+
continue
|
|
239
|
+
if value:
|
|
240
|
+
return int(value)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def running(self) -> list[dict]:
|
|
244
|
+
return [] # the server decides what it keeps loaded
|
|
245
|
+
|
|
246
|
+
def load(self, model: str, options: dict, keep_alive: str) -> None:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def unload(self, model: str) -> None:
|
|
250
|
+
pass # not possible through this API: free_gpu tools share the GPU with the model
|
|
251
|
+
|
|
252
|
+
def chat(self, payload: dict, timeout: float = 900) -> dict:
|
|
253
|
+
try:
|
|
254
|
+
r = requests.post(
|
|
255
|
+
self._url("/chat/completions"),
|
|
256
|
+
json=self._payload(payload, False),
|
|
257
|
+
headers=self.headers,
|
|
258
|
+
timeout=timeout,
|
|
259
|
+
)
|
|
260
|
+
except requests.RequestException as e:
|
|
261
|
+
raise BackendError(f"cannot reach the model server at {redact(self.host)}: {e}") from e
|
|
262
|
+
if r.status_code == 400 and payload.get("format") and any(w in r.text.lower() for w in GRAMMAR_ERRORS):
|
|
263
|
+
# The server can't hold the answer to a schema (llama-server can't with some settings):
|
|
264
|
+
# ask without one. The prompt asks for the same JSON, and lcode reads it out of the text.
|
|
265
|
+
return self.chat({k: v for k, v in payload.items() if k not in ("format", "tools")}, timeout)
|
|
266
|
+
if r.status_code != 200:
|
|
267
|
+
raise self._failed(r.status_code, r.text)
|
|
268
|
+
choice = (r.json().get("choices") or [{}])[0].get("message") or {}
|
|
269
|
+
message: dict = {"role": "assistant", "content": strip_thinking(choice.get("content") or "")}
|
|
270
|
+
if choice.get("tool_calls"):
|
|
271
|
+
message["tool_calls"] = [self._ollama_call(c) for c in choice["tool_calls"]]
|
|
272
|
+
return {"message": message}
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _ollama_call(call: dict) -> dict:
|
|
276
|
+
fn = call.get("function") or {}
|
|
277
|
+
raw = fn.get("arguments") or "{}"
|
|
278
|
+
try:
|
|
279
|
+
args = json.loads(raw) if isinstance(raw, str) else raw
|
|
280
|
+
except json.JSONDecodeError as e:
|
|
281
|
+
# The same wording as Ollama's, so the agent asks the model to try again.
|
|
282
|
+
raise BackendError(f"error parsing tool call: raw='{raw[:200]}', err={e}") from e
|
|
283
|
+
return {"id": call.get("id", ""), "function": {"name": fn.get("name", ""), "arguments": args}}
|
|
284
|
+
|
|
285
|
+
def chat_stream(self, payload: dict, on_open: Callable | None = None) -> Iterator[dict]:
|
|
286
|
+
"""Ollama-style chunks from an OpenAI-style stream: content, thinking, then tool calls and counts."""
|
|
287
|
+
started = time.monotonic_ns()
|
|
288
|
+
try:
|
|
289
|
+
r = requests.post(
|
|
290
|
+
self._url("/chat/completions"), json=self._payload(payload, True), headers=self.headers,
|
|
291
|
+
stream=True, timeout=(10, None),
|
|
292
|
+
) # fmt: skip
|
|
293
|
+
except requests.RequestException as e:
|
|
294
|
+
raise BackendError(f"cannot reach the model server at {redact(self.host)}: {e}") from e
|
|
295
|
+
if on_open:
|
|
296
|
+
on_open(r)
|
|
297
|
+
calls: dict[int, dict] = {}
|
|
298
|
+
usage: dict = {}
|
|
299
|
+
timings: dict = {} # llama-server measures prompt and generation time itself
|
|
300
|
+
splitter = ThinkSplitter()
|
|
301
|
+
first_token = None
|
|
302
|
+
with r:
|
|
303
|
+
if r.status_code != 200:
|
|
304
|
+
raise self._failed(r.status_code, r.text)
|
|
305
|
+
for line in r.iter_lines():
|
|
306
|
+
if not line or not line.startswith(b"data:"):
|
|
307
|
+
continue
|
|
308
|
+
data = line[5:].strip()
|
|
309
|
+
if data == b"[DONE]":
|
|
310
|
+
break
|
|
311
|
+
try:
|
|
312
|
+
chunk = json.loads(data)
|
|
313
|
+
except ValueError:
|
|
314
|
+
continue
|
|
315
|
+
if chunk.get("error"):
|
|
316
|
+
error = chunk["error"]
|
|
317
|
+
raise self._failed(0, error.get("message", str(error)) if isinstance(error, dict) else str(error))
|
|
318
|
+
usage = chunk.get("usage") or usage
|
|
319
|
+
timings = chunk.get("timings") or timings
|
|
320
|
+
for choice in chunk.get("choices") or []:
|
|
321
|
+
delta = choice.get("delta") or {}
|
|
322
|
+
thinking = delta.get("reasoning_content") or delta.get("reasoning")
|
|
323
|
+
if thinking:
|
|
324
|
+
first_token = first_token or time.monotonic_ns()
|
|
325
|
+
yield {"message": {"thinking": thinking}, "done": False}
|
|
326
|
+
if delta.get("content"):
|
|
327
|
+
first_token = first_token or time.monotonic_ns()
|
|
328
|
+
for kind, piece in splitter.feed(delta["content"]):
|
|
329
|
+
yield {"message": {kind: piece}, "done": False}
|
|
330
|
+
for piece in delta.get("tool_calls") or []:
|
|
331
|
+
first_token = first_token or time.monotonic_ns()
|
|
332
|
+
slot = calls.setdefault(piece.get("index", len(calls)), {"id": "", "name": "", "arguments": ""})
|
|
333
|
+
fn = piece.get("function") or {}
|
|
334
|
+
slot["id"] = piece.get("id") or slot["id"]
|
|
335
|
+
slot["name"] += fn.get("name") or ""
|
|
336
|
+
slot["arguments"] += fn.get("arguments") or ""
|
|
337
|
+
for kind, piece in splitter.flush():
|
|
338
|
+
yield {"message": {kind: piece}, "done": False}
|
|
339
|
+
if calls:
|
|
340
|
+
tool_calls = [
|
|
341
|
+
self._ollama_call({"id": c["id"], "function": {"name": c["name"], "arguments": c["arguments"] or "{}"}})
|
|
342
|
+
for _, c in sorted(calls.items())
|
|
343
|
+
]
|
|
344
|
+
yield {"message": {"tool_calls": tool_calls}, "done": False}
|
|
345
|
+
ended = time.monotonic_ns()
|
|
346
|
+
first_token = first_token or ended
|
|
347
|
+
final = {
|
|
348
|
+
"message": {},
|
|
349
|
+
"done": True,
|
|
350
|
+
"prompt_eval_count": int(usage.get("prompt_tokens") or 0),
|
|
351
|
+
"prompt_eval_duration": first_token - started,
|
|
352
|
+
"eval_count": int(usage.get("completion_tokens") or 0),
|
|
353
|
+
"eval_duration": max(1, ended - first_token),
|
|
354
|
+
}
|
|
355
|
+
if timings.get("prompt_ms") is not None and timings.get("predicted_ms"):
|
|
356
|
+
# The whole prompt counts (it's what fills the context), cached tokens included.
|
|
357
|
+
prompt = final["prompt_eval_count"] or int(timings.get("prompt_n", 0)) + int(timings.get("cache_n", 0))
|
|
358
|
+
final.update(
|
|
359
|
+
prompt_eval_count=prompt,
|
|
360
|
+
prompt_eval_duration=int(timings["prompt_ms"] * 1e6),
|
|
361
|
+
eval_count=final["eval_count"] or int(timings.get("predicted_n", 0)),
|
|
362
|
+
eval_duration=max(1, int(timings["predicted_ms"] * 1e6)),
|
|
363
|
+
)
|
|
364
|
+
yield final
|
|
365
|
+
|
|
366
|
+
def embed(self, model: str, inputs: list[str], options: dict | None = None, keep_alive: str = "5m") -> list:
|
|
367
|
+
try:
|
|
368
|
+
r = requests.post(
|
|
369
|
+
self._url("/embeddings"), json={"model": model, "input": inputs}, headers=self.headers, timeout=600
|
|
370
|
+
)
|
|
371
|
+
except requests.RequestException as e:
|
|
372
|
+
raise BackendError(f"cannot reach the model server at {redact(self.host)}: {e}") from e
|
|
373
|
+
if r.status_code != 200:
|
|
374
|
+
raise BackendError(f"{self.name} embeddings failed ({r.status_code}): {r.text[:300]}")
|
|
375
|
+
return [item["embedding"] for item in sorted(r.json().get("data") or [], key=lambda d: d.get("index", 0))]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def is_openai(client) -> bool:
|
|
379
|
+
return getattr(client, "kind", "ollama") == "openai"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def is_embedding(model: str) -> bool:
|
|
383
|
+
return any(hint in model.lower() for hint in EMBEDDING_HINTS)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def pick_model(client: OpenAICompatible, name: str) -> str:
|
|
387
|
+
"""The served model `name` stands for: the same id, else the one id that contains it, else the only one."""
|
|
388
|
+
served = client.chat_models()
|
|
389
|
+
if name in served or not served:
|
|
390
|
+
return name # nothing listed: some servers load models on request
|
|
391
|
+
matches = [m for m in served if name.lower() in m.lower()]
|
|
392
|
+
if len(matches) == 1:
|
|
393
|
+
return matches[0]
|
|
394
|
+
if len(served) == 1:
|
|
395
|
+
return served[0] # llama-server serves one model, whatever it's called
|
|
396
|
+
listed = ", ".join(served) if served else "none"
|
|
397
|
+
raise BackendError(
|
|
398
|
+
f"{client.describe()} doesn't serve '{name}'. Its models: {listed}. "
|
|
399
|
+
"Choose one with --model or `lcode config set model <id>`."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def context_for(client: OpenAICompatible, model: str, requested: int | None) -> tuple[int, str]:
|
|
404
|
+
"""lcode's context budget: the server's window (lcode can't change it), or less when asked."""
|
|
405
|
+
try:
|
|
406
|
+
limit = client.max_context(model)
|
|
407
|
+
except BackendError:
|
|
408
|
+
limit = None
|
|
409
|
+
if limit and requested and requested > limit:
|
|
410
|
+
return limit, f"capped at the server's context window of {limit:,} tokens"
|
|
411
|
+
if requested or limit:
|
|
412
|
+
return requested or limit, "" # type: ignore[return-value]
|
|
413
|
+
return DEFAULT_CONTEXT, (
|
|
414
|
+
f"{client.name} doesn't say how large its context window is; lcode assumes {DEFAULT_CONTEXT:,} tokens. "
|
|
415
|
+
"Set the real size with --context or `lcode config set context`"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def connect(cfg: dict):
|
|
420
|
+
"""The model server the settings point at: Ollama, or a server with an OpenAI-compatible API."""
|
|
421
|
+
from lcode.ollama import Ollama
|
|
422
|
+
|
|
423
|
+
backend = cfg.get("backend") or "ollama"
|
|
424
|
+
if backend == "ollama":
|
|
425
|
+
return Ollama(cfg["ollama_host"])
|
|
426
|
+
name, default_url = PRESETS[backend]
|
|
427
|
+
url = cfg.get("base_url") or default_url
|
|
428
|
+
if not url:
|
|
429
|
+
raise BackendError("backend = openai needs the server's address: lcode config set base_url http://host:port/v1")
|
|
430
|
+
return OpenAICompatible(url, name=name)
|
lcode/bench.py
CHANGED
|
@@ -1010,7 +1010,10 @@ def run_model(
|
|
|
1010
1010
|
return run
|
|
1011
1011
|
where = f" · {run.memory_gb} GB, {run.gpu_percent}% on the GPU" if run.memory_gb else ""
|
|
1012
1012
|
reads = f" · reads prompts at {run.prompt_tps:,.0f} tok/s" if run.prompt_tps else ""
|
|
1013
|
-
|
|
1013
|
+
if getattr(ollama, "kind", "ollama") == "ollama":
|
|
1014
|
+
console.print(f" [dim]Loaded in {run.load_seconds:.0f}s{where}{reads}[/]")
|
|
1015
|
+
elif reads: # the server loads the model itself
|
|
1016
|
+
console.print(f" [dim]{reads.removeprefix(' · ').capitalize()}[/]")
|
|
1014
1017
|
for task in tasks:
|
|
1015
1018
|
log = console if verbose else Console(file=io.StringIO(), width=120, force_terminal=False)
|
|
1016
1019
|
if session and shared is None:
|
|
@@ -1099,12 +1102,13 @@ def print_summary(console: Console, runs: list[ModelRun], tasks: list[Task]) ->
|
|
|
1099
1102
|
console.print(f"[yellow]{escape(run.model)}: {escape(run.error)}[/]")
|
|
1100
1103
|
|
|
1101
1104
|
|
|
1102
|
-
def report(runs: list[ModelRun], hardware: Hardware, ollama_version: str) -> dict:
|
|
1105
|
+
def report(runs: list[ModelRun], hardware: Hardware, ollama_version: str, server: str = "") -> dict:
|
|
1103
1106
|
"""The JSON document written by --json (schema version 1)."""
|
|
1104
1107
|
return {
|
|
1105
1108
|
"schema": SCHEMA_VERSION,
|
|
1106
1109
|
"lcode": __version__,
|
|
1107
|
-
"ollama": ollama_version,
|
|
1110
|
+
"ollama": None if server else ollama_version,
|
|
1111
|
+
"server": server or f"Ollama {ollama_version}",
|
|
1108
1112
|
"date": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
1109
1113
|
"hardware": {**asdict(hardware), "description": hardware.describe()},
|
|
1110
1114
|
"tasks": [{"id": t.id, "title": t.title} for t in TASKS],
|
|
@@ -1112,11 +1116,12 @@ def report(runs: list[ModelRun], hardware: Hardware, ollama_version: str) -> dic
|
|
|
1112
1116
|
}
|
|
1113
1117
|
|
|
1114
1118
|
|
|
1115
|
-
def markdown(runs: list[ModelRun], tasks: list[Task], hardware: Hardware, ollama_version: str) -> str:
|
|
1119
|
+
def markdown(runs: list[ModelRun], tasks: list[Task], hardware: Hardware, ollama_version: str, server: str = "") -> str:
|
|
1116
1120
|
"""A table to paste into a model test report."""
|
|
1117
1121
|
date = dt.date.today().isoformat()
|
|
1122
|
+
server = server or f"Ollama {ollama_version}"
|
|
1118
1123
|
lines = [
|
|
1119
|
-
f"**lcode bench** · lcode {__version__} ·
|
|
1124
|
+
f"**lcode bench** · lcode {__version__} · {server} · {hardware.describe()} · {date}",
|
|
1120
1125
|
"",
|
|
1121
1126
|
"| | " + " | ".join(f"`{r.model}`" for r in runs) + " |",
|
|
1122
1127
|
"|---|" + "---|" * len(runs),
|