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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.35.1"
6
+ __version__ = "0.37.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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(client: backends.OpenAICompatible, current: str | None) -> None:
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
- console.print(f"[red]{e}[/]")
123
- return
124
- console.print(f"[bold]Models served by {escape(client.describe())}[/]")
125
- for name in served:
126
- console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
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
- console.print(" none listed")
129
- console.print(
130
- "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
131
- "or [/]lcode config set model <id>[dim].[/]"
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 print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
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
- print_served(ollama, current) # type: ignore[arg-type]
138
- return
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 = console.width >= 130 # the descriptive columns don't fit an 80-column terminal
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, no_wrap=col in ("Key", "Size", "Max ctx", "Fits here"), min_width=11 if col == "Status" else None
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
- console.print(table)
175
- console.print(
176
- "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
177
- "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
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 self.provider in ("openai", "anthropic", "xai") or (self.provider == "custom" and not self.local)
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 re.sub(r"^https?://", "", redact(self.url)).split("/")[0]
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": "Show or switch model, e.g. /model qwen3.5-9b",
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
- try:
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 == "/models":
1010
- print_models(agent.ollama, hardware, s.model)
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.35.1
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=zUaeUug04u05tZncoC6Kos2DYIKI8_-kGSal29vOt5M,473
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=m_q7Q-uKWRUru6rUXXEhmUvJeDQQR1EMADigw06MGq4,84599
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=nPvGnL0gZiUkbTniCoTAAyItiPEXZ-f6rHLYYUOKSiU,42151
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=6amspekybujPn0BfbVsNDYkB-I_pu6jJB45YJgFv0yc,14638
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=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
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=adq99lxLLr43AQMEHT3qNLNXho4kiQIJUzJTWO9-pmY,54646
38
+ lcode/repl.py,sha256=elnJeSQYt0oEtE14UzW2k-fkDHVFr_Lf2L6XWFo0ne0,56891
38
39
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
39
- lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
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.35.1.dist-info/METADATA,sha256=QUw9A6Zoi4mnZTYIimLNmID9EjahV_ZlF7ZJmBm5NUw,13141
69
- lcode_cli-0.35.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
70
- lcode_cli-0.35.1.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
71
- lcode_cli-0.35.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
72
- lcode_cli-0.35.1.dist-info/RECORD,,
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,,