lcode-cli 0.15.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 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.16.0"
lcode/agent.py CHANGED
@@ -570,7 +570,8 @@ class Agent:
570
570
  self.console.print(f"[dim]{self.settings.model} does not support reasoning; continuing without.[/]")
571
571
  yield from self.chat(messages, tools, False)
572
572
  return
573
- if any(marker in error for marker in GPU_MEMORY_ERRORS):
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):
574
575
  batch = self.settings.num_batch
575
576
  if not started and batch and batch > SAFE_NUM_BATCH:
576
577
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
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),
lcode/cli.py CHANGED
@@ -12,11 +12,12 @@ from pathlib import Path
12
12
 
13
13
  import requests
14
14
  from rich.console import Console
15
+ from rich.markup import escape
15
16
  from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TimeRemainingColumn, TransferSpeedColumn
16
17
  from rich.prompt import Confirm
17
18
  from rich.table import Table
18
19
 
19
- from lcode import __version__, catalog, config, limits, web
20
+ from lcode import __version__, backends, catalog, config, limits, web
20
21
  from lcode.agent import Agent, Settings
21
22
  from lcode.catalog import ModelSpec
22
23
  from lcode.config import ConfigError, format_tokens, parse_context
@@ -40,7 +41,25 @@ def fail(message: str, code: int = 1):
40
41
  sys.exit(code)
41
42
 
42
43
 
44
+ def connect(cfg: dict) -> Ollama:
45
+ """The model server the settings point at (an OpenAI-compatible one has the same interface)."""
46
+ if cfg["backend"] == "ollama":
47
+ return Ollama(cfg["ollama_host"])
48
+ try:
49
+ return backends.connect(cfg)
50
+ except OllamaError as e:
51
+ fail(str(e))
52
+
53
+
43
54
  def check_ollama(ollama: Ollama, hw: Hardware | None = None) -> str:
55
+ if backends.is_openai(ollama):
56
+ try:
57
+ return ollama.version()
58
+ except OllamaError as e:
59
+ fail(
60
+ f"{e}\n\nIs {ollama.name} running, with its server started? lcode expects its OpenAI-compatible "
61
+ "API there; change the address with: lcode config set base_url http://host:port/v1"
62
+ )
44
63
  try:
45
64
  version = ollama.version()
46
65
  except OllamaError as e:
@@ -52,7 +71,12 @@ def check_ollama(ollama: Ollama, hw: Hardware | None = None) -> str:
52
71
 
53
72
 
54
73
  def resolve_model(ollama: Ollama, name: str) -> tuple[str, ModelSpec | None]:
55
- """Map a catalog key or Ollama tag to an installed Ollama model name."""
74
+ """Map a catalog key or Ollama tag to an installed Ollama model name (or a model the server serves)."""
75
+ if backends.is_openai(ollama):
76
+ try:
77
+ return backends.pick_model(ollama, name), None
78
+ except OllamaError as e:
79
+ raise NotInstalled(str(e)) from e
56
80
  spec = catalog.find(name)
57
81
  installed = ollama.installed_names()
58
82
  if spec:
@@ -72,6 +96,8 @@ def choose_context(
72
96
  ollama: Ollama, model: str, spec: ModelSpec | None, requested: int | None, hw: Hardware
73
97
  ) -> tuple[int, str]:
74
98
  """Pick the context window: requested > largest that fits (catalog models) > 32K. Capped at the model max."""
99
+ if backends.is_openai(ollama):
100
+ return backends.context_for(ollama, model, requested)
75
101
  try:
76
102
  limit = ollama.max_context(model) or (spec.max_context if spec else None)
77
103
  except OllamaError:
@@ -96,7 +122,29 @@ def choose_context(
96
122
  # ----------------------------------------------------------------------------- lcode models
97
123
 
98
124
 
125
+ def print_served(client: backends.OpenAICompatible, current: str | None) -> None:
126
+ try:
127
+ served = client.chat_models()
128
+ current = backends.pick_model(client, current) if current else None
129
+ except OllamaError as e:
130
+ if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
131
+ console.print(f"[red]{e}[/]")
132
+ return
133
+ console.print(f"[bold]Models served by {escape(client.describe())}[/]")
134
+ for name in served:
135
+ console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
136
+ if not served:
137
+ console.print(" none listed")
138
+ console.print(
139
+ "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
140
+ "or [/]lcode config set model <id>[dim].[/]"
141
+ )
142
+
143
+
99
144
  def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
145
+ if ollama is not None and backends.is_openai(ollama):
146
+ print_served(ollama, current) # type: ignore[arg-type]
147
+ return
100
148
  try:
101
149
  installed = ollama.installed_names() if ollama else set()
102
150
  except OllamaError:
@@ -141,8 +189,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
141
189
 
142
190
  def cmd_models(args) -> None:
143
191
  cfg = config.load()
144
- ollama = Ollama(cfg["ollama_host"])
145
- print_models(ollama, detect(), cfg["model"])
192
+ print_models(connect(cfg), detect(), cfg["model"])
146
193
 
147
194
 
148
195
  # ----------------------------------------------------------------------------- lcode setup
@@ -173,6 +220,13 @@ def pull_with_progress(ollama: Ollama, tag: str) -> None:
173
220
 
174
221
  def cmd_setup(args) -> None:
175
222
  cfg = config.load()
223
+ if cfg["backend"] != "ollama":
224
+ name = backends.PRESETS[cfg["backend"]][0]
225
+ fail(
226
+ f"lcode setup downloads models into Ollama, but lcode uses {name} (backend = {cfg['backend']}). "
227
+ f"Download and load a model in {name}, then: lcode config set model <id> (see lcode models). "
228
+ "Back to Ollama: lcode config set backend ollama"
229
+ )
176
230
  ollama = Ollama(cfg["ollama_host"])
177
231
  hw = detect()
178
232
  console.print(f"[bold]Machine:[/] {hw.describe()}")
@@ -257,15 +311,24 @@ def cmd_doctor(args) -> None:
257
311
  f"{config.CONFIG_PATH}" + ("" if config.CONFIG_PATH.exists() else " (not created yet)"),
258
312
  True if config.CONFIG_PATH.exists() else None,
259
313
  )
260
- ollama = Ollama(cfg["ollama_host"])
314
+ ollama = connect(cfg)
315
+ openai = backends.is_openai(ollama)
261
316
  try:
262
- version = ollama.version()
263
- fresh = version_tuple(version) >= MIN_VERSION
264
- line("Ollama", f"{redact(ollama.host)} · version {version}" + ("" if fresh else " (too old)"), fresh)
265
- ok &= fresh
317
+ if openai:
318
+ ollama.version()
319
+ served = ollama.chat_models() # type: ignore[attr-defined]
320
+ line("Server", f"{ollama.describe()} · {len(served)} model(s) served", True)
321
+ else:
322
+ version = ollama.version()
323
+ fresh = version_tuple(version) >= MIN_VERSION
324
+ line("Ollama", f"{redact(ollama.host)} · version {version}" + ("" if fresh else " (too old)"), fresh)
325
+ ok &= fresh
266
326
  except OllamaError as e:
267
- line("Ollama", f"{e}", False)
268
- line("", f"install: {OLLAMA_INSTALL.get(hw.os, OLLAMA_INSTALL['linux'])}", None)
327
+ line("Server" if openai else "Ollama", f"{e}", False)
328
+ if openai:
329
+ line("", f"start {ollama.name}'s server, or set its address: lcode config set base_url <url>", None)
330
+ else:
331
+ line("", f"install: {OLLAMA_INSTALL.get(hw.os, OLLAMA_INSTALL['linux'])}", None)
269
332
  sys.exit(1)
270
333
  try:
271
334
  model, spec = resolve_model(ollama, cfg["model"])
@@ -288,6 +351,8 @@ def cmd_doctor(args) -> None:
288
351
  f"{seer} looks at screenshots and images" + ("" if seer == model else " (loaded when needed)"),
289
352
  True,
290
353
  )
354
+ elif openai:
355
+ line("Vision", "off; set vision_model to a model the server serves that can see images", None)
291
356
  else:
292
357
  line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
293
358
  if limits.get(model):
@@ -475,10 +540,11 @@ def cmd_bench(args) -> None:
475
540
  fail(str(e))
476
541
  if args.timeout <= 0:
477
542
  fail("--timeout must be a positive number of seconds")
478
- ollama = Ollama(cfg["ollama_host"])
543
+ ollama = connect(cfg)
479
544
  hw = detect()
480
545
  version = check_ollama(ollama, hw)
481
- console.print(f"[bold]lcode bench[/] · {hw.describe()} · Ollama {version}")
546
+ server = ollama.describe() if backends.is_openai(ollama) else ""
547
+ console.print(f"[bold]lcode bench[/] · {hw.describe()} · {server or f'Ollama {version}'}")
482
548
  console.print("[dim]Each task runs in a new temporary folder with every permission granted and web access off.[/]")
483
549
  names = args.models or [cfg["model"]]
484
550
  resolved: dict[str, tuple[str, ModelSpec | None] | NotInstalled] = {}
@@ -535,10 +601,10 @@ def cmd_bench(args) -> None:
535
601
  bench.print_summary(console, runs, tasks)
536
602
  if args.json:
537
603
  path = Path(args.json).expanduser()
538
- bench.write_json(path, bench.report(runs, hw, version))
604
+ bench.write_json(path, bench.report(runs, hw, version, server))
539
605
  console.print(f"Results saved to {path}")
540
606
  if args.markdown:
541
- print("\n" + bench.markdown(runs, tasks, hw, version))
607
+ print("\n" + bench.markdown(runs, tasks, hw, version, server))
542
608
  if args.keep:
543
609
  console.print(f"[dim]Task folders are kept in {tempfile.gettempdir()} (lcode-bench-*).[/]")
544
610
 
@@ -560,13 +626,14 @@ def cmd_chat(args) -> None:
560
626
  cwd = Path(args.repo).expanduser().resolve()
561
627
  if not cwd.is_dir():
562
628
  fail(f"not a directory: {cwd}")
563
- ollama = Ollama(cfg["ollama_host"])
629
+ ollama = connect(cfg)
564
630
  hw = detect()
565
631
  check_ollama(ollama, hw)
566
632
  try:
567
633
  model, spec = resolve_model(ollama, args.model or cfg["model"])
568
634
  except NotInstalled as e:
569
- if not args.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists():
635
+ first_run = not args.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
636
+ if first_run and not backends.is_openai(ollama):
570
637
  fail("lcode isn't set up yet. Run: lcode setup")
571
638
  fail(str(e))
572
639
  context, note = choose_context(ollama, model, spec, requested_ctx, hw)
@@ -744,11 +811,13 @@ def cmd_index(args) -> None:
744
811
 
745
812
  cfg = config.load()
746
813
  root = work_tree_for(Path(args.repo).expanduser().resolve())
747
- ollama = Ollama(cfg["ollama_host"])
814
+ ollama = connect(cfg)
748
815
  setting = args.model or cfg["embed_model"]
749
816
  if setting == "off":
750
817
  fail("semantic code search is off (lcode config set embed_model auto)")
751
818
  model = codesearch.pick_model(ollama, "auto" if setting == "off" else setting)
819
+ if model is None and backends.is_openai(ollama):
820
+ fail(f"{ollama.describe()} serves no embedding model: load one there (e.g. qwen3-embedding-0.6b)")
752
821
  if model is None:
753
822
  fail(
754
823
  "no embedding model is installed. Install one, for example:\n ollama pull qwen3-embedding:0.6b\n"
lcode/codesearch.py CHANGED
@@ -53,7 +53,12 @@ def pick_model(ollama: Ollama, setting: str) -> str | None:
53
53
  return None
54
54
  if setting != "auto":
55
55
  return setting if setting in installed or f"{setting}:latest" in installed else None
56
- return next((m for m in MODELS if m in installed), None)
56
+ found = next((m for m in MODELS if m in installed), None)
57
+ if found is None and getattr(ollama, "kind", "ollama") == "openai": # e.g. LM Studio's text-embedding-…
58
+ from lcode.backends import is_embedding
59
+
60
+ found = next((m for m in sorted(installed) if is_embedding(m)), None)
61
+ return found
57
62
 
58
63
 
59
64
  def index_dir(root: Path) -> Path:
lcode/config.py CHANGED
@@ -7,6 +7,7 @@ import os
7
7
  import re
8
8
  import sys
9
9
  from pathlib import Path
10
+ from urllib.parse import urlsplit
10
11
 
11
12
  if sys.version_info >= (3, 11):
12
13
  import tomllib
@@ -36,6 +37,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
36
37
  "num_batch": (None, int, "prompt batch size; larger reads prompts faster but needs more VRAM"),
37
38
  "keep_alive": ("30m", str, "how long Ollama keeps the model loaded after the last request"),
38
39
  "ollama_host": ("http://localhost:11434", str, "Ollama server URL"),
40
+ "backend": ("ollama", str, "model server: ollama | lmstudio | llama.cpp | vllm | mlx | openai (any OpenAI API)"),
41
+ "base_url": (None, str, "OpenAI-compatible server address, e.g. http://localhost:1234/v1 (default: the backend's)"),
39
42
  "permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
40
43
  "think": (True, bool, "let the model reason before answering (slower, better)"),
41
44
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
@@ -62,6 +65,8 @@ ENV_OVERRIDES = {
62
65
  "LCODE_NUM_BATCH": "num_batch",
63
66
  "LCODE_KEEP_ALIVE": "keep_alive",
64
67
  "OLLAMA_HOST": "ollama_host",
68
+ "LCODE_BACKEND": "backend",
69
+ "LCODE_BASE_URL": "base_url",
65
70
  "LCODE_WEB": "web",
66
71
  "LCODE_SANDBOX": "sandbox",
67
72
  "LCODE_MEMORY": "memory",
@@ -111,6 +116,18 @@ def coerce(key: str, value: object) -> object:
111
116
  return parse_context(value) # type: ignore[arg-type]
112
117
  if key in ("ollama_host", "searxng_url"):
113
118
  return normalize_host(str(value))
119
+ if key == "base_url":
120
+ url = normalize_host(str(value))
121
+ return url if urlsplit(url).path.strip("/") else url + "/v1" # the OpenAI API lives under /v1
122
+ if key == "backend":
123
+ from lcode.backends import BACKENDS
124
+
125
+ value = {"lm-studio": "lmstudio", "llamacpp": "llama.cpp", "llama-server": "llama.cpp"}.get(
126
+ str(value).lower(), str(value).lower()
127
+ )
128
+ if value not in BACKENDS:
129
+ raise ConfigError(f"backend must be one of {', '.join(BACKENDS)}")
130
+ return value
114
131
  if key == "web":
115
132
  value = {"true": "on", "false": "off", "yes": "on", "no": "off"}.get(str(value).lower(), str(value).lower())
116
133
  if value not in WEB_MODES:
lcode/ollama.py CHANGED
@@ -32,9 +32,15 @@ def version_tuple(version: str) -> tuple[int, ...]:
32
32
 
33
33
 
34
34
  class Ollama:
35
+ kind = "ollama"
36
+ name = "Ollama"
37
+
35
38
  def __init__(self, host: str):
36
39
  self.host = host.rstrip("/")
37
40
 
41
+ def describe(self) -> str:
42
+ return f"Ollama at {redact(self.host)}"
43
+
38
44
  def _url(self, path: str) -> str:
39
45
  return f"{self.host}{path}"
40
46
 
lcode/repl.py CHANGED
@@ -26,7 +26,7 @@ from rich.rule import Rule
26
26
  from rich.table import Table
27
27
  from rich.text import Text
28
28
 
29
- from lcode import __version__, catalog, extensions, limits, sessions, web
29
+ from lcode import __version__, backends, catalog, extensions, limits, sessions, web
30
30
  from lcode import context as context_tools
31
31
  from lcode import memory as memory_notes
32
32
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -149,7 +149,9 @@ def banner(agent: Agent) -> None:
149
149
  Group(
150
150
  Text.from_markup(f"[bold cyan]lcode[/] [dim]v{__version__}[/] — local coding agent\n"),
151
151
  Text.from_markup(
152
- f"[dim]model[/] {s.model}\n"
152
+ f"[dim]model[/] {escape(s.model)}"
153
+ + (f" [dim]· {escape(agent.ollama.describe())}[/]" if backends.is_openai(agent.ollama) else "")
154
+ + "\n"
153
155
  f"[dim]context[/] {format_tokens(s.context)} tokens\n"
154
156
  f"[dim]cwd[/] {agent.cwd}\n"
155
157
  f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
@@ -221,6 +223,12 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
221
223
  c.print(f"MCP tools: ~{format_tokens(agent.mcp.cost())} tokens of definitions, {how}.")
222
224
  if not arg:
223
225
  print_breakdown(agent)
226
+ if not arg and backends.is_openai(agent.ollama):
227
+ c.print(
228
+ f"[dim]{agent.ollama.name} sets the context window when it loads the model; lcode uses "
229
+ f"{format_tokens(s.context)} of it. To summarize sooner: /ctx 16k[/]"
230
+ )
231
+ return
224
232
  if arg:
225
233
  try:
226
234
  apply_context(agent, parse_context(arg), hardware)
@@ -838,7 +846,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
838
846
  print_models(agent.ollama, hardware, s.model)
839
847
  elif cmd == "/model":
840
848
  if not arg:
841
- c.print(f"Model: {s.model}. Switch with /model <key or Ollama tag>; list with /models.")
849
+ c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
842
850
  else:
843
851
  try:
844
852
  name, spec = resolve_model(agent.ollama, arg)
@@ -849,6 +857,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
849
857
  s.num_batch = spec.num_batch if spec and name == spec.local_name else None
850
858
  if spec: # size the context for the new model: largest window that fits this machine
851
859
  s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
860
+ elif backends.is_openai(agent.ollama):
861
+ s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
852
862
  agent.messages[0]["content"] = agent.system_prompt()
853
863
  c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
854
864
  elif cmd == "/think":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -102,7 +102,7 @@ through [Ollama](https://ollama.com), and when it needs current information it c
102
102
  session as short notes, saved by the model, by you (`/remember`) or by a quick check at the end
103
103
  of a session. Not whole conversations, and never secrets.
104
104
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
105
- with tool calling.
105
+ with tool calling. Or use LM Studio, llama.cpp, vLLM or MLX instead of Ollama.
106
106
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
107
107
  Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes, Metabase, Encord, Valohai and more with one command
108
108
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
@@ -208,8 +208,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
208
208
 
209
209
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
210
210
 
211
- - [More local backends: LM Studio, llama.cpp, vLLM and MLX](https://github.com/nasser1941/lcode/issues/47)
212
211
  - [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
212
+ - [Automation: JSON output, a Python API and a GitHub Action for self-hosted runners](https://github.com/nasser1941/lcode/issues/48)
213
213
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
214
214
 
215
215
  ## Contributing
@@ -1,13 +1,14 @@
1
- lcode/__init__.py,sha256=nt1m5TjCnM-J0wx2xHbNb3L4c1vNJmM3nIma1bcOi5E,118
1
+ lcode/__init__.py,sha256=zacfVVvqhWmN_-V62Fl2VjpnxCnSmt1Y75XJ4_2KJ64,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=7eWmKnxRAYEvGYGkukmd2EGfWg_LYhEQdBJQljMK_Uk,46113
4
- lcode/bench.py,sha256=7XHJZmRVUt3JYYEyIIMX9fcqOFIvfKB9Iy-2aPo00VU,40406
3
+ lcode/agent.py,sha256=ZApMmW8TE4SMD5nc2TCE7M0U_VCwHq1xIl97hmFGcfo,46262
4
+ lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
5
+ lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
5
6
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
6
7
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
7
8
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
8
- lcode/cli.py,sha256=bcASJ0qpSbAi-rmqqLqpySHtckf6rQ777CjB82ll8C0,37091
9
- lcode/codesearch.py,sha256=_Z7D_Snpl0NwfXk6P4yUJ0VmwLKCy74YLc52DjUUdTU,10510
10
- lcode/config.py,sha256=5JdLA04cxB7gEf6JGJYbIlqIgjo9tNNiT26osEdJ9zI,9190
9
+ lcode/cli.py,sha256=QAnmYqJ36Wyhp5WLI0DC6S-KIVgzx8vZ8Iv-rQLmqEk,40289
10
+ lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
11
+ lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
11
12
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
12
13
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
13
14
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
@@ -17,11 +18,11 @@ lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
17
18
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
18
19
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
19
20
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
20
- lcode/ollama.py,sha256=edSckfbtK0iSH-Pm-IpUHO_73nPaRokh7cHiDBSKvWU,7594
21
+ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
21
22
  lcode/permissions.py,sha256=bSDpkSzq1tozC3LgzAzXmJX_DGRxoY9a3Kdx-AFQ3nI,5426
22
23
  lcode/planning.py,sha256=hZjVka7i9TswJrXLbjbZJ0PBc6zHpmO64JqMiaFYnMQ,6110
23
24
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
24
- lcode/repl.py,sha256=l3LHCNRZD7Kk03k5hk6GJKrm62ToQT9FPjKtW3xwsAc,45540
25
+ lcode/repl.py,sha256=PRO4VG-s8kSowcHJ3svYHpHFBU0s4nsvsth8dyKG6mE,46117
25
26
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
26
27
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
27
28
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
@@ -44,8 +45,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
44
45
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
45
46
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
46
47
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
47
- lcode_cli-0.15.0.dist-info/METADATA,sha256=BSaWKw3B8zJZ55eweihAEyScAXH3KT3fHgfBTdTQJQk,12040
48
- lcode_cli-0.15.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
49
- lcode_cli-0.15.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
50
- lcode_cli-0.15.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
51
- lcode_cli-0.15.0.dist-info/RECORD,,
48
+ lcode_cli-0.16.0.dist-info/METADATA,sha256=vHaeTD3ZW6w2pDT_LhDDlfyVYrIa3lprUbWUof4WJtE,12126
49
+ lcode_cli-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
+ lcode_cli-0.16.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
51
+ lcode_cli-0.16.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
52
+ lcode_cli-0.16.0.dist-info/RECORD,,