lcode-cli 0.35.1__py3-none-any.whl → 0.37.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 +3 -2
- lcode/cli.py +89 -21
- lcode/cloud.py +46 -0
- lcode/escalate.py +14 -2
- lcode/ollama.py +6 -0
- lcode/repl.py +88 -31
- lcode/residency.py +3 -0
- {lcode_cli-0.35.1.dist-info → lcode_cli-0.37.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.35.1.dist-info → lcode_cli-0.37.0.dist-info}/RECORD +13 -12
- {lcode_cli-0.35.1.dist-info → lcode_cli-0.37.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.35.1.dist-info → lcode_cli-0.37.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.35.1.dist-info → lcode_cli-0.37.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -45,6 +45,7 @@ from lcode import (
|
|
|
45
45
|
from lcode import context as context_tools
|
|
46
46
|
from lcode import memory as memory_notes
|
|
47
47
|
from lcode.checkpoints import Checkpoints
|
|
48
|
+
from lcode.cloud import is_cloud
|
|
48
49
|
from lcode.config import format_tokens
|
|
49
50
|
from lcode.jobs import Jobs
|
|
50
51
|
from lcode.mcp import McpManager
|
|
@@ -1011,8 +1012,8 @@ class Agent:
|
|
|
1011
1012
|
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
1012
1013
|
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
1013
1014
|
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
1014
|
-
if getattr(self.ollama, "kind", "ollama") != "ollama":
|
|
1015
|
-
return None
|
|
1015
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama" or is_cloud(self.settings.model):
|
|
1016
|
+
return None # another server loads models itself; an Ollama cloud model isn't loaded here at all
|
|
1016
1017
|
model, context = self.settings.model, self.settings.context
|
|
1017
1018
|
fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
|
|
1018
1019
|
where = residency.placement(self.ollama, model)
|
lcode/cli.py
CHANGED
|
@@ -21,6 +21,7 @@ from rich.table import Table
|
|
|
21
21
|
from lcode import __version__, backends, catalog, config, limits, web
|
|
22
22
|
from lcode.agent import Settings
|
|
23
23
|
from lcode.catalog import ModelSpec
|
|
24
|
+
from lcode.cloud import is_cloud, signin_command
|
|
24
25
|
from lcode.config import ConfigError, format_tokens, parse_context
|
|
25
26
|
from lcode.hardware import Hardware, detect
|
|
26
27
|
from lcode.ollama import MIN_VERSION, Ollama, OllamaError, redact, version_tuple
|
|
@@ -113,44 +114,96 @@ def choose_context(
|
|
|
113
114
|
# ----------------------------------------------------------------------------- lcode models
|
|
114
115
|
|
|
115
116
|
|
|
116
|
-
def print_served(
|
|
117
|
+
def print_served(
|
|
118
|
+
client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
out = out or console
|
|
117
121
|
try:
|
|
118
122
|
served = client.chat_models()
|
|
119
123
|
current = backends.pick_model(client, current) if current else None
|
|
120
124
|
except OllamaError as e:
|
|
121
125
|
if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
|
|
122
|
-
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
out.print(f"[red]{e}[/]")
|
|
127
|
+
return []
|
|
128
|
+
served = []
|
|
129
|
+
out.print(f"[bold]Models served by {escape(client.describe())}[/]")
|
|
130
|
+
for i, name in enumerate(served, 1):
|
|
131
|
+
number = f"[cyan]{i:>3}[/] " if numbered else " "
|
|
132
|
+
out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
|
|
127
133
|
if not served:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
out.print(" none listed")
|
|
135
|
+
if not numbered:
|
|
136
|
+
out.print(
|
|
137
|
+
"[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
|
|
138
|
+
"or [/]lcode config set model <id>[dim].[/]"
|
|
139
|
+
)
|
|
140
|
+
return served
|
|
133
141
|
|
|
134
142
|
|
|
135
|
-
def
|
|
143
|
+
def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
|
|
144
|
+
"""Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
|
|
145
|
+
Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
|
|
146
|
+
known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
|
|
147
|
+
usable = []
|
|
148
|
+
for name in sorted({n.removesuffix(":latest") for n in installed} - known):
|
|
149
|
+
try:
|
|
150
|
+
capabilities = ollama.show(name).get("capabilities") or []
|
|
151
|
+
if "completion" in capabilities and "tools" in capabilities:
|
|
152
|
+
usable.append(name)
|
|
153
|
+
except OllamaError:
|
|
154
|
+
continue
|
|
155
|
+
return usable
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def model_choices(ollama: Ollama | None) -> list[str]:
|
|
159
|
+
"""What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
|
|
160
|
+
For an OpenAI-compatible server, the models it serves."""
|
|
136
161
|
if ollama is not None and backends.is_openai(ollama):
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
try:
|
|
163
|
+
return ollama.chat_models() # type: ignore[union-attr]
|
|
164
|
+
except OllamaError:
|
|
165
|
+
return []
|
|
166
|
+
try:
|
|
167
|
+
installed = ollama.installed_names() if ollama else set()
|
|
168
|
+
except OllamaError:
|
|
169
|
+
installed = set()
|
|
170
|
+
return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def print_models(
|
|
174
|
+
ollama: Ollama | None,
|
|
175
|
+
hw: Hardware,
|
|
176
|
+
current: str | None = None,
|
|
177
|
+
numbered: bool = False,
|
|
178
|
+
out: Console | None = None,
|
|
179
|
+
) -> list[str]:
|
|
180
|
+
"""The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
|
|
181
|
+
out = out or console
|
|
182
|
+
if ollama is not None and backends.is_openai(ollama):
|
|
183
|
+
return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
|
|
139
184
|
try:
|
|
140
185
|
installed = ollama.installed_names() if ollama else set()
|
|
141
186
|
except OllamaError:
|
|
142
187
|
installed = set()
|
|
143
188
|
rec = catalog.recommend(hw)
|
|
144
|
-
wide =
|
|
189
|
+
wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
|
|
145
190
|
columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
|
|
146
191
|
if not wide:
|
|
147
192
|
columns = [c for c in columns if c not in ("Model", "Type")]
|
|
193
|
+
if numbered:
|
|
194
|
+
columns.insert(0, "#")
|
|
148
195
|
table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
|
|
149
196
|
for col in columns:
|
|
150
197
|
table.add_column(
|
|
151
|
-
col,
|
|
198
|
+
col,
|
|
199
|
+
no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
|
|
200
|
+
min_width=11 if col == "Status" else None,
|
|
201
|
+
justify="right" if col == "#" else "left",
|
|
202
|
+
style="cyan" if col == "#" else None,
|
|
152
203
|
)
|
|
204
|
+
choices: list[str] = []
|
|
153
205
|
for spec in catalog.load():
|
|
206
|
+
choices.append(spec.key)
|
|
154
207
|
ctx, speed = spec.fit(hw)
|
|
155
208
|
marks = []
|
|
156
209
|
if spec.local_name in installed or spec.tag in installed:
|
|
@@ -161,6 +214,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
|
|
|
161
214
|
marks.append("[bold]current[/]")
|
|
162
215
|
marks.append("tested" if spec.tested else "[dim]untested[/]")
|
|
163
216
|
row = {
|
|
217
|
+
"#": str(len(choices)),
|
|
164
218
|
"Key": spec.key,
|
|
165
219
|
"Model": spec.name,
|
|
166
220
|
"Type": spec.params,
|
|
@@ -171,11 +225,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
|
|
|
171
225
|
"Status": ", ".join(marks) if wide else "\n".join(marks),
|
|
172
226
|
}
|
|
173
227
|
table.add_row(*(row[c] for c in columns))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"[
|
|
177
|
-
|
|
178
|
-
|
|
228
|
+
for name in other_installed(ollama, installed) if ollama else []:
|
|
229
|
+
choices.append(name)
|
|
230
|
+
marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
|
|
231
|
+
row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
|
|
232
|
+
table.add_row(*(row.get(c, "") for c in columns))
|
|
233
|
+
out.print(table)
|
|
234
|
+
if not numbered:
|
|
235
|
+
out.print(
|
|
236
|
+
"[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
|
|
237
|
+
"choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
|
|
238
|
+
)
|
|
239
|
+
return choices
|
|
179
240
|
|
|
180
241
|
|
|
181
242
|
def cmd_models(args) -> None:
|
|
@@ -324,6 +385,13 @@ def cmd_doctor(args) -> None:
|
|
|
324
385
|
try:
|
|
325
386
|
model, spec = resolve_model(ollama, cfg["model"])
|
|
326
387
|
line("Model", f"{cfg['model']} → {model}", True)
|
|
388
|
+
if is_cloud(model):
|
|
389
|
+
line(
|
|
390
|
+
"",
|
|
391
|
+
"runs in Ollama's cloud: what lcode sends goes to ollama.com, billed per token, and this Ollama "
|
|
392
|
+
f"must be signed in once: {signin_command(ollama.host)}",
|
|
393
|
+
None,
|
|
394
|
+
)
|
|
327
395
|
ctx, note = choose_context(ollama, model, spec, cfg["context"], hw)
|
|
328
396
|
if spec:
|
|
329
397
|
from lcode.subagents import parallel_slots
|
lcode/cloud.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Ollama's cloud models: tags like `minimax-m3.1-flash:cloud` run on ollama.com, through the local Ollama.
|
|
2
|
+
|
|
3
|
+
They look like any installed model to lcode (Ollama lists them, and they chat and call tools), but nothing about
|
|
4
|
+
them is local: what lcode sends goes to ollama.com, it's billed per token, and it needs the Ollama signed in to an
|
|
5
|
+
ollama.com account (`ollama signin`). So lcode says so where it matters (`lcode doctor`, the start of a session, the
|
|
6
|
+
stronger-model panel), skips what only makes sense for a local model (loading it into memory, GPU fit), and turns
|
|
7
|
+
Ollama's bare `Unauthorized` into the command that fixes it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_cloud(model: str | None) -> bool:
|
|
14
|
+
"""Whether an Ollama model name is one of Ollama's cloud models: its tag is `cloud` or ends with `-cloud`."""
|
|
15
|
+
name = (model or "").strip().lower()
|
|
16
|
+
if ":" not in name:
|
|
17
|
+
return False
|
|
18
|
+
tag = name.rsplit(":", 1)[1]
|
|
19
|
+
return tag == "cloud" or tag.endswith("-cloud")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def signin_command(host: str) -> str:
|
|
23
|
+
"""How to sign the Ollama at `host` in to ollama.com: inside its container when it runs in Docker."""
|
|
24
|
+
try:
|
|
25
|
+
from lcode import update
|
|
26
|
+
|
|
27
|
+
container = update.find_container(host) if update.docker_available() else None
|
|
28
|
+
except Exception: # Docker missing, or not answering: the plain command is still right for most installs
|
|
29
|
+
container = None
|
|
30
|
+
return f"docker exec -it {container.name} ollama signin" if container else "ollama signin"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unauthorized(model: str, host: str) -> str:
|
|
34
|
+
"""The error for a cloud model that Ollama refused: what it means and the command to run."""
|
|
35
|
+
return (
|
|
36
|
+
f"{model} runs in Ollama's cloud (ollama.com), and this Ollama isn't signed in to an ollama.com account. "
|
|
37
|
+
f"Sign it in with `{signin_command(host)}`, then try again."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def notice(model: str) -> str:
|
|
42
|
+
"""What to know when a cloud model is lcode's main model."""
|
|
43
|
+
return (
|
|
44
|
+
f"{model} runs in Ollama's cloud: what lcode sends (your requests and the files it reads) goes to "
|
|
45
|
+
"ollama.com, and Ollama bills it per token."
|
|
46
|
+
)
|
lcode/escalate.py
CHANGED
|
@@ -23,6 +23,7 @@ from rich.panel import Panel
|
|
|
23
23
|
from rich.text import Text
|
|
24
24
|
|
|
25
25
|
from lcode import context as context_tools
|
|
26
|
+
from lcode.cloud import is_cloud
|
|
26
27
|
from lcode.ollama import Ollama, OllamaError, redact
|
|
27
28
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
@@ -76,14 +77,23 @@ class Target:
|
|
|
76
77
|
@property
|
|
77
78
|
def cloud(self) -> bool:
|
|
78
79
|
"""Paid per token: a cloud provider, or an OpenAI-compatible API elsewhere."""
|
|
79
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
self.provider in ("openai", "anthropic", "xai")
|
|
82
|
+
or (self.provider == "custom" and not self.local)
|
|
83
|
+
or (self.provider == "ollama" and is_cloud(self.model)) # an Ollama cloud model runs on ollama.com
|
|
84
|
+
)
|
|
80
85
|
|
|
81
86
|
@property
|
|
82
87
|
def where(self) -> str:
|
|
88
|
+
if self.provider == "ollama" and is_cloud(self.model):
|
|
89
|
+
return "ollama.com, through " + ("this machine's Ollama" if self.local else self._host())
|
|
83
90
|
if self.url:
|
|
84
|
-
return
|
|
91
|
+
return self._host()
|
|
85
92
|
return "this machine"
|
|
86
93
|
|
|
94
|
+
def _host(self) -> str:
|
|
95
|
+
return re.sub(r"^https?://", "", redact(self.url)).split("/")[0]
|
|
96
|
+
|
|
87
97
|
|
|
88
98
|
def target(setting: str | None, url: str | None = None) -> Target | None:
|
|
89
99
|
"""The stronger model from the settings (None: escalation is off). Raises EscalateError for a bad setting."""
|
|
@@ -105,6 +115,8 @@ def target(setting: str | None, url: str | None = None) -> Target | None:
|
|
|
105
115
|
raise EscalateError(f"escalate_model {setting!r} names no model")
|
|
106
116
|
if provider == "custom" and not url:
|
|
107
117
|
raise EscalateError("a custom/… escalate_model needs escalate_url, the server's OpenAI-compatible address")
|
|
118
|
+
if provider == "ollama" and is_cloud(model):
|
|
119
|
+
name = "Ollama cloud"
|
|
108
120
|
return Target(provider, model.strip(), (url or base).rstrip("/"), key_env, f"{model.strip()} ({name})")
|
|
109
121
|
|
|
110
122
|
|
lcode/ollama.py
CHANGED
|
@@ -9,6 +9,8 @@ from urllib.parse import urlsplit, urlunsplit
|
|
|
9
9
|
|
|
10
10
|
import requests
|
|
11
11
|
|
|
12
|
+
from lcode.cloud import is_cloud, unauthorized
|
|
13
|
+
|
|
12
14
|
MIN_VERSION = (0, 30, 0) # first release that runs the Qwen3.5/3.6 hybrid-attention architecture
|
|
13
15
|
|
|
14
16
|
|
|
@@ -49,6 +51,8 @@ class Ollama:
|
|
|
49
51
|
r = requests.post(self._url(path), json=payload, timeout=timeout)
|
|
50
52
|
except requests.RequestException as e:
|
|
51
53
|
raise OllamaError(f"cannot reach Ollama at {redact(self.host)}: {e}") from e
|
|
54
|
+
if r.status_code == 401 and is_cloud(payload.get("model")):
|
|
55
|
+
raise OllamaError(unauthorized(payload["model"], self.host))
|
|
52
56
|
if r.status_code != 200:
|
|
53
57
|
raise OllamaError(f"Ollama {path} failed ({r.status_code}): {r.text[:300]}")
|
|
54
58
|
return r.json()
|
|
@@ -164,6 +168,8 @@ class Ollama:
|
|
|
164
168
|
if on_open:
|
|
165
169
|
on_open(r)
|
|
166
170
|
with r:
|
|
171
|
+
if r.status_code == 401 and is_cloud(payload.get("model")):
|
|
172
|
+
raise OllamaError(unauthorized(payload["model"], self.host))
|
|
167
173
|
if r.status_code != 200:
|
|
168
174
|
raise OllamaError(f"Ollama error {r.status_code}: {r.text[:500]}")
|
|
169
175
|
for line in r.iter_lines():
|
lcode/repl.py
CHANGED
|
@@ -45,6 +45,8 @@ from lcode import memory as memory_notes
|
|
|
45
45
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
46
46
|
from lcode.catalog import MIN_USEFUL_CONTEXT
|
|
47
47
|
from lcode.checkpoints import Checkpoint, CheckpointError, Restore
|
|
48
|
+
from lcode.cloud import is_cloud
|
|
49
|
+
from lcode.cloud import notice as cloud_notice
|
|
48
50
|
from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
|
|
49
51
|
from lcode.hardware import Hardware
|
|
50
52
|
from lcode.ollama import OllamaError
|
|
@@ -68,8 +70,8 @@ COMMANDS = {
|
|
|
68
70
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
69
71
|
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
70
72
|
"/ctx": "Shortcut for /context",
|
|
71
|
-
"/model": "
|
|
72
|
-
"/models": "List models and how they fit this machine",
|
|
73
|
+
"/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
|
|
74
|
+
"/models": "List models and how they fit this machine; type a number to switch",
|
|
73
75
|
"/think": "Toggle model reasoning on/off",
|
|
74
76
|
"/verbose": "Toggle showing the model's reasoning text",
|
|
75
77
|
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
|
|
@@ -243,14 +245,20 @@ def banner(agent: Agent) -> None:
|
|
|
243
245
|
CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
|
|
244
246
|
|
|
245
247
|
|
|
248
|
+
def model_limit(agent: Agent) -> int | None:
|
|
249
|
+
"""The largest context the model supports (what Ollama reports, else the catalog's), if known."""
|
|
250
|
+
spec = catalog.find(agent.settings.model)
|
|
251
|
+
try:
|
|
252
|
+
return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
|
|
253
|
+
except OllamaError:
|
|
254
|
+
return spec.max_context if spec else None
|
|
255
|
+
|
|
256
|
+
|
|
246
257
|
def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
|
|
247
258
|
"""The context sizes this model supports, with how each one fits this machine."""
|
|
248
259
|
s = agent.settings
|
|
249
260
|
spec = catalog.find(s.model)
|
|
250
|
-
|
|
251
|
-
max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
|
|
252
|
-
except OllamaError:
|
|
253
|
-
max_ctx = spec.max_context if spec else None
|
|
261
|
+
max_ctx = model_limit(agent)
|
|
254
262
|
sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
|
|
255
263
|
if max_ctx:
|
|
256
264
|
sizes.add(max_ctx)
|
|
@@ -317,6 +325,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
317
325
|
marks.append("[cyan]recommended[/]")
|
|
318
326
|
table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
|
|
319
327
|
c.print(table)
|
|
328
|
+
limit = model_limit(agent)
|
|
329
|
+
beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
|
|
330
|
+
if beyond:
|
|
331
|
+
sizes = " and ".join(format_tokens(size) for size in beyond[:2])
|
|
332
|
+
c.print(
|
|
333
|
+
f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
|
|
334
|
+
f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
|
|
335
|
+
"setting would only look larger.[/]"
|
|
336
|
+
)
|
|
320
337
|
try:
|
|
321
338
|
answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
|
|
322
339
|
except EOFError:
|
|
@@ -809,6 +826,65 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
809
826
|
agent.save()
|
|
810
827
|
|
|
811
828
|
|
|
829
|
+
def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
830
|
+
"""/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
|
|
831
|
+
from lcode.cli import model_choices, print_models
|
|
832
|
+
|
|
833
|
+
c, s = agent.console, agent.settings
|
|
834
|
+
if arg and not arg.isdigit():
|
|
835
|
+
switch_model(agent, arg, hardware)
|
|
836
|
+
return
|
|
837
|
+
if arg:
|
|
838
|
+
choices = model_choices(agent.ollama)
|
|
839
|
+
else:
|
|
840
|
+
choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
|
|
841
|
+
if not choices:
|
|
842
|
+
return
|
|
843
|
+
try:
|
|
844
|
+
answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
|
|
845
|
+
except EOFError:
|
|
846
|
+
return
|
|
847
|
+
if not answer:
|
|
848
|
+
c.print(f"Keeping {s.model}.")
|
|
849
|
+
return
|
|
850
|
+
arg = answer
|
|
851
|
+
if arg.isdigit():
|
|
852
|
+
if not 1 <= int(arg) <= len(choices):
|
|
853
|
+
c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
|
|
854
|
+
return
|
|
855
|
+
arg = choices[int(arg) - 1]
|
|
856
|
+
switch_model(agent, arg, hardware)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
|
|
860
|
+
"""Switch to a catalog key or an installed model, and size the context for it."""
|
|
861
|
+
from lcode.cli import NotInstalled, resolve_model
|
|
862
|
+
|
|
863
|
+
c, s = agent.console, agent.settings
|
|
864
|
+
try:
|
|
865
|
+
name, spec = resolve_model(agent.ollama, wanted)
|
|
866
|
+
except (NotInstalled, OllamaError) as e:
|
|
867
|
+
c.print(f"[red]{e}[/]")
|
|
868
|
+
return
|
|
869
|
+
if name == s.model:
|
|
870
|
+
c.print(f"Already using {name} (context {format_tokens(s.context)}).")
|
|
871
|
+
return
|
|
872
|
+
s.model = name
|
|
873
|
+
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
|
|
874
|
+
if spec: # size the context for the new model: largest window that fits this machine
|
|
875
|
+
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
876
|
+
elif backends.is_openai(agent.ollama):
|
|
877
|
+
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
|
|
878
|
+
agent._vision = False # the model that looks at images goes with the model: decide again
|
|
879
|
+
agent.messages[0]["content"] = agent.system_prompt()
|
|
880
|
+
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
881
|
+
if is_cloud(name):
|
|
882
|
+
agent.say(cloud_notice(name))
|
|
883
|
+
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
|
|
884
|
+
if warning:
|
|
885
|
+
agent.say(warning, warning=True)
|
|
886
|
+
|
|
887
|
+
|
|
812
888
|
def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
|
|
813
889
|
c, s = agent.console, agent.settings
|
|
814
890
|
if size == s.context:
|
|
@@ -956,8 +1032,6 @@ def session_start(agent: Agent) -> None:
|
|
|
956
1032
|
|
|
957
1033
|
def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
958
1034
|
"""Run a slash command. Returns False when the REPL should exit."""
|
|
959
|
-
from lcode.cli import NotInstalled, print_models, resolve_model
|
|
960
|
-
|
|
961
1035
|
cmd, _, arg = line.partition(" ")
|
|
962
1036
|
arg = arg.strip()
|
|
963
1037
|
c, s = agent.console, agent.settings
|
|
@@ -1006,29 +1080,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1006
1080
|
agent.compact(arg)
|
|
1007
1081
|
elif cmd in ("/context", "/ctx"):
|
|
1008
1082
|
context_command(agent, arg, hardware)
|
|
1009
|
-
elif cmd
|
|
1010
|
-
|
|
1011
|
-
elif cmd == "/model":
|
|
1012
|
-
if not arg:
|
|
1013
|
-
c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
|
|
1014
|
-
else:
|
|
1015
|
-
try:
|
|
1016
|
-
name, spec = resolve_model(agent.ollama, arg)
|
|
1017
|
-
except (NotInstalled, OllamaError) as e:
|
|
1018
|
-
c.print(f"[red]{e}[/]")
|
|
1019
|
-
return True
|
|
1020
|
-
s.model = name
|
|
1021
|
-
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
|
|
1022
|
-
if spec: # size the context for the new model: largest window that fits this machine
|
|
1023
|
-
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
1024
|
-
elif backends.is_openai(agent.ollama):
|
|
1025
|
-
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
|
|
1026
|
-
agent._vision = False # the model that looks at images goes with the model: decide again
|
|
1027
|
-
agent.messages[0]["content"] = agent.system_prompt()
|
|
1028
|
-
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
1029
|
-
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
|
|
1030
|
-
if warning:
|
|
1031
|
-
agent.say(warning, warning=True)
|
|
1083
|
+
elif cmd in ("/models", "/model"):
|
|
1084
|
+
model_command(agent, arg, hardware)
|
|
1032
1085
|
elif cmd == "/think":
|
|
1033
1086
|
s.think = (arg == "on") if arg in ("on", "off") else not s.think
|
|
1034
1087
|
c.print(f"Reasoning {'on' if s.think else 'off'}.")
|
|
@@ -1207,6 +1260,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1207
1260
|
run_safely(agent, prompt)
|
|
1208
1261
|
return
|
|
1209
1262
|
banner(agent)
|
|
1263
|
+
if is_cloud(agent.settings.model):
|
|
1264
|
+
agent.say(cloud_notice(agent.settings.model))
|
|
1210
1265
|
warning = residency.dense_warning(agent.ollama, agent.settings.model, agent.settings.context, hardware)
|
|
1211
1266
|
if warning:
|
|
1212
1267
|
agent.say(warning, warning=True)
|
|
@@ -1237,6 +1292,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1237
1292
|
|
|
1238
1293
|
def preload(agent: Agent, session: PromptSession | None = None) -> None:
|
|
1239
1294
|
"""Start loading the model in the background so the first answer comes sooner; the status bar says so."""
|
|
1295
|
+
if is_cloud(agent.settings.model):
|
|
1296
|
+
return # it runs on ollama.com: there's nothing to load here
|
|
1240
1297
|
where = residency.placement(agent.ollama, agent.settings.model)
|
|
1241
1298
|
if where is not None and (not where.context or where.context == agent.settings.context):
|
|
1242
1299
|
return # already in memory
|
lcode/residency.py
CHANGED
|
@@ -15,6 +15,7 @@ import time
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
|
|
17
17
|
from lcode import catalog
|
|
18
|
+
from lcode.cloud import is_cloud
|
|
18
19
|
from lcode.ollama import OllamaError
|
|
19
20
|
|
|
20
21
|
GB = 1e9
|
|
@@ -97,6 +98,8 @@ def expected_share(model: str, context: int, hardware) -> float | None:
|
|
|
97
98
|
|
|
98
99
|
def dense_warning(ollama, model: str, context: int, hardware) -> str:
|
|
99
100
|
"""For a dense model that won't fit on the GPU: what that means and what to do. Empty otherwise."""
|
|
101
|
+
if is_cloud(model):
|
|
102
|
+
return "" # it runs on ollama.com
|
|
100
103
|
if hardware is None or hardware.unified or is_moe(ollama, model) is not False:
|
|
101
104
|
return ""
|
|
102
105
|
spec = catalog.find(model)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37.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/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=lKIIi9DSWXNWEjJvCuOOtYqAkmR0gtWHsaKllXOu7vc,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=vjWiK79BgUTMAbeC6SPslGaYT86YELwTHIGB21tc4to,84751
|
|
6
6
|
lcode/api.py,sha256=mIfmiN3jDv8jg-HOJtqfr-MQ3cOI8nfFmlYYRZR1r1Y,16164
|
|
7
7
|
lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
|
|
8
8
|
lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
|
|
@@ -11,12 +11,13 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
13
|
lcode/checks.py,sha256=iPaNaGOGo7zHc_mue41ELEHSNq0VIH_xVYL_bHkYLXU,9373
|
|
14
|
-
lcode/cli.py,sha256=
|
|
14
|
+
lcode/cli.py,sha256=mJBM3Loqs9kOE94IRe00d1A1izW5caAjSR7ITUxC27I,45042
|
|
15
15
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
16
|
+
lcode/cloud.py,sha256=q5Dl5GuqWvKzBCIso-eh6yomvEOsnQ0LW8ZgaAGwBxc,2131
|
|
16
17
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
17
18
|
lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
|
|
18
19
|
lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
|
|
19
|
-
lcode/escalate.py,sha256=
|
|
20
|
+
lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
|
|
20
21
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
21
22
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
22
23
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
@@ -29,14 +30,14 @@ lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
|
|
|
29
30
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
30
31
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
31
32
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
32
|
-
lcode/ollama.py,sha256=
|
|
33
|
+
lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
|
|
33
34
|
lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
34
35
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
35
36
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
36
37
|
lcode/repeats.py,sha256=vpXR5pzKX9GsTzQONGS30V-rEU3gunaj_hBzJxg43Q4,5542
|
|
37
|
-
lcode/repl.py,sha256=
|
|
38
|
+
lcode/repl.py,sha256=elnJeSQYt0oEtE14UzW2k-fkDHVFr_Lf2L6XWFo0ne0,56891
|
|
38
39
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
39
|
-
lcode/residency.py,sha256=
|
|
40
|
+
lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
|
|
40
41
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
41
42
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
42
43
|
lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
|
|
@@ -65,8 +66,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
65
66
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
66
67
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
67
68
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
68
|
-
lcode_cli-0.
|
|
69
|
-
lcode_cli-0.
|
|
70
|
-
lcode_cli-0.
|
|
71
|
-
lcode_cli-0.
|
|
72
|
-
lcode_cli-0.
|
|
69
|
+
lcode_cli-0.37.0.dist-info/METADATA,sha256=fgaRfrIMEYuJXyDpy8Kj18AL7S2IybZqNmvd09c6IgI,13141
|
|
70
|
+
lcode_cli-0.37.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
71
|
+
lcode_cli-0.37.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
72
|
+
lcode_cli-0.37.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
73
|
+
lcode_cli-0.37.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|