lcode-cli 0.15.0__py3-none-any.whl → 0.17.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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.15.0"
3
+ __version__ = "0.17.0"
lcode/agent.py CHANGED
@@ -130,7 +130,14 @@ def git_info(cwd: Path) -> str:
130
130
  return "not a git repository"
131
131
  changed = git("status", "--short").stdout.strip().splitlines()
132
132
  log = git("log", "--oneline", "-5").stdout.strip() or "(no commits)"
133
- return f"branch {branch.stdout.strip()}, {len(changed)} changed file(s)\nRecent commits:\n{log}"
133
+ dirs = git("rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").stdout.split()
134
+ worktree = (
135
+ "\nThis folder is a git worktree, separate from the repository's main checkout: read, change and "
136
+ "commit files only here, with paths inside this folder."
137
+ if len(dirs) == 2 and dirs[0] != dirs[1]
138
+ else ""
139
+ )
140
+ return f"branch {branch.stdout.strip()}, {len(changed)} changed file(s){worktree}\nRecent commits:\n{log}"
134
141
  except (OSError, subprocess.TimeoutExpired):
135
142
  return "unknown"
136
143
 
@@ -242,6 +249,7 @@ class Agent:
242
249
  # Set on subagents (see lcode.subagents):
243
250
  self.allowed_tools: set[str] | None = None # None: every tool
244
251
  self.read_only = False # only read-only shell commands
252
+ self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
245
253
  self.cancel: threading.Event | None = None # set from another thread to stop
246
254
  self.on_tool = None # called with (name, arguments) before each tool runs
247
255
  self.response = None # the streaming response, so another thread can abort it
@@ -570,7 +578,8 @@ class Agent:
570
578
  self.console.print(f"[dim]{self.settings.model} does not support reasoning; continuing without.[/]")
571
579
  yield from self.chat(messages, tools, False)
572
580
  return
573
- if any(marker in error for marker in GPU_MEMORY_ERRORS):
581
+ # Only Ollama loads the model with lcode's settings; other servers manage their own memory.
582
+ if getattr(self.ollama, "kind", "ollama") == "ollama" and any(m in error for m in GPU_MEMORY_ERRORS):
574
583
  batch = self.settings.num_batch
575
584
  if not started and batch and batch > SAFE_NUM_BATCH:
576
585
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
@@ -930,4 +939,6 @@ class Agent:
930
939
  else:
931
940
  note = ""
932
941
  self.settings.context = tokens
942
+ if getattr(self.ollama, "kind", "ollama") != "ollama": # the server keeps its own window
943
+ return f"lcode now uses {format_tokens(tokens)} tokens of the context window{note}, and summarizes sooner."
933
944
  return f"Context window set to {format_tokens(tokens)} tokens{note}. The model reloads on the next request."
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
- console.print(f" [dim]Loaded in {run.load_seconds:.0f}s{where}{reads}[/]")
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__} · Ollama {ollama_version} · {hardware.describe()} · {date}",
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),