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
16 changes: 11 additions & 5 deletions apps/desktop/src/main/engine/local-models/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ async function lmStudioListing(c: ResolvedConnection, get: (path: string) => Pro
return collect(list(raw, "data"), i => i.id, lmStudioFacts);
}

/** Ollama names the models it forwards to ollama.com `…-cloud`/`…:cloud`. */
export const OLLAMA_CLOUD_NAME = /[:-]cloud$/;
/** Ollama marks models it forwards to ollama.com with remote_host, and names them `…-cloud`/`…:cloud`. */
const ollamaCloud = (i: Record<string, unknown>, id: string) => typeof i.remote_host === "string" || typeof i.remote_model === "string" || /[:-]cloud$/.test(id);
const ollamaCloud = (i: Record<string, unknown>, id: string) => typeof i.remote_host === "string" || typeof i.remote_model === "string" || OLLAMA_CLOUD_NAME.test(id);

async function ollamaListing(get: (path: string) => Promise<unknown>, show: (model: string, signal: AbortSignal) => Promise<unknown>, signal: AbortSignal, deadline: number): Promise<Listing> {
const tags = list(await get("/api/tags"), "models");
Expand Down Expand Up @@ -150,8 +152,12 @@ function descriptor(connection: ConnectionRef, id: string, source: LocalModelDes
return { model: localModelRef(connection, id), source, ...facts, advertised: { ...facts.advertised }, selectable: !reason, ...(reason ? { unselectableReason: reason } : {}) };
}

/** What a runtime's listing last said about one model's context window; `tokens` is null when it advertised none. */
export interface AdvertisedContext { tokens: number | null; checkedAt: string }
/**
* What a runtime's listing last said about one model: its context window (`tokens` is null when it
* advertised none) and, when the runtime marks it cloud-routed, embedding-only or not generative, why it
* can't be a local coding target.
*/
export interface AdvertisedContext { tokens: number | null; checkedAt: string; unselectable?: NonNullable<LocalModelDescriptor["unselectableReason"]> }

export class LocalModelDiscovery {
private readonly inflight = new Map<string, { controller: AbortController; requestId: string }>();
Expand All @@ -174,7 +180,7 @@ export class LocalModelDiscovery {
/** The runtime's advertised context for a model, from the last ready listing of its connection revision; undefined when never listed. */
advertisedContext(model: LocalModelRef): AdvertisedContext | undefined {
const context = this.contexts.get(modelKey(model));
return context && { tokens: context.tokens, checkedAt: context.checkedAt };
return context && { tokens: context.tokens, checkedAt: context.checkedAt, ...(context.unselectable ? { unselectable: context.unselectable } : {}) };
}

cancel(connectionId: string): void {
Expand Down Expand Up @@ -227,7 +233,7 @@ export class LocalModelDiscovery {
const checkedAt = this.now().toISOString();
if (failure) return { state: "unavailable", connection: { ...ref }, requestId, reason: failure.reason, detail: failure.message, models, checkedAt };
// Only the runtime's own listing counts: a manually typed model has advertised nothing.
for (const model of discovered) this.contexts.set(modelKey(model.model), { tokens: model.advertisedContext, checkedAt, connection: ref.id });
for (const model of discovered) this.contexts.set(modelKey(model.model), { tokens: model.advertisedContext, checkedAt, connection: ref.id, ...(model.unselectableReason ? { unselectable: model.unselectableReason } : {}) });
return { state: "ready", connection: { ...ref }, requestId, models, truncated: listing!.truncated, checkedAt };
}
}
8 changes: 6 additions & 2 deletions apps/desktop/src/main/engine/local-models/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { CliExecutable } from "../cli-path.js";
import { modelKey, threadTarget, type LocalModelRef, type LocalThreadTarget } from "../../../shared/local-models.js";
import type { LocalConnectionStore } from "./connections.js";
import { admitLocalConfiguration, type LocalConfigurationProvenance } from "./run-policy.js";
import type { AdvertisedContext } from "./discovery.js";
import { OLLAMA_CLOUD_NAME, type AdvertisedContext } from "./discovery.js";

/**
* Runs a thread's turns on a local model through the Codex CLI (#379), the same `codex app-server`
Expand Down Expand Up @@ -77,8 +77,12 @@ export class LocalExecution {
// Codex resolves names itself, outside LM-2's loopback-checked socket: execution takes IP literals only.
if (connection.endpoint.host === "localhost") throw new Error("Use a loopback IP address (127.0.0.1 or ::1) for local execution.");
if (connection.token && target.target.model.id.includes(connection.token)) throw new Error("A model ID can't contain the connection's token.");
// A model the runtime itself says is served elsewhere is never a local target, listed or not (#380).
const listed = this.advertisedContext(target.target.model);
if (listed?.unselectable === "cloud" || (connection.runtime === "ollama" && OLLAMA_CLOUD_NAME.test(target.target.model.id))) throw new Error("The local server forwards this model to a cloud service, so it can't run as a local model. Choose a model the server runs itself.");
if (listed?.unselectable) throw new Error(`The local server lists this model as ${listed.unselectable === "embedding" ? "an embedding model" : "a model that doesn't generate text"}, so it can't run coding turns.`);
// A window above what the runtime itself advertised would overflow there, not here: refuse it rather than find out mid-turn.
const advertised = this.advertisedContext(target.target.model)?.tokens;
const advertised = listed?.tokens;
if (typeof advertised === "number" && context! > advertised) throw new Error(`The local context window (${context} tokens) is larger than the ${advertised} tokens the server advertised for this model. Lower it, or raise the server's own setting and check its models again.`);
return target.target;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/test/local-model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ test("Ollama: installed, loaded, kind, advertised capabilities, cloud models and
assert.deepEqual([embed.kind, embed.loaded, embed.selectable, embed.unselectableReason], ["embedding", "no", false, "embedding"]);
const cloud = m["gpt-oss:120b-cloud"]!;
assert.deepEqual([cloud.locality, cloud.selectable, cloud.unselectableReason, cloud.installed], ["cloud", false, "cloud", "unknown"]);
// Main keeps why a listed model can't be a local target, for execution to refuse it (#380).
assert.equal(discovery.advertisedContext(cloud.model)?.unselectable, "cloud");
assert.equal(discovery.advertisedContext(embed.model)?.unselectable, "embedding");
assert.equal(discovery.advertisedContext(coder.model)?.unselectable, undefined);
const spaced = m["hf.co/Org/My Model:Q4_K_M"]!;
assert.deepEqual([spaced.kind, spaced.advertised.images, spaced.advertised.reasoning, spaced.advertised.tools, spaced.advertisedContext], ["generative", "advertised", "advertised", "not-advertised", null]);
const manual = m["custom:tag"]!;
Expand Down
27 changes: 24 additions & 3 deletions apps/desktop/test/local-model-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ function effectiveConfiguration(args: string[]): { config: Record<string, any>;
}

/** A local execution over a scripted app-server that answers every open with the model and provider it was asked for. */
function setup(input: { url?: string; token?: string | null; configRead?: (reported: ReturnType<typeof effectiveConfiguration>) => unknown; requirements?: unknown; rerouted?: boolean; advertised?: ConstructorParameters<typeof LocalExecution>[4] } = {}) {
function setup(input: { runtime?: "ollama" | "responses"; model?: string; url?: string; token?: string | null; configRead?: (reported: ReturnType<typeof effectiveConfiguration>) => unknown; requirements?: unknown; rerouted?: boolean; advertised?: ConstructorParameters<typeof LocalExecution>[4] } = {}) {
const home = tmpdir("lm3-exec-");
const connections = new LocalConnectionStore(home, new SecretStore(home, testCipher));
const connection = connections.create({ label: "Local", runtime: "responses", url: input.url ?? "http://127.0.0.1:18501/v1", ...(input.token === null ? {} : { token: input.token ?? "LOCAL_TOKEN_SENTINEL" }) });
const target = { schema: 1 as const, harness: "codex" as const, model: { schema: 1 as const, connection: { id: connection.id, revision: 1 }, id: MODEL } };
const connection = connections.create({ label: "Local", runtime: input.runtime ?? "responses", url: input.url ?? "http://127.0.0.1:18501/v1", ...(input.token === null ? {} : { token: input.token ?? "LOCAL_TOKEN_SENTINEL" }) });
const target = { schema: 1 as const, harness: "codex" as const, model: { schema: 1 as const, connection: { id: connection.id, revision: 1 }, id: input.model ?? MODEL } };
const children: { proc: FakeProcess; args: string[]; opts: SpawnOptions }[] = [];
const spawnImpl = ((bin: string, args: string[], opts: SpawnOptions) => {
const proc = new FakeProcess();
Expand Down Expand Up @@ -304,6 +304,27 @@ test("the context window's sources stay apart: the person's setting, the effecti
}
});

test("a model the server forwards to a cloud service, or lists as not generating text, is refused before any process starts", async () => {
const checkedAt = "2026-10-30T12:00:00.000Z";
const cases: [string, Parameters<typeof setup>[0], RegExp][] = [
["listed cloud", { advertised: () => ({ tokens: null, checkedAt, unselectable: "cloud" }) }, /forwards this model to a cloud service/],
["Ollama cloud name, never listed", { runtime: "ollama", url: "http://127.0.0.1:11434", model: "gpt-oss:120b-cloud" }, /forwards this model to a cloud service/],
["Ollama -cloud name, never listed", { runtime: "ollama", url: "http://127.0.0.1:11434", model: "deepseek-v4-cloud" }, /forwards this model to a cloud service/],
["listed embedding", { advertised: () => ({ tokens: 8192, checkedAt, unselectable: "embedding" }) }, /lists this model as an embedding model/],
["listed other", { advertised: () => ({ tokens: null, checkedAt, unselectable: "other" }) }, /a model that doesn't generate text/],
];
for (const [name, input, pattern] of cases) {
const h = setup(input);
try {
assert.throws(() => h.execution.validate(h.thread), pattern, name);
const result = await h.execution.backend(h.thread).runTurn("read", { ...h.opts, model: h.target.model.id }, collectSink().sink, new AbortController().signal);
assert.equal(result.status, "failed", name);
assert.match(result.error!, pattern, name);
assert.equal(h.children.length, 0, `${name}: nothing is spawned`);
} finally { await h.execution.dispose(); }
}
});

test("a configuration another layer governs, managed requirements or a Codex without config/read refuse the turn before thread/start", async () => {
const cases: [string, Parameters<typeof setup>[0], RegExp][] = [
["managed base_url", { configRead: r => { r.origins["model_providers.modex_local.base_url"] = { name: { type: "system", file: "/etc/codex/config.toml" }, version: "sha256:m" }; return r; } }, /the system configuration at \/etc\/codex\/config.toml sets `model_providers.modex_local.base_url`/],
Expand Down
15 changes: 15 additions & 0 deletions docs/local-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ runs with is never a cloud model's default. It is the person's explicit `localCo

Evidence: [LM-5 context-window evidence](reviews/2026-10-30-lm5-context-evidence.md).

## LM-5: known cloud-routed and non-generative models

Status: on `main`, no UI, local execution still unadmitted (#380, step 3). A loopback server can forward a model
elsewhere: Ollama serves `…:cloud`/`…-cloud` models from ollama.com. Discovery already marked these
unselectable, but that was the renderer's list. Main now refuses them itself, in `LocalExecution.validate`,
before any process starts:

- **Cloud-routed.** The connection's last ready listing marked the model cloud-routed (Ollama's
`remote_host`/`remote_model`, or its cloud name). On an Ollama connection, a cloud-suffixed name is also
refused without any listing.
- **Not generative.** The listing marked the model embedding-only or not generating text.

A model that isn't marked stays *unverified*, not local: loopback alone doesn't prove where inference runs, and
an unknown downstream route is still unknown. Evidence: [LM-5 cloud-routed models](reviews/2026-10-30-lm5-cloud-routed-models.md).

## Accepted boundary and follow-up runtime availability

Val accepted narrow Gate A and separately approved the AGENTS metadata boundary in this task on 2026-10-24. The [acceptance and runtime-availability receipt](reviews/2026-10-24-lm1-acceptance.md) records the exact producer SHA, approval scope and authorized installed-model inventory. The accepted producer and Strata evidence may be consumed by LM-2/LM-3; acceptance does not qualify Plan, fresh-process resume, other runtimes, offline operation or UI.
Expand Down
45 changes: 45 additions & 0 deletions docs/reviews/2026-10-30-lm5-cloud-routed-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# LM-5 known cloud-routed models (#380 step 3), 2026-10-30

Issue #380 remains open. This slice builds on the context-window evidence slice. It moves the refusal of known
cloud-routed and non-generative models from the renderer's model list into main. It does not admit product
local execution, and it does not verify where unmarked models run.

## What changed

- **`LocalModelDiscovery`.** The per-model record from the last ready listing also keeps `unselectable`
(`cloud`, `embedding` or `other`) when the runtime marked the model that way. Its record of advertised
context is unchanged.
- **`LocalExecution.validate`.** Refuses, before any process starts:
- a model whose record says `cloud`;
- on an Ollama connection, any model named `…:cloud` or `…-cloud` (`OLLAMA_CLOUD_NAME`), even if it was never
listed;
- a model whose record says `embedding` or `other`.
- **Unmarked models** remain unverified. Loopback is the configured destination, not verified locality.

## Verification

- **Execution suite.** A new regression covers five cases: a listed cloud model, two unlisted Ollama cloud names
(`:cloud` and `-cloud`), a listed embedding model and a listed non-text model. Each is refused by `validate`
and by the turn, and no process is spawned.
- **Ollama discovery case.** It now checks that main's record keeps `cloud` and `embedding`, and nothing for the
generative coder.

**Negative controls.** Each guard was disabled in turn in the compiled output, and each made its regression fail:

- the listed-cloud check;
- the Ollama name check;
- the non-generative check;
- the discovery record.

The files were then restored, verified byte for byte with `cmp`, and both cases passed again.

**Results:**

- Focused suites (execution, discovery, runner local models, run-policy): 59/59.
- Full-check results are recorded in the PR.

## Still open on #380

- the network-denied offline canary (AC2);
- real-runtime overflow, OOM and restart handling (AC4);
- product admission, which is Val's decision.
1 change: 1 addition & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ Desktop CI memory diagnostics landed through #458 after #456’s cold transcript

- **LM-5 auxiliary dispatch regressions.** Candidate tests exercise deterministic local approvals, human approval fallback and four local failure outcomes with a recording Jev transport and cloud positive control. Thirteen focused cases passed; removing the local approval guard made both local approval cases fail on unexpected dispatch. Build and full unit validation passed (2,251 passes, 26 skips); independent review found no actionable findings. Protected delivery landed through #447; native offline acceptance and the remaining #380 criteria stay open. [Evidence](reviews/2026-10-30-lm5-auxiliary-dispatch.md).
- **LM-5 context-window evidence (#380 AC6).** A local child's context window keeps its sources apart: the person's explicit setting, the effective value Codex reports, the runtime's last advertised maximum (or "not checked") and an unknown loaded size. A window above the advertised maximum is refused before any process starts. Disabling each of the four new guards made its regression fail. Product admission, the native offline canary and real-runtime overflow handling stay open. [Evidence](reviews/2026-10-30-lm5-context-evidence.md).
- **LM-5 known cloud-routed models refused by main (#380).** `LocalExecution.validate` refuses a model the connection's last listing marked cloud-routed, embedding-only or not generative. On an Ollama connection it also refuses a `…:cloud`/`…-cloud` name without any listing. Nothing is spawned. Unmarked models stay unverified rather than local. Disabling each of the four guards made its regression fail. [Evidence](reviews/2026-10-30-lm5-cloud-routed-models.md).

- **Canvas Escape harness readiness.** Repeated hosted failures showed the main pane still hidden after guest Escape. A controlled delayed-IPC diagnostic proved that renderer full-view state can precede native readiness. The test-only candidate waits for the native guest to become visible at the expanded preview bounds before sending one Escape, retaining all security and lifecycle assertions. Five candidate repetitions and full local validation passed; the implementation also passed hosted macOS validation. The Windows first failure, passing retry and original CodeQL upload failure remain recorded. It landed through #446 (`2ce777c`) on 2026-10-30, after v0.4.0. [Evidence](reviews/2026-10-30-canvas-native-readiness.md).

Expand Down
Loading