diff --git a/CHANGELOG.md b/CHANGELOG.md index b523617..291f4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to lcode are documented here. The format follows ## [Unreleased] +### Added + +- Ollama's cloud models (tags ending in `cloud`, such as `minimax-m3.1-flash:cloud`) work as the + main model or as the stronger model when stuck, and lcode says what they are: what it sends goes + to ollama.com and is billed per token. The stronger-model panel said "free, on this machine" for + them; it now says ollama.com and shows the cost. A request Ollama refuses because it isn't signed + in says to run `ollama signin` (in Docker, with your container's name), and lcode skips the + local loading and GPU checks for them. The docs explain the setup and list the cloud models that + call tools, with prices and published coding scores: see + [Cloud models through Ollama](https://nasser1941.github.io/lcode/models/#cloud-models-through-ollama). + ## [0.36.0] - 2026-10-18 ### Changed diff --git a/docs/configuration.md b/docs/configuration.md index a9711a3..79c7ae9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,7 @@ lcode config path # print the file location | `notify_after` | `30` | Seconds a request runs before it notifies | | `review` | `off` | Before the answer, [review a change](usage.md#a-review-before-the-answer) against the request and send problems back to the model: `auto` or `off` | | `checks` | `auto` | Run the project's [checks](usage.md#checks-before-done) after a request changed files: `auto` (found from the project), `off`, or a command | -| `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/`, or `custom/` with `escalate_url` | +| `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/` (a local model, or an [Ollama cloud model](models.md#cloud-models-through-ollama) like `ollama/glm-5.3-flash:cloud`), or `custom/` with `escalate_url` | | `escalate_url` | — | Its server, when it isn't the provider's usual one: another Ollama, or any OpenAI-compatible API | | `escalate_price` | — | Its price in dollars per million tokens, input/output (e.g. `3/15`), for the cost shown before each use | | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off, along with the checks for [unreachable sites](how-it-works.md#when-a-site-cant-be-reached) and [thinking in circles](how-it-works.md#when-a-reply-goes-nowhere) | diff --git a/docs/models.md b/docs/models.md index a82b3e2..2c91386 100644 --- a/docs/models.md +++ b/docs/models.md @@ -85,6 +85,104 @@ context; set `--context` yourself. If a model prints tool calls as text instead lcode tries to recover them, but results are usually worse. To propose a model for the catalog, see [Contributing](contributing.md#adding-a-model-to-the-catalog). +## Cloud models through Ollama + +Ollama also offers models that run on its own servers, with tags that end in `cloud`, such as +`minimax-m3.1-flash:cloud`. Your Ollama passes the requests on, so lcode uses them like an installed +model: as the main model, or as the [stronger model to ask when stuck](usage.md#a-stronger-model-when-stuck). +What changes: + +- **Your code leaves the machine.** What lcode sends (your requests and the files it reads) goes to + ollama.com. lcode says so when a session starts, when you switch to one, and in `lcode doctor`. +- **It's billed per token** to your ollama.com account (some models are free for a while). +- **It needs an ollama.com account** and a connection to ollama.com. + +### Setting one up + +1. **Sign your Ollama in** to your ollama.com account, once. It prints a link to open in the + browser: + + === "Ollama installed normally" + + ```bash + ollama signin + ``` + + === "Ollama in Docker" + + ```bash + docker exec -it ollama signin + ``` + + `lcode doctor` shows this command with your container's name once a cloud model is set. + +2. **Add the model.** This stores a small reference, not the weights: + + ```bash + ollama pull minimax-m3.1-flash:cloud # in Docker: docker exec ollama pull … + ``` + +3. **Use it** as the main model, or as the stronger model when stuck: + + ```bash + lcode --model minimax-m3.1-flash:cloud --context 256k # or pick it by number in /models + + lcode config set escalate_model ollama/minimax-m3.1-flash:cloud + lcode config set escalate_price 0.15/0.60 # its price in/out, for the cost shown + ``` + + As the main model, give the context yourself: lcode defaults to 32K for models outside its + catalog. A larger window costs more, since every step sends the conversation again (the + repeated part at the lower *cached* price). As the stronger model, only a short brief goes out, + and only when you say yes: the panel shows `Where: … at ollama.com` and the cost. + +If a request fails with *this Ollama isn't signed in to an ollama.com account*, run the sign-in +command the message shows. + +### Cheap coding models in Ollama's cloud + +The cloud models that call tools, cheapest first. Prices are in dollars per million tokens, from +[ollama.com](https://ollama.com/search?c=cloud) on 2026-10-20. Check a model's page before you +rely on a price: they change. + +- **TB 2.1** is the Terminal-Bench 2.1 score as published on the model's page. The makers measured + it themselves, in different setups, so it's a rough guide, not a ranking. A dash means the page + gives none. +- **One question** is the most a question to the stronger model costs: a 15K-token brief, plus + lcode's 4K-token cap on the answer. +- **DeepSeek** charges less off-peak. Its prices read off-peak / peak. + +| Model | Input | Cached | Output | Context | TB 2.1 | One question | +|---|---|---|---|---|---|---| +| `glm-5.3-flash:cloud` | $0.15 | $0.03 | $0.50 | 1M | 84.3 | $0.004 | +| `minimax-m3.1-flash:cloud` | $0.15 | $0.015 | $0.60 | 976K | — | $0.005 | +| `deepseek-v4.1-flash:cloud` | $0.15 / $0.30 | $0.003 / $0.006 | $0.60 / $1.20 | 1M | 90.6 | $0.005–$0.009 | +| `minimax-m2.7:cloud` | $0.30 | $0.06 | $1.20 | 200K | — | $0.009 | +| `nemotron-3-ultra:cloud` | $0.10 | $0.10 | $3.00 | 256K | — | $0.014 | +| `mistral-large-3:675b-cloud` | $0.50 | — | $1.50 | 256K | — | $0.014 | +| `minimax-m3:cloud` | $0.60 | $0.12 | $2.40 | 512K | — | $0.019 | +| `mistral-large-4:cloud` | $0.68 | $0.07 | $2.09 | 1M | — | $0.019 | +| `deepseek-v4-pro:cloud` | $0.66 / $1.32 | $0.022 / $0.044 | $1.98 / $3.96 | 1M | 87.9 | $0.018–$0.036 | +| `kimi-k2.6:cloud` | $0.95 | $0.16 | $4.00 | 256K | — | $0.031 | +| `kimi-k2.7-code:cloud` | $0.95 | $0.19 | $4.00 | 256K | — | $0.031 | +| `muse-spark-open:cloud` | $1.25 | $0.15 | $4.25 | 1M | — | $0.036 | +| `glm-5.2:cloud` | $1.40 | $0.26 | $4.40 | 976K | 81.0 | $0.039 | +| `glm-5.3:cloud` | $1.40 | $0.26 | $4.40 | 1M | 88.2 | $0.039 | +| `kimi-k3:cloud` | $3.00 | $0.30 | $15.00 | 1M | 88.3 | $0.106 | + +- **For the stronger model, the first three cost under a cent a question.** + - `deepseek-v4.1-flash` has the highest published score (90.6, DeepSeek's own measurement). + - `glm-5.3-flash` is the cheapest. + - `minimax-m3.1-flash` is free until 22 October 2026. +- **GLM-5.3, DeepSeek V4 Pro and Kimi K3** publish similar scores (88.2, 87.9, 88.3) for 4 to 25 + times the price. +- **Not in the table:** `beam`, `gemma4:31b-cloud`, `nemotron-3-super`, `nemotron-3-nano` and + `clef` have cloud versions too, but list no price on their pages. Clef is an image model, not a + coding one. + +To set a price for the cost shown, use the input/output pair from the table, e.g. +`lcode config set escalate_price 0.15/0.50` for `glm-5.3-flash:cloud`. + ## Choosing a context window The **context window** is how much text the model can see at once: the conversation, files it has diff --git a/docs/usage.md b/docs/usage.md index 1b7dd91..206ab03 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -219,7 +219,8 @@ would have to read the whole conversation again. On a small GPU that's tens of s ## A stronger model when stuck Some problems are beyond a local model. When it's stuck, lcode can ask a stronger one for -advice: a cloud model with your API key, or a bigger local model. The local model gets the advice +advice: a cloud model with your API key, one of [Ollama's cloud models](models.md#cloud-models-through-ollama) +(less than a cent a question for some), or a bigger local model. The local model gets the advice and carries on with its own tools. It's off until you choose the model: ```bash @@ -227,6 +228,7 @@ lcode config set escalate_model anthropic/ # with ANTHROPIC_API_KEY in lcode config set escalate_model openai/ # OPENAI_API_KEY lcode config set escalate_model xai/ # XAI_API_KEY lcode config set escalate_model ollama/qwen3.8-27b # a bigger local model: free, and nothing leaves +lcode config set escalate_model ollama/glm-5.3-flash:cloud # Ollama's cloud, after `ollama signin` lcode config set escalate_price 3/15 # dollars per million tokens in/out, for the cost ``` @@ -257,7 +259,8 @@ a stronger model can't reach it either. You can also ask at any time: `/escalate - **Keys** come from environment variables, are read when needed, and go only to their own provider. lcode never stores them. - **Cost.** The token count is always shown; the price only when you set `escalate_price`, since - prices change. A local model is free. + prices change. A local model is free; an Ollama cloud model isn't, and the panel says it goes to + ollama.com. - **Another server:** `escalate_url` points at another Ollama (`ollama/`) or any OpenAI API (`custom/`, key in `LCODE_ESCALATE_API_KEY`). - `lcode -p` can't ask, so it doesn't escalate unless you add `--escalate`, which is your OK for diff --git a/src/lcode/agent.py b/src/lcode/agent.py index f1877a6..0191e3b 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -45,6 +45,7 @@ from lcode import context as context_tools from lcode import memory as memory_notes from lcode.checkpoints import Checkpoints +from lcode.cloud import is_cloud from lcode.config import format_tokens from lcode.jobs import Jobs from lcode.mcp import McpManager @@ -1011,8 +1012,8 @@ def run_check(self, check: checks.Check, round_: int) -> str | None: def before_request(self, progress: Progress) -> residency.LoadWatch | None: """Set the status line for what comes before the first token: loading the model if it isn't in memory (watched until it is), then reading the prompt. Returns the watch while the model loads.""" - if getattr(self.ollama, "kind", "ollama") != "ollama": - return None + if getattr(self.ollama, "kind", "ollama") != "ollama" or is_cloud(self.settings.model): + return None # another server loads models itself; an Ollama cloud model isn't loaded here at all model, context = self.settings.model, self.settings.context fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3 where = residency.placement(self.ollama, model) diff --git a/src/lcode/cli.py b/src/lcode/cli.py index 7437b64..c2de7e2 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -21,6 +21,7 @@ from lcode import __version__, backends, catalog, config, limits, web from lcode.agent import Settings from lcode.catalog import ModelSpec +from lcode.cloud import is_cloud, signin_command from lcode.config import ConfigError, format_tokens, parse_context from lcode.hardware import Hardware, detect from lcode.ollama import MIN_VERSION, Ollama, OllamaError, redact, version_tuple @@ -384,6 +385,13 @@ def line(label: str, value: str, good: bool | None = True) -> None: try: model, spec = resolve_model(ollama, cfg["model"]) line("Model", f"{cfg['model']} → {model}", True) + if is_cloud(model): + line( + "", + "runs in Ollama's cloud: what lcode sends goes to ollama.com, billed per token, and this Ollama " + f"must be signed in once: {signin_command(ollama.host)}", + None, + ) ctx, note = choose_context(ollama, model, spec, cfg["context"], hw) if spec: from lcode.subagents import parallel_slots diff --git a/src/lcode/cloud.py b/src/lcode/cloud.py new file mode 100644 index 0000000..ee7c073 --- /dev/null +++ b/src/lcode/cloud.py @@ -0,0 +1,46 @@ +"""Ollama's cloud models: tags like `minimax-m3.1-flash:cloud` run on ollama.com, through the local Ollama. + +They look like any installed model to lcode (Ollama lists them, and they chat and call tools), but nothing about +them is local: what lcode sends goes to ollama.com, it's billed per token, and it needs the Ollama signed in to an +ollama.com account (`ollama signin`). So lcode says so where it matters (`lcode doctor`, the start of a session, the +stronger-model panel), skips what only makes sense for a local model (loading it into memory, GPU fit), and turns +Ollama's bare `Unauthorized` into the command that fixes it. +""" + +from __future__ import annotations + + +def is_cloud(model: str | None) -> bool: + """Whether an Ollama model name is one of Ollama's cloud models: its tag is `cloud` or ends with `-cloud`.""" + name = (model or "").strip().lower() + if ":" not in name: + return False + tag = name.rsplit(":", 1)[1] + return tag == "cloud" or tag.endswith("-cloud") + + +def signin_command(host: str) -> str: + """How to sign the Ollama at `host` in to ollama.com: inside its container when it runs in Docker.""" + try: + from lcode import update + + container = update.find_container(host) if update.docker_available() else None + except Exception: # Docker missing, or not answering: the plain command is still right for most installs + container = None + return f"docker exec -it {container.name} ollama signin" if container else "ollama signin" + + +def unauthorized(model: str, host: str) -> str: + """The error for a cloud model that Ollama refused: what it means and the command to run.""" + return ( + f"{model} runs in Ollama's cloud (ollama.com), and this Ollama isn't signed in to an ollama.com account. " + f"Sign it in with `{signin_command(host)}`, then try again." + ) + + +def notice(model: str) -> str: + """What to know when a cloud model is lcode's main model.""" + return ( + f"{model} runs in Ollama's cloud: what lcode sends (your requests and the files it reads) goes to " + "ollama.com, and Ollama bills it per token." + ) diff --git a/src/lcode/escalate.py b/src/lcode/escalate.py index d208d45..f8194f6 100644 --- a/src/lcode/escalate.py +++ b/src/lcode/escalate.py @@ -23,6 +23,7 @@ from rich.text import Text from lcode import context as context_tools +from lcode.cloud import is_cloud from lcode.ollama import Ollama, OllamaError, redact if TYPE_CHECKING: @@ -76,14 +77,23 @@ def local(self) -> bool: @property def cloud(self) -> bool: """Paid per token: a cloud provider, or an OpenAI-compatible API elsewhere.""" - return self.provider in ("openai", "anthropic", "xai") or (self.provider == "custom" and not self.local) + return ( + self.provider in ("openai", "anthropic", "xai") + or (self.provider == "custom" and not self.local) + or (self.provider == "ollama" and is_cloud(self.model)) # an Ollama cloud model runs on ollama.com + ) @property def where(self) -> str: + if self.provider == "ollama" and is_cloud(self.model): + return "ollama.com, through " + ("this machine's Ollama" if self.local else self._host()) if self.url: - return re.sub(r"^https?://", "", redact(self.url)).split("/")[0] + return self._host() return "this machine" + def _host(self) -> str: + return re.sub(r"^https?://", "", redact(self.url)).split("/")[0] + def target(setting: str | None, url: str | None = None) -> Target | None: """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: raise EscalateError(f"escalate_model {setting!r} names no model") if provider == "custom" and not url: raise EscalateError("a custom/… escalate_model needs escalate_url, the server's OpenAI-compatible address") + if provider == "ollama" and is_cloud(model): + name = "Ollama cloud" return Target(provider, model.strip(), (url or base).rstrip("/"), key_env, f"{model.strip()} ({name})") diff --git a/src/lcode/ollama.py b/src/lcode/ollama.py index d60d597..f9eaf5a 100644 --- a/src/lcode/ollama.py +++ b/src/lcode/ollama.py @@ -9,6 +9,8 @@ import requests +from lcode.cloud import is_cloud, unauthorized + MIN_VERSION = (0, 30, 0) # first release that runs the Qwen3.5/3.6 hybrid-attention architecture @@ -49,6 +51,8 @@ def _post(self, path: str, payload: dict, timeout: float | None = 30) -> dict: r = requests.post(self._url(path), json=payload, timeout=timeout) except requests.RequestException as e: raise OllamaError(f"cannot reach Ollama at {redact(self.host)}: {e}") from e + if r.status_code == 401 and is_cloud(payload.get("model")): + raise OllamaError(unauthorized(payload["model"], self.host)) if r.status_code != 200: raise OllamaError(f"Ollama {path} failed ({r.status_code}): {r.text[:300]}") return r.json() @@ -164,6 +168,8 @@ def chat_stream(self, payload: dict, on_open: Callable[[requests.Response], None if on_open: on_open(r) with r: + if r.status_code == 401 and is_cloud(payload.get("model")): + raise OllamaError(unauthorized(payload["model"], self.host)) if r.status_code != 200: raise OllamaError(f"Ollama error {r.status_code}: {r.text[:500]}") for line in r.iter_lines(): diff --git a/src/lcode/repl.py b/src/lcode/repl.py index 25eeb64..e7ce83a 100644 --- a/src/lcode/repl.py +++ b/src/lcode/repl.py @@ -45,6 +45,8 @@ from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent from lcode.catalog import MIN_USEFUL_CONTEXT from lcode.checkpoints import Checkpoint, CheckpointError, Restore +from lcode.cloud import is_cloud +from lcode.cloud import notice as cloud_notice from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context from lcode.hardware import Hardware from lcode.ollama import OllamaError @@ -876,6 +878,8 @@ def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None: 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)}).") + if is_cloud(name): + agent.say(cloud_notice(name)) warning = residency.dense_warning(agent.ollama, name, s.context, hardware) if warning: agent.say(warning, warning=True) @@ -1256,6 +1260,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals run_safely(agent, prompt) return banner(agent) + if is_cloud(agent.settings.model): + agent.say(cloud_notice(agent.settings.model)) warning = residency.dense_warning(agent.ollama, agent.settings.model, agent.settings.context, hardware) if warning: agent.say(warning, warning=True) @@ -1286,6 +1292,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals def preload(agent: Agent, session: PromptSession | None = None) -> None: """Start loading the model in the background so the first answer comes sooner; the status bar says so.""" + if is_cloud(agent.settings.model): + return # it runs on ollama.com: there's nothing to load here where = residency.placement(agent.ollama, agent.settings.model) if where is not None and (not where.context or where.context == agent.settings.context): return # already in memory diff --git a/src/lcode/residency.py b/src/lcode/residency.py index 17e8fad..26c2da0 100644 --- a/src/lcode/residency.py +++ b/src/lcode/residency.py @@ -15,6 +15,7 @@ from dataclasses import dataclass from lcode import catalog +from lcode.cloud import is_cloud from lcode.ollama import OllamaError GB = 1e9 @@ -97,6 +98,8 @@ def expected_share(model: str, context: int, hardware) -> float | None: def dense_warning(ollama, model: str, context: int, hardware) -> str: """For a dense model that won't fit on the GPU: what that means and what to do. Empty otherwise.""" + if is_cloud(model): + return "" # it runs on ollama.com if hardware is None or hardware.unified or is_moe(ollama, model) is not False: return "" spec = catalog.find(model) diff --git a/tests/test_cloud.py b/tests/test_cloud.py new file mode 100644 index 0000000..cd39679 --- /dev/null +++ b/tests/test_cloud.py @@ -0,0 +1,86 @@ +"""Ollama's cloud models (tags like minimax-m3.1-flash:cloud): lcode says where they run and what they cost, +skips what only makes sense for a local model, and says how to sign in when Ollama refuses them.""" + +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from conftest import output +from lcode import cloud, escalate, residency +from lcode.hardware import Hardware +from lcode.ollama import Ollama, OllamaError +from lcode.repl import handle_command, preload + +LAPTOP = Hardware("linux", "x", 31, "RTX 4080 Laptop", 12) + + +def test_cloud_tags_are_recognized(): + for name in ("minimax-m3.1-flash:cloud", "deepseek-v4-pro:0813-cloud", "mistral-large-3:675b-cloud", "X:CLOUD"): + assert cloud.is_cloud(name), name + for name in ("qwen3.6:35b-a3b-coding", "lcode-qwen3.6-35b", "my-cloud-model:latest", "cloud", None, ""): + assert not cloud.is_cloud(name), name + + +class Refuses(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + body = b'{"error":"Unauthorized"}' + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +@pytest.fixture +def refusing_ollama(monkeypatch): + monkeypatch.setattr(cloud, "signin_command", lambda host: "docker exec -it my-ollama ollama signin") + server = ThreadingHTTPServer(("127.0.0.1", 0), Refuses) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield Ollama(f"http://127.0.0.1:{server.server_address[1]}") + server.shutdown() + + +def test_an_ollama_that_isnt_signed_in_says_how_to_fix_it(refusing_ollama): + payload = {"model": "minimax-m3.1-flash:cloud", "messages": [{"role": "user", "content": "hi"}]} + with pytest.raises(OllamaError) as streamed: + list(refusing_ollama.chat_stream(payload)) + with pytest.raises(OllamaError) as asked: + refusing_ollama.chat(payload) + for error in (streamed.value, asked.value): + assert "runs in Ollama's cloud (ollama.com)" in str(error) + assert "`docker exec -it my-ollama ollama signin`" in str(error) + with pytest.raises(OllamaError, match="Ollama error 401"): # a local model keeps the plain error + list(refusing_ollama.chat_stream({**payload, "model": "qwen3.6:35b-a3b-coding"})) + + +def test_the_stronger_model_panel_tells_where_a_cloud_model_runs_and_what_it_costs(): + t = escalate.target("ollama/minimax-m3.1-flash:cloud") + assert t.label == "minimax-m3.1-flash:cloud (Ollama cloud)" and t.cloud + assert t.where == "ollama.com, through this machine's Ollama" + assert escalate.cost_line(t, 12000, None).startswith("unknown: set escalate_price") + assert escalate.cost_line(t, 12000, (0.15, 0.6)).startswith("about $0.002 to $0.004") + remote = escalate.target("ollama/glm-5.3-flash:cloud", "http://gpu-box:11434") + assert remote.where == "ollama.com, through gpu-box:11434" + local = escalate.target("ollama/qwen3.8-27b") + assert not local.cloud and local.where == "this machine" + assert escalate.cost_line(local, 12000, None) == "free: it runs on this machine" + + +def test_a_cloud_model_skips_what_only_a_local_model_needs(make_agent): + agent = make_agent(model="minimax-m3.1-flash:cloud") + agent.ollama.load = lambda *a, **k: pytest.fail("nothing to load: it runs on ollama.com") + preload(agent) + assert agent.before_request(progress=None) is None + assert residency.dense_warning(agent.ollama, "minimax-m3.1-flash:cloud", 262144, LAPTOP) == "" + + +def test_switching_to_a_cloud_model_says_where_the_code_goes(agent, monkeypatch): + agent.ollama.installed_names = lambda: {"lcode-qwen3.6-35b", "minimax-m3.1-flash:cloud"} + handle_command(agent, "/model minimax-m3.1-flash:cloud", LAPTOP) + text = " ".join(output(agent).split()) + assert "Switched to minimax-m3.1-flash:cloud" in text + assert "runs in Ollama's cloud: what lcode sends (your requests and the files it reads) goes to ollama.com" in text