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 +1 -1
- lcode/agent.py +2 -1
- lcode/backends.py +430 -0
- lcode/bench.py +10 -5
- lcode/cli.py +87 -18
- lcode/codesearch.py +6 -1
- lcode/config.py +17 -0
- lcode/ollama.py +6 -0
- lcode/repl.py +13 -3
- {lcode_cli-0.15.0.dist-info → lcode_cli-0.16.0.dist-info}/METADATA +3 -3
- {lcode_cli-0.15.0.dist-info → lcode_cli-0.16.0.dist-info}/RECORD +14 -13
- {lcode_cli-0.15.0.dist-info → lcode_cli-0.16.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.15.0.dist-info → lcode_cli-0.16.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.15.0.dist-info → lcode_cli-0.16.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
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),
|
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
|
-
|
|
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 =
|
|
314
|
+
ollama = connect(cfg)
|
|
315
|
+
openai = backends.is_openai(ollama)
|
|
261
316
|
try:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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 =
|
|
543
|
+
ollama = connect(cfg)
|
|
479
544
|
hw = detect()
|
|
480
545
|
version = check_ollama(ollama, hw)
|
|
481
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
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 <
|
|
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.
|
|
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=
|
|
1
|
+
lcode/__init__.py,sha256=zacfVVvqhWmN_-V62Fl2VjpnxCnSmt1Y75XJ4_2KJ64,118
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
4
|
-
lcode/
|
|
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=
|
|
9
|
-
lcode/codesearch.py,sha256=
|
|
10
|
-
lcode/config.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
48
|
-
lcode_cli-0.
|
|
49
|
-
lcode_cli-0.
|
|
50
|
-
lcode_cli-0.
|
|
51
|
-
lcode_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|