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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model>`, or `custom/<model>` 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/<model>` (a local model, or an [Ollama cloud model](models.md#cloud-models-through-ollama) like `ollama/glm-5.3-flash:cloud`), or `custom/<model>` 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) |
Expand Down
98 changes: 98 additions & 0 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container> 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 <container> 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
Expand Down
7 changes: 5 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,16 @@ 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
lcode config set escalate_model anthropic/<model> # with ANTHROPIC_API_KEY in your environment
lcode config set escalate_model openai/<model> # OPENAI_API_KEY
lcode config set escalate_model xai/<model> # 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
```

Expand Down Expand Up @@ -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/<model>`) or any OpenAI
API (`custom/<model>`, 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
Expand Down
5 changes: 3 additions & 2 deletions src/lcode/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/lcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/lcode/cloud.py
Original file line number Diff line number Diff line change
@@ -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."
)
16 changes: 14 additions & 2 deletions src/lcode/escalate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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})")


Expand Down
6 changes: 6 additions & 0 deletions src/lcode/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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():
Expand Down
8 changes: 8 additions & 0 deletions src/lcode/repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/lcode/residency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading