From 12a448ac11a374e6b1a3030e65a86e72ecdbe98e Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:15:30 +0200 Subject: [PATCH] Pick a model by number in /models and /model; /context says why it stops at 256K /models and /model (with no name) show the models table numbered, catalog first, then the other installed Ollama models that can chat and call tools, and ask which to switch to: no second /model . /model 2 switches straight away; names still work; one that isn't installed says how to get it. Embedding models are left out even when Ollama lists 'tools' for them. /context explains the sizes it doesn't offer: Ollama caps the window at what the model was trained on (256K for qwen3.6-35b) whatever the memory, so a 512K or 1M setting would only look larger. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ docs/models.md | 15 ++++- docs/usage.md | 2 +- src/lcode/cli.py | 102 +++++++++++++++++++++++++++------- src/lcode/repl.py | 111 ++++++++++++++++++++++++++----------- tests/test_backends.py | 14 +++++ tests/test_model_picker.py | 94 +++++++++++++++++++++++++++++++ tests/test_residency.py | 4 +- 8 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 tests/test_model_picker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b63a900..5b33f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. `/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 diff --git a/docs/models.md b/docs/models.md index b7b7cb5..a82b3e2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -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 ` in a shell). + ### Any other Ollama model ```bash @@ -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. diff --git a/docs/usage.md b/docs/usage.md index f524bc7..1b7dd91 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 | diff --git a/src/lcode/cli.py b/src/lcode/cli.py index 886596c..7437b64 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -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 [dim] " - "or [/]lcode config set model [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 [dim] " + "or [/]lcode config set model [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: @@ -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, @@ -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 [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 [/]" - ) + 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 [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 [/]" + ) + return choices def cmd_models(args) -> None: diff --git a/src/lcode/repl.py b/src/lcode/repl.py index ae19c61..25eeb64 100644 --- a/src/lcode/repl.py +++ b/src/lcode/repl.py @@ -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 , or /plan to show the approved plan", @@ -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) @@ -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: @@ -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: @@ -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 @@ -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 ; 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'}.") diff --git a/tests/test_backends.py b/tests/test_backends.py index 023c824..4e717c4 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -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() diff --git a/tests/test_model_picker.py b/tests/test_model_picker.py new file mode 100644 index 0000000..e93a3bf --- /dev/null +++ b/tests/test_model_picker.py @@ -0,0 +1,94 @@ +"""/models and /model: a numbered list to switch from, without typing the model's name.""" + +import pytest + +from conftest import output +from lcode import catalog, cli +from lcode.hardware import Hardware +from lcode.repl import handle_command + +LAPTOP = Hardware("linux", "x", 31, "RTX 4080 Laptop", 12) + + +def answer(monkeypatch, text): + asked = [] + + def ask(prompt): + asked.append(prompt) + return text + + monkeypatch.setattr("builtins.input", ask) + return asked + + +def number_of(name: str) -> str: + keys = [spec.key for spec in catalog.load()] + return str(keys.index(name) + 1) if name in keys else str(len(keys) + 1) # custom:7b comes after the catalog + + +def test_models_lists_numbers_and_switches_to_the_one_picked(agent, monkeypatch): + asked = answer(monkeypatch, number_of("custom:7b")) + handle_command(agent, "/models", LAPTOP) + out = output(agent) + assert "Switch to which model? (number or name, Enter to keep lcode-qwen3.6-35b)" in asked[0] + [row] = [line for line in out.splitlines() if "custom:7b" in line and "│" in line] + assert row.split("│")[1].strip() == number_of("custom:7b") and "catalog" in out + assert agent.settings.model == "custom:7b" and "Switched to custom:7b" in output(agent) + + +def test_model_with_no_name_shows_the_same_list(agent, monkeypatch): + answer(monkeypatch, "") + handle_command(agent, "/model", LAPTOP) + assert "Models for this machine" in output(agent) and "Keeping lcode-qwen3.6-35b." in output(agent) + assert agent.settings.model == "lcode-qwen3.6-35b" + + +def test_a_number_or_a_name_switches_without_the_list(agent, monkeypatch): + monkeypatch.setattr("builtins.input", lambda _: pytest.fail("should not ask")) + handle_command(agent, f"/model {number_of('custom:7b')}", LAPTOP) + assert agent.settings.model == "custom:7b" and "Models for this machine" not in output(agent) + handle_command(agent, "/model qwen3.6-35b", LAPTOP) + assert agent.settings.model == "lcode-qwen3.6-35b" and agent.settings.context == 262144 + handle_command(agent, "/models 1", LAPTOP) + assert "Already using lcode-qwen3.6-35b" in output(agent) + + +def test_a_model_that_isnt_installed_says_how_to_get_it(agent, monkeypatch): + answer(monkeypatch, number_of("qwen3.8-27b")) + handle_command(agent, "/models", LAPTOP) + assert "lcode setup qwen3.8-27b" in output(agent) and agent.settings.model == "lcode-qwen3.6-35b" + handle_command(agent, "/model 99", LAPTOP) + assert "There's no model 99 in the list" in " ".join(output(agent).split()) + + +def test_installed_models_that_cant_call_tools_are_left_out(agent): + installed = {"lcode-qwen3.6-35b", "custom:7b", "embed:1", "embed:1:latest", "chat-only:3b"} + agent.ollama.installed_names = lambda: installed + agent.ollama.capabilities["embed:1"] = ["tools", "thinking", "embedding"] # what Ollama says of qwen3-embedding + agent.ollama.capabilities["chat-only:3b"] = ["completion"] + choices = cli.model_choices(agent.ollama) + assert choices[-1] == "custom:7b" and "embed:1" not in choices and "chat-only:3b" not in choices + assert choices[: len(catalog.load())] == [spec.key for spec in catalog.load()] + + +def test_the_shell_command_keeps_its_table_without_numbers(monkeypatch): + import io + + from rich.console import Console + + from conftest import FakeOllama + + wide = Console(file=io.StringIO(), width=160) + names = cli.print_models(FakeOllama(), LAPTOP, "qwen3.6-35b", out=wide) + out = wide.file.getvalue() + assert names[0] == "qwen3.6-35b" and "Install one with" in out and " # " not in out.splitlines()[2] + + +def test_context_says_why_larger_windows_are_not_offered(agent, monkeypatch): + answer(monkeypatch, "") + handle_command(agent, "/context", LAPTOP) + text = " ".join(output(agent).split()) + assert "Not offered: 512K and 1M. lcode-qwen3.6-35b was trained on up to 256K tokens" in text + agent.ollama.max_ctx = 1048576 + handle_command(agent, "/context", LAPTOP) + assert " ".join(output(agent).split()).count("Not offered") == 1 diff --git a/tests/test_residency.py b/tests/test_residency.py index 2a7dbd9..b094d6e 100644 --- a/tests/test_residency.py +++ b/tests/test_residency.py @@ -159,6 +159,6 @@ def test_switching_models_picks_the_vision_model_again(make_agent, monkeypatch): agent = make_agent() agent._vision = "qwen3.8:27b" # decided for the previous model - monkeypatch.setattr("lcode.cli.resolve_model", lambda ollama, name: ("lcode-qwen3.6-35b", None)) - handle_command(agent, "/model qwen3.6-35b", LAPTOP) + monkeypatch.setattr("lcode.cli.resolve_model", lambda ollama, name: ("custom:7b", None)) + handle_command(agent, "/model custom:7b", LAPTOP) assert agent._vision != "qwen3.8:27b" # decided again for the new model (here: none can see)