Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to lcode are documented here. The format follows

## [Unreleased]

### Changed

- `/models` and `/model` show a numbered list of models, and you switch by typing a number: no
second `/model <name>`. `/model 2` switches straight away, and a name still works. The list ends
with the other Ollama models you've installed that can call tools, and a model that isn't
installed says how to get it.
- `/context` says why it stops at the model's largest window (256K for qwen3.6-35b): Ollama caps the
window at what the model was trained on, whatever the memory, so 512K or 1M would only look larger.

## [0.35.1] - 2026-10-15

### Fixed
Expand Down
15 changes: 14 additions & 1 deletion docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,18 @@ when they fit entirely in GPU (or Apple unified) memory.

```text
❯ /models
❯ /model qwen3.5-9b
Models for this machine: RTX 4080 Laptop (12 GB VRAM), 31 GB RAM
# Key Size Max ctx Fits here Speed Status
1 qwen3.6-35b 22.6 GB 256K 256K good (experts in RAM) installed, recommended, current
2 qwen3.8-27b …
9 my-model:7b installed, not in catalog
Switch to which model? (number or name, Enter to keep lcode-qwen3.6-35b): 9
```

`/model` shows the same list. `/model 9` or `/model qwen3.5-9b` switches straight away. The
list ends with the other Ollama models you've installed that can call tools. A model that isn't
installed yet says how to get it (`lcode setup <key>` in a shell).

### Any other Ollama model

```bash
Expand Down Expand Up @@ -107,6 +116,10 @@ Context window for lcode-qwen3.6-35b
Choose a number, a size like 96k, or press Enter to keep 256K: 2
```

The list stops at the largest window the model was trained on, and says so: Ollama caps the window
there whatever the memory, so a larger setting would only look larger. For qwen3.6-35b that's 256K;
512K and 1M are offered for models trained on them.

Sizes accept `k` and `m` suffixes (`128k` = 131,072 tokens, `1m` = 1,048,576). When the window is
85% full, lcode summarizes the conversation automatically so you can keep working; `/compact` does it
on demand.
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ reasoning is on.
| `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
| `/compact [focus]` | Summarize the conversation to free context |
| `/context [size]` | Show how full the context window is, what's using it (by category), and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
| `/model [name]`, `/models` | Switch model, list models |
| `/models`, `/model [number or name]` | List the models and how they fit this machine, numbered: type a number to switch, or `/model 2`, `/model qwen3.5-9b` |
| `/think [on\|off]` | Toggle reasoning |
| `/verbose` | Toggle showing the reasoning text |
| `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
Expand Down
102 changes: 81 additions & 21 deletions src/lcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,44 +113,96 @@ def choose_context(
# ----------------------------------------------------------------------------- lcode models


def print_served(client: backends.OpenAICompatible, current: str | None) -> None:
def print_served(
client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
) -> list[str]:
out = out or console
try:
served = client.chat_models()
current = backends.pick_model(client, current) if current else None
except OllamaError as e:
if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
console.print(f"[red]{e}[/]")
return
console.print(f"[bold]Models served by {escape(client.describe())}[/]")
for name in served:
console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
out.print(f"[red]{e}[/]")
return []
served = []
out.print(f"[bold]Models served by {escape(client.describe())}[/]")
for i, name in enumerate(served, 1):
number = f"[cyan]{i:>3}[/] " if numbered else " "
out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
if not served:
console.print(" none listed")
console.print(
"[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
"or [/]lcode config set model <id>[dim].[/]"
)
out.print(" none listed")
if not numbered:
out.print(
"[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
"or [/]lcode config set model <id>[dim].[/]"
)
return served


def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
"""Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
usable = []
for name in sorted({n.removesuffix(":latest") for n in installed} - known):
try:
capabilities = ollama.show(name).get("capabilities") or []
if "completion" in capabilities and "tools" in capabilities:
usable.append(name)
except OllamaError:
continue
return usable


def model_choices(ollama: Ollama | None) -> list[str]:
"""What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
For an OpenAI-compatible server, the models it serves."""
if ollama is not None and backends.is_openai(ollama):
print_served(ollama, current) # type: ignore[arg-type]
return
try:
return ollama.chat_models() # type: ignore[union-attr]
except OllamaError:
return []
try:
installed = ollama.installed_names() if ollama else set()
except OllamaError:
installed = set()
return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])


def print_models(
ollama: Ollama | None,
hw: Hardware,
current: str | None = None,
numbered: bool = False,
out: Console | None = None,
) -> list[str]:
"""The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
out = out or console
if ollama is not None and backends.is_openai(ollama):
return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
try:
installed = ollama.installed_names() if ollama else set()
except OllamaError:
installed = set()
rec = catalog.recommend(hw)
wide = console.width >= 130 # the descriptive columns don't fit an 80-column terminal
wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
if not wide:
columns = [c for c in columns if c not in ("Model", "Type")]
if numbered:
columns.insert(0, "#")
table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
for col in columns:
table.add_column(
col, no_wrap=col in ("Key", "Size", "Max ctx", "Fits here"), min_width=11 if col == "Status" else None
col,
no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
min_width=11 if col == "Status" else None,
justify="right" if col == "#" else "left",
style="cyan" if col == "#" else None,
)
choices: list[str] = []
for spec in catalog.load():
choices.append(spec.key)
ctx, speed = spec.fit(hw)
marks = []
if spec.local_name in installed or spec.tag in installed:
Expand All @@ -161,6 +213,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
marks.append("[bold]current[/]")
marks.append("tested" if spec.tested else "[dim]untested[/]")
row = {
"#": str(len(choices)),
"Key": spec.key,
"Model": spec.name,
"Type": spec.params,
Expand All @@ -171,11 +224,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
"Status": ", ".join(marks) if wide else "\n".join(marks),
}
table.add_row(*(row[c] for c in columns))
console.print(table)
console.print(
"[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
"choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
)
for name in other_installed(ollama, installed) if ollama else []:
choices.append(name)
marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
table.add_row(*(row.get(c, "") for c in columns))
out.print(table)
if not numbered:
out.print(
"[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
"choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
)
return choices


def cmd_models(args) -> None:
Expand Down
111 changes: 80 additions & 31 deletions src/lcode/repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
"/ctx": "Shortcut for /context",
"/model": "Show or switch model, e.g. /model qwen3.5-9b",
"/models": "List models and how they fit this machine",
"/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
"/models": "List models and how they fit this machine; type a number to switch",
"/think": "Toggle model reasoning on/off",
"/verbose": "Toggle showing the model's reasoning text",
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
Expand Down Expand Up @@ -243,14 +243,20 @@ def banner(agent: Agent) -> None:
CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)


def model_limit(agent: Agent) -> int | None:
"""The largest context the model supports (what Ollama reports, else the catalog's), if known."""
spec = catalog.find(agent.settings.model)
try:
return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
except OllamaError:
return spec.max_context if spec else None


def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
"""The context sizes this model supports, with how each one fits this machine."""
s = agent.settings
spec = catalog.find(s.model)
try:
max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
except OllamaError:
max_ctx = spec.max_context if spec else None
max_ctx = model_limit(agent)
sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
if max_ctx:
sizes.add(max_ctx)
Expand Down Expand Up @@ -317,6 +323,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
marks.append("[cyan]recommended[/]")
table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
c.print(table)
limit = model_limit(agent)
beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
if beyond:
sizes = " and ".join(format_tokens(size) for size in beyond[:2])
c.print(
f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
"setting would only look larger.[/]"
)
try:
answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
except EOFError:
Expand Down Expand Up @@ -809,6 +824,63 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
agent.save()


def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
"""/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
from lcode.cli import model_choices, print_models

c, s = agent.console, agent.settings
if arg and not arg.isdigit():
switch_model(agent, arg, hardware)
return
if arg:
choices = model_choices(agent.ollama)
else:
choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
if not choices:
return
try:
answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
except EOFError:
return
if not answer:
c.print(f"Keeping {s.model}.")
return
arg = answer
if arg.isdigit():
if not 1 <= int(arg) <= len(choices):
c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
return
arg = choices[int(arg) - 1]
switch_model(agent, arg, hardware)


def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
"""Switch to a catalog key or an installed model, and size the context for it."""
from lcode.cli import NotInstalled, resolve_model

c, s = agent.console, agent.settings
try:
name, spec = resolve_model(agent.ollama, wanted)
except (NotInstalled, OllamaError) as e:
c.print(f"[red]{e}[/]")
return
if name == s.model:
c.print(f"Already using {name} (context {format_tokens(s.context)}).")
return
s.model = name
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
if spec: # size the context for the new model: largest window that fits this machine
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
elif backends.is_openai(agent.ollama):
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
agent._vision = False # the model that looks at images goes with the model: decide again
agent.messages[0]["content"] = agent.system_prompt()
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
if warning:
agent.say(warning, warning=True)


def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
c, s = agent.console, agent.settings
if size == s.context:
Expand Down Expand Up @@ -956,8 +1028,6 @@ def session_start(agent: Agent) -> None:

def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
"""Run a slash command. Returns False when the REPL should exit."""
from lcode.cli import NotInstalled, print_models, resolve_model

cmd, _, arg = line.partition(" ")
arg = arg.strip()
c, s = agent.console, agent.settings
Expand Down Expand Up @@ -1006,29 +1076,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
agent.compact(arg)
elif cmd in ("/context", "/ctx"):
context_command(agent, arg, hardware)
elif cmd == "/models":
print_models(agent.ollama, hardware, s.model)
elif cmd == "/model":
if not arg:
c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
else:
try:
name, spec = resolve_model(agent.ollama, arg)
except (NotInstalled, OllamaError) as e:
c.print(f"[red]{e}[/]")
return True
s.model = name
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
if spec: # size the context for the new model: largest window that fits this machine
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
elif backends.is_openai(agent.ollama):
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
agent._vision = False # the model that looks at images goes with the model: decide again
agent.messages[0]["content"] = agent.system_prompt()
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
if warning:
agent.say(warning, warning=True)
elif cmd in ("/models", "/model"):
model_command(agent, arg, hardware)
elif cmd == "/think":
s.think = (arg == "on") if arg in ("on", "off") else not s.think
c.print(f"Reasoning {'on' if s.think else 'off'}.")
Expand Down
14 changes: 14 additions & 0 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,17 @@ def test_a_reply_cut_off_by_the_server_says_so(server):
assert chunks[-1]["done_reason"] == "length"
FakeServer.replies = [sse(delta(content="Done."), USAGE)]
assert list(OpenAICompatible(server).chat_stream({"model": "m", "messages": []}))[-1]["done_reason"] == "stop"


def test_served_models_are_numbered_to_pick_from(server):
import io

from rich.console import Console

from lcode import cli

client = OpenAICompatible(server)
out = Console(file=io.StringIO(), width=120)
names = cli.print_models(client, None, MODELS[0], numbered=True, out=out)
assert names == cli.model_choices(client) == ["google/gemma-4-12b", "qwen/qwen3.6-35b-a3b"] # no embeddings
assert " 2 qwen/qwen3.6-35b-a3b current" in out.file.getvalue()
Loading
Loading