From 1a18aceca808523d78f3512f4355353099457f8f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 01:24:54 +0900 Subject: [PATCH 001/132] docs: add minimal container / agent sandbox instructions to AGENTS.md (#2832) * release: v2.32.1 * release: v2.33.0 * chore: add Cloud Agent dev environment config Add .cursor/environment.json so Cloud Agents boot ready for the Bun-native runtime: install bun + project/gui dependencies, expose the proxy on port 8899, and run 'ocx start' as a persistent terminal. Co-authored-by: JUN * docs: record Cloud Agent setup in AGENTS.md instead of environment.json Drop the .cursor/environment.json added earlier on this branch and document the same knowledge as a 'Cursor Cloud specific instructions' section: Bun is not preinstalled (install via the official installer), how to run the proxy, and the five environment-only test failures (no systemd init; container filesystem mtime granularity) so future agents do not re-investigate them. Co-authored-by: JUN * docs: generalize sandbox setup notes beyond Cursor Cloud Retitle the section 'Minimal containers and agent sandboxes' and phrase the guidance so it covers any fresh dev container or agent sandbox (Cursor Cloud, devcontainers, CI images): Bun is often absent, service tests need a running systemd init, and two integrity tests need fine mtime granularity. Co-authored-by: JUN --------- Co-authored-by: Cursor Agent --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 50db477a0d..dcc3544be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,37 @@ on Linux, Windows, and macOS. Do not rerun passing checks on unchanged code merely for additional confidence. +## Minimal containers and agent sandboxes + +Fresh dev containers and agent sandboxes (Cursor Cloud, devcontainers, CI +images) often ship Node but not Bun. Install it first: + +```bash +curl -fsSL https://bun.sh/install | bash # installs ~/.bun/bin/bun +export PATH="$HOME/.bun/bin:$PATH" +bun install && (cd gui && bun install) +``` + +Run the proxy with `bun run src/cli/index.ts start --port `. `/healthz` +reports status, `/` serves the dashboard, and the management API requires the +admin token the server writes to `$OPENCODEX_HOME/admin-api-token` at startup. + +`bun run test` has five known environment-only failures in such containers. +They are not regressions; do not re-investigate them: + +- `service diagnostics > status summary exposes the service log path`, + `CLI subcommand help > status prints diagnostics without starting the proxy`, + and `CLI subcommand help > invalid service and codex-shim usage include + remove alias` require a running systemd init; in a container PID 1 is + typically `tini` or another minimal init, so service commands report + "systemd not found". +- `package tree integrity > an in-place rewrite of the same byte length is + still a replacement` and `Codex Log Guard inspection > repeat inspection is + memoized and invalidated by a write` rely on filesystem mtime granularity + that some container filesystems do not provide. + +Everything else passes (15480 pass / 16 skip / 5 fail as of 2.35.0). + ## Issues and pull requests (agents) Agent-created issues and PRs must use the repository templates. The gates From befcac3e10ac175f9aa8de65a799abd0b5e8f7aa Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sat, 29 Aug 2026 03:35:10 +0900 Subject: [PATCH 002/132] fix(release): move dev's version line past the published preview dev's package.json said 2.35.0 while the repository had already published v2.36.0-preview.20260829 (npm dist-tags: preview=2.36.0-preview.20260829, latest=2.35.0). The preview bump was cut on the prerelease train and never came back to dev, so tests/release-version-line.test.ts fails on every commit that descends from dev: release version line > the in-tree version is never behind a released one package.json version 2.35.0 is BEHIND the highest release tag v2.36.0-preview.20260829 That is inherited red, not a defect in any of the pull requests hitting it. It currently fails test 2/4, test 3/4, test 4/4, and macos on #2835, #2822, #2821, #2796, #2797, and #2785 - six bug PRs whose own diffs are unrelated to release tooling. Rebasing them onto an unrepaired dev cannot turn them green, which is why this lands first. 2.36.0 rather than a preview suffix follows the precedent this repository set twice: e4a85d134 moved dev to 2.34.0 when it trailed a published 2.33.0, and 076ad3036 moved dev to 2.35.0 right after v2.34.0 shipped. dev carries the next stable version; the preview train adds its own suffix at release time. The value was chosen by running the repository's own comparator rather than by reading it. Against the highest tag v2.36.0-preview.20260829, compareReleaseTags returns -1 for 2.35.0 and 2.35.1, 0 for 2.36.0-preview.20260829 (legal only on the commit that tag names, which a dev merge commit is not), and +1 for 2.36.0. npm view @bitkyc08/opencodex@2.36.0 returns E404 and git tag --list v2.36.0 is empty, so the string is unused. Verification on this branch: bun test tests/release-version-line.test.ts 3 pass 0 fail (was 2 pass 1 fail) bun test tests/release-helper.test.ts 5 pass 0 fail bun test tests/compatibility-version.test.ts 1 pass 0 fail --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57f06ad22b..605a48e65b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.35.0", + "version": "2.36.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 69031f6aa3055dbc30f117ef4ec858826f29e5b4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 04:24:49 +0900 Subject: [PATCH 003/132] fix(kiro): render one answer when prose and the completion tool share an inference (#2835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro emits answer-shaped prose and then calls the private completion tool in the SAME inference. The adapter released the prose as `phase: "commentary"` and the completion answer as `phase: "final_answer"`, and `src/bridge.ts` closes the commentary message on the phase change, so the client rendered two assistant messages with near-identical text. That is the duplicate answer users reported after #2819. A valid completion answer supersedes prose staged during the same inference, so consume that collection instead of releasing it: drop the redundant `text_delta`, keep every non-text event, and release retention either way. Applied to `deferred` in required mode and `fallbackEvents` in text_fallback, which is already gated on a valid completion answer. The outer drain in `parseKiroAttempt` is deliberately untouched. An independent audit caught that it is also the leftover flush for early terminal returns, so teaching it to discard text would hide the only commentary a failed turn ever produces. Splicing at the inner site leaves that drain empty on the completion path and unchanged on every failure path. Two existing expectations pinned the duplicate as intended behaviour and now state the new contract. A Responses-protocol assertion covers what adapter-event coverage cannot: the split happens in the bridge, so the proof has to be taken at the wire. Both were driven red against the unpatched adapter — two assistant messages before, one after. --- .../021_audit_round3.md | 71 ++++++++++++++ .../030_wp1_live_measurement.md | 94 +++++++++++++++++++ src/adapters/kiro.ts | 28 +++++- tests/kiro-stream.test.ts | 6 +- tests/server-kiro-completion-e2e.test.ts | 52 ++++++++++ 5 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md create mode 100644 devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md diff --git a/devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md b/devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md new file mode 100644 index 0000000000..c6f3c51325 --- /dev/null +++ b/devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md @@ -0,0 +1,71 @@ +# wp2 audit round 3 — the outer drain must not learn about completions + +Reviewer: independent explorer lane, read-only, HEAD `a43e4cda`. +Verdict: **FAIL** on the plan as written in `020_wp2_duplicate_answer.md`. +This document records the finding and the corrected design. + +## What the reviewer accepted + +- Consuming the retained collection on a valid completion IS sufficient to remove + the user-visible duplicate. Required-mode text is held only in `deferred` + (`src/adapters/kiro.ts:1174-1179`, `:1207`), fallback text only in + `fallbackEvents` (`:1202-1205`), and a valid completion never live-flushes that + run because a completion alongside a real tool call is already a protocol error + (`:1260-1261`). With the commentary `text_delta` gone, `src/bridge.ts` never + splits the message. +- There is **no third emitter**. `:1523` builds a new event from + `completionAnswer`; it does not read `deferred`. The early-loop yields at + `:1398` and `:1418` only flush `deferred` when a real tool starts (`:1175`). +- Dropping `text_delta` while keeping non-text retained events loses nothing + load-bearing on this path. Tool events never sit in the collection — they splice + live and forbid a valid completion. +- `releaseEvent` is idempotent via its `eventBytes` map guard (`:808-810`), so a + double release cannot double-credit the budget. + +## The blocker + +`020`'s option 1 says to consume the collection at BOTH readers — the inner flush +and the outer drain in `parseKiroAttempt` (`:996-1001`). The reviewer showed the +second half is wrong: + +> `996-1001` is also the leftover flush for early `terminal` returns that never +> hit `1468` (`1405-1413`). Dropping `text_delta` there without a completion flag +> hides the only commentary. + +That outer drain is the release path for stream, protocol, and provider failures — +the row `020`'s own table requires to stay intact. A turn that fails after emitting +progress prose would lose that prose entirely, which is a worse defect than the +duplicate: the user would see an error with no indication of what the model had +been doing. + +## Corrected design + +The inner site is the only consumer, and it leaves the collection **empty**: + +1. `src/adapters/kiro.ts:1468`, `mode === "required"`: when + `completionAnswer !== undefined`, splice the collection and consume it — drop + each `text_delta` after releasing its retention, yield every non-text event and + release it. When there is no completion answer, flush exactly as today. +2. `:1474`, `mode === "text_fallback"`: this branch is **already** gated on + `completionAnswer !== undefined`, so the same consume applies there and nowhere + else in that mode. +3. `:996-1001`, the outer drain: **unchanged**. Because step 1 splices, there is + nothing left for it to emit on the completion path, and it keeps its full + flush behaviour for every early-terminal path. + +The correction is that suppression is expressed by emptying the collection at the +one site that knows a completion arrived — not by teaching a second, +failure-serving reader to discard text. + +Untouched, per `020`'s release table: the `sawRealTool` flush (`:1483`), the +plain-text promotion (`:1490-1498`), and the empty/reasoning-only fallback +(`:1505`). + +## Budget note + +The reviewer's condition for a leak is "splice, skip `releaseEvent`, and skip +`releaseAll`". The consume path releases every event it drops, and +`releaseRetained`/`releaseAll` still runs for the `trackReplacement` remainder +(`:801-803`), which is not tracked in `eventBytes`. A test asserts the budget +returns to baseline. + diff --git a/devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md b/devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md new file mode 100644 index 0000000000..c734eb8441 --- /dev/null +++ b/devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md @@ -0,0 +1,94 @@ +# wp1 — live measurement: what is stale, what is a real defect + +Measured 2026-08-29 by direct probe of three hosts plus the current tree. +This phase changed no production code. + +## Host attribution + +| host | opencodex | process | age at measurement | verdict | +|------|-----------|---------|--------------------|---------| +| jun's mac (local) | 2.35.0, run from the checkout via `bun src/cli/index.ts start --port 10100` | PID 62773 | started 2026-08-28 22:12:11 | **STALE by 3 commits** | +| `suji` (sujis-MacBook-Pro, 100.65.106.2) | 2.24.2, installed binary `~/.local/bin/ocx` | PID 98048 | uptime 941472s ≈ **10.9 days** | **GROSSLY STALE** — predates every Kiro fix in this unit | +| `macmini-cf` (juniui-Macmini) | checkout `~/opencodex` at `d7a82a8fc` (dev, includes all of #2819) | none | `ocx` not installed, no proxy running | current source, not serving | + +Commands: `ps -o lstart=,etime= -p 62773`, `curl -s localhost:10100/healthz` on each +host, `git log --oneline -3` in `~/opencodex` on macmini-cf. + +## Finding 1 — part of the non-termination report is a stale process + +The local proxy the user was routed through started at **22:12:11**. Three commits +of #2819 landed *after* that: + +| commit | time | content | +|--------|------|---------| +| `b0740840d` | 22:14 | mark the physical attempt as locally answered too | +| `d9d26552f` | 22:15 | state the code-mode echo rule before the first call | +| `68eaf45d8` | 22:30 | remember a delivered final answer instead of trusting the client to echo phase | + +`68eaf45d8` is the one that matters: it stops the terminal boundary from depending +on the client echoing `phase`. A proxy started before it cannot have the completed +form of the wp1 terminal-boundary fix. So the user's "still doesn't finish" report +is measured against a binary that never contained the finished fix. + +`suji` is worse and is worth stating plainly: at 2.24.2 with 10.9 days of uptime it +predates the entire unit, so any Kiro turn routed there reproduces every symptom +regardless of what `dev` contains. + +**This does not close the report.** It explains part of it. Finding 2 is a real +defect in current source. + +## Finding 2 — the duplicate answer is live in current `dev` + +Live probes against the running proxy (`/v1/responses`, streaming, +`kiro/claude-opus-5`): + +- plain question, no tools -> 1 visible assistant message, `phase: "final_answer"`, `end_turn: true` +- question with a tool available, answered directly -> 1 visible message +- tool-result round trip -> normal `function_call` + +Those turns are clean, which is consistent with finding 1's fix having landed in +source. But the duplicate needs a specific shape: **one inference that emits +ordinary prose AND then calls the private completion tool**. The model does not +take that shape on every turn, so a live probe is not a reliable trigger. + +It does not need to be. The shape is pinned deterministically by the suite in the +current tree — `tests/kiro-stream.test.ts` asserts the duplicate as expected +behaviour in three places: + +| test | asserted events | +|------|-----------------| +| "tool-enabled commentary can finish only through a fragmented private completion call" (:267) | `"Checking the result."` commentary, then `"Task complete."` final_answer | +| "STOP_SEQUENCE text also enters bounded completion validation" (:659) | `"Done."` commentary, then `"Done."` final_answer — **byte-identical** | +| "END_TURN does not promote a private completion answer's commentary" (:746) | `"Checking the result."` commentary, then `"Task complete."` final_answer | + +Verified green at HEAD: `bun test tests/kiro-stream.test.ts -t "STOP_SEQUENCE text +also enters bounded completion validation"` -> 1 pass, 3 expect() calls. + +The `:659` case is the user's symptom exactly: the same text delivered twice, once +as commentary and once as the final answer. `src/bridge.ts` splits on the phase +change, so the client renders two assistant messages. + +Mechanism in source, unchanged from `000_research.md`: +`src/adapters/kiro.ts:1468` flushes the whole `deferred` collection +unconditionally when `mode === "required"`, immediately before the completion +answer is emitted as `final_answer`. Nothing consumes the retained prose when a +valid completion answer supersedes it. + +## Finding 3 — the outer drain is a second, independent emitter + +`parseKiroAttempt` drains `deferred` again at `src/adapters/kiro.ts:996-999` after +the inner generator returns. Skipping only the inner flush at `:1468` therefore +does not remove the duplicate — it reverses its order. Any fix must CONSUME the +collection, not skip one of its two readers. This confirms the audit round-2 +correction in `020_wp2_duplicate_answer.md` against current source. + +## Conclusion + +- The non-termination report: **partly stale process** (local proxy predates + `68eaf45d8`; `suji` predates the entire unit). Restart is the remedy, not a code + change. +- The duplicate answer: **a real, currently-pinned defect in `dev`**. It is wp2. + +Both hosts need a restart onto current `dev` before any further live judgement of +turn termination is meaningful. + diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index ccfd80febe..d405dfb7dd 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1062,6 +1062,27 @@ async function* parseKiroAttemptEvents( try { yield event; } finally { retention.releaseEvent(event); } } }; + // A valid private completion answer supersedes the progress prose staged during the SAME + // inference: Kiro emits answer-like text and then calls the completion tool, so releasing both + // makes the bridge close the commentary message and open a second one with near-identical text + // (#2819 follow-up). Consume the collection instead — drop the redundant text, keep every + // non-text event, and release retention either way. + // + // This is deliberately the ONLY suppression site. The outer drain in `parseKiroAttempt` is also + // the leftover flush for early terminal returns (stream, protocol, and provider failures), so + // teaching it to discard text would hide the only commentary a failed turn ever produced. + // Splicing here leaves that drain empty on the completion path and untouched everywhere else. + const consumeSupersededByCompletion = async function* ( + events: AdapterEvent[], + ): AsyncGenerator { + for (const event of events.splice(0)) { + try { + if (event.type !== "text_delta") yield event; + } finally { + retention.releaseEvent(event); + } + } + }; const providerState = (): { kiro: { conversationId: string } } | undefined => returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; @@ -1466,12 +1487,15 @@ async function* parseKiroAttemptEvents( }); if (mode === "required") { - yield* emitRetained(deferred.splice(0)); + // A valid completion answer makes this inference's staged prose redundant; anything else + // still flushes exactly as before (bounded fallback, explicit stops, real tool calls). + if (completionAnswer !== undefined) yield* consumeSupersededByCompletion(deferred); + else yield* emitRetained(deferred.splice(0)); } if (mode === "text_fallback") { if (completionAnswer !== undefined) { - yield* emitRetained(fallbackEvents); + yield* consumeSupersededByCompletion(fallbackEvents); yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; return { assistantText, diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index f38b0d1b2d..1e6d3eb393 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -267,8 +267,9 @@ describe("kiro adapter — parseStream", () => { ...completionFrames("Task complete."), )))); + // The completion answer supersedes prose staged in the SAME inference. Releasing both made the + // bridge split one turn into two near-identical assistant messages, which is what the user saw. expect(events.filter(event => event.type === "text_delta")).toEqual([ - { type: "text_delta", text: "Checking the result.", phase: "commentary" }, { type: "text_delta", text: "Task complete.", phase: "final_answer" }, ]); expect(events.some(event => event.type === "tool_call_start" || event.type === "tool_call_delta")).toBe(false); @@ -742,8 +743,9 @@ describe("kiro adapter — parseStream", () => { eventFrame({ stopReason: "END_TURN" }, "metadataEvent"), )))); + // END_TURN still does not promote the prose to a final answer — but the prose is now consumed + // rather than released, so the turn renders as one answer instead of two. expect(events.filter(event => event.type === "text_delta")).toEqual([ - { type: "text_delta", text: "Checking the result.", phase: "commentary" }, { type: "text_delta", text: "Task complete.", phase: "final_answer" }, ]); expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); diff --git a/tests/server-kiro-completion-e2e.test.ts b/tests/server-kiro-completion-e2e.test.ts index 5708c913e0..61b6181643 100644 --- a/tests/server-kiro-completion-e2e.test.ts +++ b/tests/server-kiro-completion-e2e.test.ts @@ -218,6 +218,58 @@ describe("Kiro completion through public server endpoints", () => { } }); + + test("answer-like prose plus a completion answer in ONE inference renders exactly one answer", async () => { + // The user-visible defect: Kiro emits answer-shaped prose and then calls the private completion + // tool in the SAME inference. Releasing both made bridge.ts close the commentary message and + // open a second one with near-identical text, so the client rendered the answer twice. + // Adapter-event coverage cannot prove this is gone — the split happens in the bridge. + const upstream = scriptedKiroUpstream([ + [textFrame("The workspace is ready."), ...completionFrames("The workspace is ready.")], + ]); + saveConfig(kiroConfig(upstream.server.url.toString())); + const proxy = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", proxy.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "kiro-test/gpt-5.6-sol", + input: "Inspect the workspace", + stream: true, + tools: [{ type: "function", name: "bash", description: "Run a command", parameters: { type: "object" } }], + }), + }); + + expect(response.status).toBe(200); + const wire = await response.text(); + const events = responseEvents(wire); + + // One inference only: the completion answer resolves the turn, so no bounded fallback runs. + expect(upstream.requests).toHaveLength(1); + + // Exactly one visible assistant answer, and the prose is not repeated as its own message. + const completed = events.filter(event => event.name === "response.completed"); + expect(completed).toHaveLength(1); + const messages = completed[0].data.response.output.filter((item: { type: string }) => item.type === "message"); + expect(messages).toHaveLength(1); + expect(messages[0].phase).toBe("final_answer"); + expect(messages[0].content.map((part: { text: string }) => part.text).join("")).toBe("The workspace is ready."); + + // And the duplicate is gone at the delta level too, not merely coalesced into one message. + const deltas = events + .filter(event => event.name === "response.output_text.delta") + .map(event => event.data.delta); + expect(deltas.join("")).toBe("The workspace is ready."); + + expect(events.at(-1)?.name).toBe("response.completed"); + expect(wire).not.toContain(KIRO_COMPLETION_TOOL_NAME); + } finally { + await proxy.stop(true); + upstream.server.stop(true); + } + }); + test("routed compaction with text.format summarizes instead of tripping the capability guard", async () => { const upstream = scriptedKiroUpstream([ [textFrame("Compaction summary of the earlier turns.")], From e2345559089919dd24f1d2213ec1d8fe662963d3 Mon Sep 17 00:00:00 2001 From: audit Date: Fri, 28 Aug 2026 02:06:29 +0200 Subject: [PATCH 004/132] fix(catalog): drop default_verbosity when verbosity is unsupported --- src/codex/catalog/parsing.ts | 7 +- tests/catalog-verbosity-default.test.ts | 90 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/catalog-verbosity-default.test.ts diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0293b4bd04..94241e8a75 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -406,7 +406,12 @@ export function ensureStrictCatalogFields( if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = false; if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none"; if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true; - if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low"; + // A row that has declared it does NOT support verbosity must not also ship a default for the + // control it just disowned: Codex seeds its picker from `default_verbosity`, so leaving the + // strict-fields fallback in place re-creates the dead toggle the explicit opt-out removed. + // Scoped to an explicit `false`, so rows that never declare a capability keep the default. + if (entry.support_verbosity === false) delete entry.default_verbosity; + else if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low"; if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform"; if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) { entry.truncation_policy = { mode: "tokens", limit: 10000 }; diff --git a/tests/catalog-verbosity-default.test.ts b/tests/catalog-verbosity-default.test.ts new file mode 100644 index 0000000000..95e4bd93be --- /dev/null +++ b/tests/catalog-verbosity-default.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect, upstreamNativeEntry } from "../src/codex/catalog"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; + +/** + * A serialized row that declares `support_verbosity: false` must not also carry a + * `default_verbosity`. Codex seeds its picker from `default_verbosity`, so leaving the + * strict-fields fallback in place re-creates the dead toggle that the explicit opt-out + * (#2578 architecture) removed. All field names here are the SERIALIZED Codex spellings — + * `supports_verbosity` does not exist in this format, which is exactly how an earlier + * assertion passed while every routed row advertised the control. + */ +describe("catalog — default_verbosity is dropped when verbosity is unsupported", () => { + test("RED: an opted-out routed row carries no verbosity default", async () => { + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-4.6"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const xai = entries.find(e => e.slug === "xai/grok-4.6"); + expect(xai?.support_verbosity).toBe(false); + expect(xai?.default_verbosity).toBeUndefined(); + }); + + test("RED: a Kiro opted-out row carries no verbosity default either", async () => { + const models = await gatherRoutedModels({ + providers: { + kiro: { + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", + liveModels: false, + models: ["gpt-5.6-sol"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const kiro = entries.find(e => e.slug === "kiro/gpt-5.6-sol"); + expect(kiro?.support_verbosity).toBe(false); + expect(kiro?.default_verbosity).toBeUndefined(); + }); + + test("CONTROL: rows that never declare a capability keep the permissive default", async () => { + const models = await gatherRoutedModels({ + providers: { + plain: { + adapter: "openai-responses", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["plain-model"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const plain = entries.find(e => e.slug === "plain/plain-model"); + expect(plain?.support_verbosity).toBe(true); + expect(plain?.default_verbosity).toBe("low"); + }); + + test("CONTROL: a native OpenAI row keeps verbosity and its default", () => { + const template = upstreamNativeEntry("gpt-5.6-sol"); + expect(template).not.toBeNull(); + const entries = buildCatalogEntries(template, ["gpt-5.6-sol"], []); + const native = entries.find(e => e.slug === "gpt-5.6-sol"); + expect(native?.support_verbosity).toBe(true); + expect(native?.default_verbosity).toBe("low"); + }); +}); From 249e1fbcbb1dd167bf7d39f1897d19901c74688c Mon Sep 17 00:00:00 2001 From: potota90 <85318310+adtumk@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:07:05 +0200 Subject: [PATCH 005/132] test(catalog): rename post-fix verbosity cases --- tests/catalog-verbosity-default.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/catalog-verbosity-default.test.ts b/tests/catalog-verbosity-default.test.ts index 95e4bd93be..75a07cca66 100644 --- a/tests/catalog-verbosity-default.test.ts +++ b/tests/catalog-verbosity-default.test.ts @@ -25,7 +25,7 @@ const originalFetch = globalThis.fetch; * assertion passed while every routed row advertised the control. */ describe("catalog — default_verbosity is dropped when verbosity is unsupported", () => { - test("RED: an opted-out routed row carries no verbosity default", async () => { + test("GREEN: an opted-out routed row carries no verbosity default", async () => { const models = await gatherRoutedModels({ providers: { xai: { @@ -43,7 +43,7 @@ describe("catalog — default_verbosity is dropped when verbosity is unsupported expect(xai?.default_verbosity).toBeUndefined(); }); - test("RED: a Kiro opted-out row carries no verbosity default either", async () => { + test("GREEN: a Kiro opted-out row carries no verbosity default either", async () => { const models = await gatherRoutedModels({ providers: { kiro: { From 7f812dd71491a2fc53dc8968440701073b3fdf08 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 27 Aug 2026 18:37:14 -0700 Subject: [PATCH 006/132] fix(security): classify NAT64-embedded IPv4 instead of refusing the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classifyIpv6` decodes both `::ffff:` IPv4-mapped forms and judges the embedded address, but had no case for NAT64. RFC 6052's well-known prefix 64:ff9b::/96 leads with hextet 0x64, which sits below the 2000::/3 global-unicast window, so it fell through to the closing `non-global address` branch. On any IPv6-only or DNS64 network that is not an edge case: the resolver synthesizes 64:ff9b:: for every IPv4-only peer, so ordinary public destinations were rejected outright. `tests/codex-catalog.test.ts` and `tests/baseten-provider.test.ts` already carry `allowPrivateNetwork: true` workarounds naming NAT64, and `key-login-live-update` has been red on `dev` for the same reason: `notifyRunningProxy` got `409 provider reload target rejected` from `providerDestinationResolvedError`, so the credential reached disk while the running proxy kept serving the stale in-memory row. The decode mirrors the `::ffff:` handling — extract the embedded IPv4 and run it through `classifyIpv4` — which is what keeps this from becoming an SSRF bypass. Verified per address: wrapped 127.0.0.1, 10/8, 172.16/12 and 192.168/16 stay blocked, and wrapped 169.254.169.254 still lands on the stronger metadata blocklist. Only the well-known prefix is decoded; RFC 8215 reserves 64:ff9b:1::/48 for local-use translation, so it keeps its non-global treatment. Prefix matching needs all eight hextets rather than the leading group `firstIpv6Hextet` returns, so `ipv6Hextets` expands the literal, handling `::` compression and the RFC 4291 trailing dotted-quad form. Reverting the classifier turns the new coverage and `key-login-live-update` red. Co-Authored-By: Claude Opus 5 --- src/lib/destination-policy.ts | 53 +++++++++++++++++++++++ tests/destination-policy-resolved.test.ts | 43 ++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 46b2aa91ba..d552b78065 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -68,6 +68,45 @@ function classifyIpv4(hostname: string): DestinationAssessment { return { kind: "public", detail: "public IP" }; } +/** + * Expand an IPv6 literal into its eight hextets, or null when it is not one this can parse. + * `firstIpv6Hextet` below only needs the leading group; prefix matching needs the whole address, + * and `::` compression plus the RFC 4291 trailing dotted-quad form both have to be handled. + */ +function ipv6Hextets(hostname: string): number[] | null { + let text = hostname; + const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/); + if (dotted?.index !== undefined) { + const octets = dotted[1].split(".").map(Number); + if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null; + text = text.slice(0, dotted.index) + + ((octets[0]! << 8) | octets[1]!).toString(16) + + ":" + + ((octets[2]! << 8) | octets[3]!).toString(16); + } + const halves = text.split("::"); + if (halves.length > 2) return null; + const parseGroups = (part: string): number[] | null => { + if (!part) return []; + const out: number[] = []; + for (const piece of part.split(":")) { + if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null; + out.push(Number.parseInt(piece, 16)); + } + return out; + }; + const head = parseGroups(halves[0] ?? ""); + const tail = halves.length === 2 ? parseGroups(halves[1] ?? "") : []; + if (!head || !tail) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + const fill = 8 - head.length - tail.length; + if (fill < 1) return null; + return [...head, ...Array(fill).fill(0), ...tail]; +} + +/** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */ +const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const; + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -89,6 +128,20 @@ function classifyIpv6(hostname: string): DestinationAssessment { const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`; return classifyIpv4(ipv4); } + // NAT64 (RFC 6052): on an IPv6-only/DNS64 network every IPv4-only peer is synthesized into + // 64:ff9b::, whose leading hextet (0x64) is below the 2000::/3 global-unicast window and + // so fell through to "non-global address". That rejected ordinary public destinations for any + // user behind NAT64 — two tests already worked around it with `allowPrivateNetwork: true`. + // Classify the EMBEDDED IPv4 instead, exactly as the ::ffff: forms above do, so a wrapped + // 127.0.0.1 or 10/8 stays blocked rather than becoming an SSRF bypass. Only the well-known + // prefix is decoded; RFC 8215's 64:ff9b:1::/48 is reserved for local-use translation and keeps + // its non-global treatment. + const hextets = ipv6Hextets(hostname); + if (hextets && NAT64_WELL_KNOWN_PREFIX.every((group, index) => hextets[index] === group)) { + const hi = hextets[6]!; + const lo = hextets[7]!; + return classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`); + } if (hostname === "::1") return { kind: "loopback", detail: "loopback address" }; if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" }; const hextet = firstIpv6Hextet(hostname); diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 98a4db6827..207f73ec8c 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -253,3 +253,46 @@ describe("resolvePublicAddresses — caller-specific diagnostics", () => { )).rejects.toThrow("benchmark address (198.19.7.9)"); }); }); + +describe("providerDestinationConfigError — NAT64 well-known prefix (RFC 6052)", () => { + // On an IPv6-only/DNS64 network every IPv4-only peer is synthesized into 64:ff9b::. + // 0x64 sits below the 2000::/3 global-unicast window, so the wrapper alone read as + // "non-global address" and rejected ordinary public destinations for anyone behind NAT64. + test("a wrapped public IPv4 is accepted", () => { + for (const host of ["64:ff9b::d25:c62c", "64:ff9b::0fe0:7748", "64:ff9b::13.37.198.44"]) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))).toBeNull(); + } + }); + + // The embedded address is what gets classified, so the decode cannot become an SSRF bypass. + test("a wrapped private, loopback, or link-local IPv4 stays blocked", () => { + const cases: [string, string][] = [ + ["64:ff9b::7f00:1", "loopback"], + ["64:ff9b::a00:1", "private-network"], + ["64:ff9b::c0a8:1", "private-network"], + ["64:ff9b::ac10:1", "private-network"], + // 169.254.169.254 is the cloud metadata IP, so the wrapped form lands on the stronger + // metadata blocklist rather than the generic link-local rule. + ["64:ff9b::a9fe:a9fe", "blocked metadata endpoint"], + ["64:ff9b::127.0.0.1", "loopback"], + ]; + for (const [host, detail] of cases) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))).toContain(detail); + } + }); + + // RFC 8215 reserves 64:ff9b:1::/48 for local-use translation, which is not the well-known + // prefix and keeps its non-global treatment. + test("the RFC 8215 local-use prefix is not decoded", () => { + expect(providerDestinationConfigError("p", provider("https://[64:ff9b:1::d25:c62c]/v1"))) + .toContain("non-global"); + }); + + test("unrelated IPv6 classification is unchanged", () => { + expect(providerDestinationConfigError("p", provider("https://[2606:4700::6812:1250]/v1"))).toBeNull(); + expect(providerDestinationConfigError("p", provider("https://[::1]/v1"))).toContain("loopback"); + expect(providerDestinationConfigError("p", provider("https://[fd00::1]/v1"))).toContain("private-network"); + expect(providerDestinationConfigError("p", provider("https://[fe80::1]/v1"))).toContain("link-local"); + expect(providerDestinationConfigError("p", provider("https://[2001:db8::1]/v1"))).toContain("documentation"); + }); +}); From 2869d2ff1bf872b856a50762d227fecb2eabadf4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 04:38:55 +0900 Subject: [PATCH 007/132] docs(devlog): close the Kiro duplicate-answer unit (#2838) The unit reached a terminal outcome, so it moves to _fin: the duplicate answer is fixed and merged (#2835, 69031f6aa), and the non-termination half was measured to be a stale process rather than a code defect. 040 records both, including the audit round that rejected the original plan. Consuming the retained prose at the outer drain would have hidden the only commentary a failed turn produces, so suppression is confined to the inner site that knows a completion arrived. --- .../000_research.md | 0 .../010_wp1_terminal_boundary.md | 0 .../011_audit_round1.md | 0 .../012_audit_round2.md | 0 .../020_wp2_duplicate_answer.md | 0 .../021_audit_round3.md | 0 .../030_wp1_live_measurement.md | 0 .../040_close_out.md | 77 +++++++++++++++++++ 8 files changed, 77 insertions(+) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/000_research.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/010_wp1_terminal_boundary.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/011_audit_round1.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/012_audit_round2.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/020_wp2_duplicate_answer.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/021_audit_round3.md (100%) rename devlog/{_plan => _fin}/260828_kiro_turn_termination/030_wp1_live_measurement.md (100%) create mode 100644 devlog/_fin/260828_kiro_turn_termination/040_close_out.md diff --git a/devlog/_plan/260828_kiro_turn_termination/000_research.md b/devlog/_fin/260828_kiro_turn_termination/000_research.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/000_research.md rename to devlog/_fin/260828_kiro_turn_termination/000_research.md diff --git a/devlog/_plan/260828_kiro_turn_termination/010_wp1_terminal_boundary.md b/devlog/_fin/260828_kiro_turn_termination/010_wp1_terminal_boundary.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/010_wp1_terminal_boundary.md rename to devlog/_fin/260828_kiro_turn_termination/010_wp1_terminal_boundary.md diff --git a/devlog/_plan/260828_kiro_turn_termination/011_audit_round1.md b/devlog/_fin/260828_kiro_turn_termination/011_audit_round1.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/011_audit_round1.md rename to devlog/_fin/260828_kiro_turn_termination/011_audit_round1.md diff --git a/devlog/_plan/260828_kiro_turn_termination/012_audit_round2.md b/devlog/_fin/260828_kiro_turn_termination/012_audit_round2.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/012_audit_round2.md rename to devlog/_fin/260828_kiro_turn_termination/012_audit_round2.md diff --git a/devlog/_plan/260828_kiro_turn_termination/020_wp2_duplicate_answer.md b/devlog/_fin/260828_kiro_turn_termination/020_wp2_duplicate_answer.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/020_wp2_duplicate_answer.md rename to devlog/_fin/260828_kiro_turn_termination/020_wp2_duplicate_answer.md diff --git a/devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md b/devlog/_fin/260828_kiro_turn_termination/021_audit_round3.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/021_audit_round3.md rename to devlog/_fin/260828_kiro_turn_termination/021_audit_round3.md diff --git a/devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md b/devlog/_fin/260828_kiro_turn_termination/030_wp1_live_measurement.md similarity index 100% rename from devlog/_plan/260828_kiro_turn_termination/030_wp1_live_measurement.md rename to devlog/_fin/260828_kiro_turn_termination/030_wp1_live_measurement.md diff --git a/devlog/_fin/260828_kiro_turn_termination/040_close_out.md b/devlog/_fin/260828_kiro_turn_termination/040_close_out.md new file mode 100644 index 0000000000..8761149fe0 --- /dev/null +++ b/devlog/_fin/260828_kiro_turn_termination/040_close_out.md @@ -0,0 +1,77 @@ +# 040 — close-out: one visible answer, and what was never a code defect + +Terminal outcome: **DONE** for the duplicate answer. **NOOP (stale process)** for +the non-termination half. Merged to `dev` as `69031f6aa` via PR #2835. + +## What the user reported + +After #2819 merged, a Kiro turn still (a) printed the final answer twice and +(b) seemed to keep going after answering. + +## What was actually true + +Two different causes wearing one bug report. + +**(a) The duplicate answer was real and live on `dev`.** Kiro emits answer-shaped +prose and then calls the private completion tool in the SAME inference. The +adapter released the prose as `commentary` and the completion answer as +`final_answer`; `src/bridge.ts` closes the commentary message on the phase +change, so the client rendered two assistant messages with near-identical text. +The repository's own suite asserted this as intended behaviour in three places, +so it was verified-present rather than hypothesised. + +**(b) The non-termination half was mostly a stale process.** Measured, not +assumed: + +| host | version | process age | verdict | +|------|---------|-------------|---------| +| local (the reporting proxy) | 2.35.0 from the checkout | started 22:12:11 | predates `b0740840d`, `d9d26552f`, `68eaf45d8` | +| `suji` | 2.24.2 installed binary | 10.9 days uptime | predates the entire unit | +| `macmini-cf` | checkout at `d7a82a8fc` | no proxy running | current source, not serving | + +`68eaf45d8` is the commit that stops the terminal boundary from depending on the +client echoing `phase`. A proxy started before it never contained the finished +fix. Live `/v1/responses` probes against current source returned exactly one +`final_answer` with `end_turn: true` for plain, tool-available, and +tool-result-round-trip turns. No code change was warranted for this half; the +remedy is a restart, which is left to the operator. + +## The fix + +`consumeSupersededByCompletion` in `src/adapters/kiro.ts`: a valid completion +answer supersedes prose staged during the same inference, so that collection is +consumed rather than released — redundant `text_delta` dropped, every non-text +event kept, retention released either way. Applied to `deferred` in `required` +mode and `fallbackEvents` in `text_fallback`. + +## What the audit changed + +The plan in `020` said to consume at BOTH readers — the inner flush and the +outer drain at `:996-1001`. An independent reviewer returned **FAIL** and was +right: that outer drain is also the leftover flush for early terminal returns, +so teaching it to discard text would hide the only commentary a *failed* turn +ever produces. Trading a cosmetic duplicate for a silent failure is a worse bug. + +Corrected: the inner site is the only consumer, and it splices, so the outer +drain finds nothing on the completion path and keeps its full behaviour on every +failure path. Re-verified by the same reviewer: **pass**. Recorded in `021`. + +## Evidence + +- `bun test` on the merged tree `ab21fa526`: 194 pass / 0 fail across the three + Kiro suites plus `release-version-line`, receipt `dirty=false exitCode=0`. +- Full `bun run test`, `bun run typecheck`, `bun run privacy:scan` green on the + PR head `0dc87045`. +- Both new assertions driven RED before being accepted: the protocol-level one + failed with `Received length: 2` (the user's exact symptom, two assistant + messages), the adapter one with the extra commentary event present. + +## One thing worth recording about CI + +PR #2835 showed `test 2/4` and `macos` red. Neither was this change: +`tests/release-version-line.test.ts` failed because `dev`'s `package.json` said +`2.35.0` while `v2.36.0-preview.20260829` was already published. Proven by +running that test on a pristine `origin/dev` worktree with none of this branch's +changes present — it failed there too. It blocked every PR targeting `dev` and +was separately repaired by PR #2836, which has since landed. + From bf166fcea1340fb1ddd4eea5be19d4196852f09f Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 04:47:35 +0900 Subject: [PATCH 008/132] fix(responses): scope canonical system folding to messages (#2822) --- src/adapters/openai-responses.ts | 11 ++++++++-- .../responses-forward-prompt-envelope.test.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 70e6e7a1d7..d01a023bdb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1202,6 +1202,13 @@ function canonicalForwardSystemText(item: Record): string | nul return text; } +/** Only message items may carry privileged system instructions. */ +function isCanonicalForwardSystemMessage(item: unknown): item is Record { + return isPlainObject(item) + && (item.type === undefined || item.type === "message") + && item.role === "system"; +} + /** * The public Responses API accepts input system messages and `truncation`, but the canonical * ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the @@ -1225,7 +1232,7 @@ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { let sawSystemMessage = false; let canFoldAllSystemMessages = true; for (const item of input) { - if (!isPlainObject(item) || item.role !== "system") continue; + if (!isCanonicalForwardSystemMessage(item)) continue; sawSystemMessage = true; const text = canonicalForwardSystemText(item); if (text === null) { @@ -1239,7 +1246,7 @@ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { const next: Record = { ...body }; if (stripTruncation) delete next.truncation; if (sawSystemMessage && canFoldAllSystemMessages) { - next.input = input.filter(item => !isPlainObject(item) || item.role !== "system"); + next.input = input.filter(item => !isCanonicalForwardSystemMessage(item)); const folded = foldedText.join("\n\n"); if (folded !== "") { const existing = typeof body.instructions === "string" ? body.instructions : ""; diff --git a/tests/responses-forward-prompt-envelope.test.ts b/tests/responses-forward-prompt-envelope.test.ts index 7d52d45151..5df097f064 100644 --- a/tests/responses-forward-prompt-envelope.test.ts +++ b/tests/responses-forward-prompt-envelope.test.ts @@ -86,6 +86,28 @@ describe("canonical ChatGPT forward prompt envelope", () => { expect(body.input).toEqual(input); }); + test("folds only message-shaped system items", () => { + const externalAgentMessage = { + type: "agent_message", + role: "system", + content: [{ type: "input_text", text: "external agent content" }], + }; + const body = outboundBody(canonicalForward, { + model: "gpt-5.6-luna", + instructions: "Existing instructions", + input: [ + { type: "message", role: "system", content: "Typed system instruction" }, + { role: "system", content: "Easy input system instruction" }, + externalAgentMessage, + ], + }); + + expect(body.instructions).toBe( + "Existing instructions\n\nTyped system instruction\n\nEasy input system instruction", + ); + expect(body.input).toEqual([externalAgentMessage]); + }); + test.each([ { name: "key-auth public Responses provider", From 3058966837fde7166b6da8cd0593eaf3a789592c Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 04:47:49 +0900 Subject: [PATCH 009/132] fix(cli): scope model removal selectors to provider (#2821) --- src/cli/models.ts | 7 +++++-- tests/cli-models.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index d050563b60..c9920e3c1d 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -283,9 +283,12 @@ async function handleCustomRemove(args: string[]): Promise { // that row while the dash form matched both — the two relations disagreed on the same // config. Removal stays exact-or-refuse: an ambiguous selector still aborts below, which is // the right default for a destructive command. + const separator = target.indexOf("/"); + const selectedProvider = separator >= 0 ? target.slice(0, separator) : undefined; const matchingIndexes = existing.flatMap((model, index) => { - if (!target.includes("/")) return model.id === target ? [index] : []; - const resolved = resolveSlugSelection(model.provider, target, [model.modelId]); + if (selectedProvider === undefined) return model.id === target ? [index] : []; + if (model.provider !== selectedProvider) return []; + const resolved = resolveSlugSelection(selectedProvider, target, [model.modelId]); return resolved.matched.length > 0 ? [index] : []; }); if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 51b5c4a8ec..c9de6f6b40 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -478,5 +478,42 @@ describe("#2491 the removal selector uses the shared equivalence relation", () = rmSync(dir, { recursive: true, force: true }); } }); -}); + test("a provider-qualified selector does not match a native id under another provider", () => { + const { dir } = freshConfig({ + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "openai", modelId: "gpt-5.5" }, + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "openai/gpt-5.5" }, + ], + }); + try { + const result = runCli(["models", "remove", "openai/gpt-5.5", "--yes"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels).toEqual([ + expect.objectContaining({ provider: "test", modelId: "openai/gpt-5.5" }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a provider-qualified selector cannot remove a sole row from another provider", () => { + const { dir } = freshConfig({ + customModels: [ + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "openai/gpt-5.5" }, + ], + }); + try { + const result = runCli(["models", "remove", "openai/gpt-5.5", "--yes"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("not found"); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels).toEqual([ + expect.objectContaining({ provider: "test", modelId: "openai/gpt-5.5" }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 24f053b8b70d81aa827589aac4d0f23903823a4c Mon Sep 17 00:00:00 2001 From: DevonGithub Date: Sat, 29 Aug 2026 01:17:52 +0530 Subject: [PATCH 010/132] fix(catalog): raise Muse Spark context window to 1M on OpenCode Go (#2785) Co-authored-by: DevonGithub <22842728+DevonGithub@users.noreply.github.com> --- src/providers/registry.ts | 4 ++ tests/opencode-go-muse-context.test.ts | 54 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/opencode-go-muse-context.test.ts diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 91405ca8d7..a8cca87828 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1442,6 +1442,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // The DeepSeek vision preview id is metadata-only here: the Go roster is // discovered live, so it applies the moment the gateway serves the id. [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + // Muse Spark 1.2 Contributor serves a 1,048,576-token (1M) context window over + // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28). + // Without this declaration the catalog falls back to 128k, capping real usable context. + "muse-spark-1.2-contributor": 1_048_576, }, modelInputModalities: { "kimi-k3": ["text", "image"], diff --git a/tests/opencode-go-muse-context.test.ts b/tests/opencode-go-muse-context.test.ts new file mode 100644 index 0000000000..a387696d71 --- /dev/null +++ b/tests/opencode-go-muse-context.test.ts @@ -0,0 +1,54 @@ +/** + * OpenCode Go Muse Spark 1.2 Contributor context window regression. + * + * Muse Spark serves a 1,048,576-token (1M) context window over /responses on Zen Go, + * matching its 1.1 sibling. The registry declared no modelContextWindows entry, so the + * catalog fell back to the 128k unknown-window default and the Codex app capped real + * usable context well below what the model supports. These tests lock the declaration + * in and prove the catalog advertises the full 1M window for Muse. + */ +import { describe, expect, test } from "bun:test"; +import { applyProviderConfigHints } from "../src/codex/catalog"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry"; +import { providerConfigSeed } from "../src/providers/derive"; +import type { OcxProviderConfig } from "../src/types"; + +const MUSE_MODEL = "muse-spark-1.2-contributor"; +const MUSE_CONTEXT = 1_048_576; + +/** Seeded OpenCode Go provider config for the Muse Spark context assertions. */ +function opencodeGo(): OcxProviderConfig { + const entry = getProviderRegistryEntry("opencode-go"); + if (!entry) throw new Error("missing opencode-go registry fixture"); + return { ...providerConfigSeed(entry), apiKey: "test-key" }; +} + +describe("OpenCode Go Muse Spark context window", () => { + test("registry declares the 1M context window for Muse", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-go"); + expect(entry?.modelContextWindows?.[MUSE_MODEL]).toBe(MUSE_CONTEXT); + }); + + test("the registry seed carries the 1M context window for Muse", () => { + const prov = opencodeGo(); + expect(prov.modelContextWindows?.[MUSE_MODEL]).toBe(MUSE_CONTEXT); + }); + + test("applyProviderConfigHints exposes the 1M context window for Muse", () => { + const prov = opencodeGo(); + const hinted = applyProviderConfigHints("opencode-go", prov, { + id: MUSE_MODEL, + provider: "opencode-go", + }); + expect(hinted.contextWindow).toBe(MUSE_CONTEXT); + }); + + test("a discovered row with no window inherits the configured 1M window", () => { + const prov = opencodeGo(); + const hinted = applyProviderConfigHints("opencode-go", prov, { + id: MUSE_MODEL, + provider: "opencode-go", + }); + expect(hinted.contextWindow).toBe(MUSE_CONTEXT); + }); +}); From f7bb8932eeac05b1b842ff0359eb184d5be2e81c Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 04:59:33 +0900 Subject: [PATCH 011/132] fix(ci): restrict closed-PR cleanup to disposable namespaces (#2840) --- .github/scripts/closed-pr-branch-cleanup.cjs | 20 ++++++- .../scripts/closed-pr-branch-cleanup.test.cjs | 58 ++++++++++++++++++- tests/closed-pr-branch-cleanup.test.ts | 49 ++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.github/scripts/closed-pr-branch-cleanup.cjs b/.github/scripts/closed-pr-branch-cleanup.cjs index 57142f19fe..3f62bd50df 100644 --- a/.github/scripts/closed-pr-branch-cleanup.cjs +++ b/.github/scripts/closed-pr-branch-cleanup.cjs @@ -16,11 +16,17 @@ /** Branches that may never be deleted regardless of pull-request state. */ const PROTECTED_BRANCHES = Object.freeze(["main", "dev", "preview", "gh-pages"]); +/** Branch namespaces explicitly reserved for disposable pull-request work. */ +const DISPOSABLE_BRANCH_PREFIXES = Object.freeze(["codex/", "ingw/"]); + /** Default grace period before a closed PR's head branch becomes eligible. */ const DEFAULT_GRACE_DAYS = 14; function normalizeBranchName(value) { - return String(value || "").trim(); + // Git permits non-ASCII whitespace in ref names, while String#trim removes + // it. Preserve API-provided branch identity byte-for-byte so two distinct + // refs cannot collapse into one deletion candidate. + return typeof value === "string" ? value : ""; } /** @@ -59,6 +65,7 @@ const KEEP_REASONS = Object.freeze({ CROSS_REPOSITORY: "cross-repository-head", MISSING_CLOSED_AT: "missing-closed-at", WITHIN_GRACE: "within-grace-period", + OUTSIDE_DISPOSABLE_NAMESPACE: "outside-disposable-namespace", MOVED_SINCE_CLOSE: "branch-moved-since-close", UNKNOWN_HEAD_SHA: "unknown-head-sha", }); @@ -79,6 +86,11 @@ const KEEP_REASONS = Object.freeze({ * contributor's repository and this token has no business there. * - A grace period after `closed_at` leaves room to reopen a PR that was * closed by mistake. + * - Only branches under namespaces explicitly reserved for disposable pull- + * request work are eligible. Pull-request history alone must not authorize + * deletion of an unrelated persistent branch. + * - Branch names are compared and emitted byte-for-byte. Normalizing Unicode + * whitespace can merge distinct valid refs and delete the wrong branch. * - The branch must still POINT AT a commit one of those closed pull requests * had as its head. Matching by NAME alone deletes reused work: `codex/`-style * names get picked up again all the time, and a branch recreated for new work @@ -178,6 +190,11 @@ function planClosedPrBranchDeletions({ continue; } + if (!DISPOSABLE_BRANCH_PREFIXES.some((prefix) => branch.startsWith(prefix))) { + keeps.push({ branch, reason: KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE }); + continue; + } + // The tip check, last because it is the most expensive claim to satisfy and // the cheaper rules above have already excluded most branches. // @@ -219,6 +236,7 @@ function planClosedPrBranchDeletions({ module.exports = { DEFAULT_GRACE_DAYS, + DISPOSABLE_BRANCH_PREFIXES, KEEP_REASONS, PROTECTED_BRANCHES, isProtectedBranch, diff --git a/.github/scripts/closed-pr-branch-cleanup.test.cjs b/.github/scripts/closed-pr-branch-cleanup.test.cjs index 644cb0b7aa..1dac5c7ce6 100644 --- a/.github/scripts/closed-pr-branch-cleanup.test.cjs +++ b/.github/scripts/closed-pr-branch-cleanup.test.cjs @@ -4,6 +4,7 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const { DEFAULT_GRACE_DAYS, + DISPOSABLE_BRANCH_PREFIXES, KEEP_REASONS, isProtectedBranch, planClosedPrBranchDeletions, @@ -11,6 +12,9 @@ const { const NOW = Date.parse("2026-08-26T00:00:00Z"); const DAY = 24 * 60 * 60 * 1000; +const HEAD_OID = "a".repeat(40); +const OTHER_OID = "b".repeat(40); +const NBSP = "\u00a0"; const longAgo = new Date(NOW - 60 * DAY).toISOString(); function closedPr(overrides) { @@ -20,6 +24,7 @@ function closedPr(overrides) { merged: false, isCrossRepository: false, headRefName: "codex/example", + headRefOid: HEAD_OID, baseRefName: "dev", closedAt: longAgo, ...overrides, @@ -42,13 +47,17 @@ describe("isProtectedBranch", () => { } assert.equal(isProtectedBranch("codex/dev"), false); }); + + it("declares the disposable pull-request branch namespaces", () => { + assert.deepEqual(DISPOSABLE_BRANCH_PREFIXES, ["codex/", "ingw/"]); + }); }); describe("planClosedPrBranchDeletions", () => { it("deletes a branch whose only pull request closed unmerged past the grace period", () => { const result = planClosedPrBranchDeletions({ pullRequests: [closedPr({ number: 42, headRefName: "codex/stale" })], - branches: ["codex/stale", "dev"], + branches: [{ name: "codex/stale", oid: HEAD_OID }, "dev"], now: NOW, }); assert.deepEqual(deletedBranches(result), ["codex/stale"]); @@ -147,13 +156,58 @@ describe("planClosedPrBranchDeletions", () => { it("ignores branches that no pull request ever used", () => { const result = planClosedPrBranchDeletions({ pullRequests: [closedPr({ number: 80, headRefName: "codex/known" })], - branches: ["codex/known", "codex/never-a-pr"], + branches: [ + { name: "codex/known", oid: HEAD_OID }, + { name: "codex/never-a-pr", oid: HEAD_OID }, + ], now: NOW, }); assert.deepEqual(deletedBranches(result), ["codex/known"]); assert.equal(keepReason(result, "codex/never-a-pr"), null); }); + it("keeps a persistent branch even when a closed pull request still matches its tip", () => { + const branch = "release/maintenance"; + const result = planClosedPrBranchDeletions({ + pullRequests: [closedPr({ number: 85, headRefName: branch })], + branches: [{ name: branch, oid: HEAD_OID }], + now: NOW, + }); + assert.deepEqual(deletedBranches(result), []); + assert.equal( + keepReason(result, branch), + KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE, + ); + }); + + it("preserves Unicode whitespace so distinct valid refs never collapse", () => { + const disposable = `codex/live${NBSP}`; + const result = planClosedPrBranchDeletions({ + pullRequests: [closedPr({ number: 86, headRefName: disposable })], + branches: [ + { name: "codex/live", oid: OTHER_OID }, + { name: disposable, oid: HEAD_OID }, + ], + now: NOW, + }); + assert.deepEqual(result.deletions, [{ branch: disposable, pullRequests: [86] }]); + assert.equal(keepReason(result, "codex/live"), null); + }); + + it("does not trim leading Unicode whitespace into a disposable namespace", () => { + const branch = `${NBSP}codex/persistent`; + const result = planClosedPrBranchDeletions({ + pullRequests: [closedPr({ number: 87, headRefName: branch })], + branches: [{ name: branch, oid: HEAD_OID }], + now: NOW, + }); + assert.deepEqual(deletedBranches(result), []); + assert.equal( + keepReason(result, branch), + KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE, + ); + }); + it("only plans deletions for branches that still exist", () => { const result = planClosedPrBranchDeletions({ pullRequests: [closedPr({ number: 90, headRefName: "codex/already-gone" })], diff --git a/tests/closed-pr-branch-cleanup.test.ts b/tests/closed-pr-branch-cleanup.test.ts index b18fdd7016..4fb0f3261a 100644 --- a/tests/closed-pr-branch-cleanup.test.ts +++ b/tests/closed-pr-branch-cleanup.test.ts @@ -75,6 +75,55 @@ describe("closed-PR branch cleanup planning", () => { expect(result.deletions).toEqual([{ branch: "codex/some-work", pullRequests: [42] }]); }); + test("an abandoned ingw branch still at the closed PR tip is deleted", () => { + const result = plan( + [closedPr({ headRefName: "ingw/some-work" })], + [{ name: "ingw/some-work", oid: OLD_TIP }], + ); + expect(result.deletions).toEqual([{ branch: "ingw/some-work", pullRequests: [42] }]); + }); + + test("a persistent branch outside disposable namespaces is kept", () => { + const branch = "release/maintenance"; + const result = plan( + [closedPr({ headRefName: branch })], + [{ name: branch, oid: OLD_TIP }], + ); + expect(result.deletions).toEqual([]); + expect(keepReason(result, branch)).toBe(KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE); + }); + + test("near-miss prefixes do not enter disposable namespaces", () => { + for (const branch of ["codexx/some-work", "ingw2/some-work"]) { + const result = plan( + [closedPr({ headRefName: branch })], + [{ name: branch, oid: OLD_TIP }], + ); + expect(result.deletions).toEqual([]); + expect(keepReason(result, branch)).toBe(KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE); + } + }); + + test("Unicode whitespace preserves ref identity and cannot create a namespace match", () => { + const trailing = "codex/some-work\u00a0"; + const collision = plan( + [closedPr({ headRefName: trailing })], + [ + { name: "codex/some-work", oid: NEW_TIP }, + { name: trailing, oid: OLD_TIP }, + ], + ); + expect(collision.deletions).toEqual([{ branch: trailing, pullRequests: [42] }]); + + const leading = "\u00a0codex/some-work"; + const outside = plan( + [closedPr({ headRefName: leading })], + [{ name: leading, oid: OLD_TIP }], + ); + expect(outside.deletions).toEqual([]); + expect(keepReason(outside, leading)).toBe(KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE); + }); + test("BUG-R4: a branch reused for new work is kept, not deleted", () => { // Same NAME, different tip. Before the SHA guard this returned a deletion // for a branch carrying commits that had never been in any pull request. From a667071039752e84662b97673f5e578b8facc749 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:24:00 +0900 Subject: [PATCH 012/132] fix(codex): close drain routing follow-ups (cherry picked from commit 5e2f1afb4fa7961dd29ed1a199f997d02a3f884a) --- src/codex/auth-context.ts | 14 +- src/codex/model-entitlements.ts | 12 + src/codex/routing.ts | 334 ++++++++-- src/codex/subagent-model-fallback.ts | 86 ++- src/server/responses/core.ts | 178 +++++- tests/codex-auth-context.test.ts | 109 +++- tests/codex-routing.test.ts | 405 ++++++++++++ ...subagent-fallback-handle-responses.test.ts | 602 +++++++++++++++++- 8 files changed, 1605 insertions(+), 135 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index f3c357d4c3..acfb82ccfb 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -383,6 +383,8 @@ export async function resolveCodexAuthContext( const nativeMainTrafficBlocked = isNativeMainTrafficBlocked(); const selectionAdmission = options.beginCodexAccountSelection?.(); const nativeMainReadsForbidden = nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true; + const nativeMainSelectionOnly = !nativeMainTrafficBlocked + && selectionAdmission?.mainProfileDraining === true; let accountId: string; const quotaScope = codexQuotaScopeForModel(options.modelId); try { @@ -401,8 +403,7 @@ export async function resolveCodexAuthContext( const selectionOptions = { // Temporary switch drain keeps the candidate until the atomic claim rejects // it. Retained recovery makes main wholly ineligible so pool routing continues. - nativeMainSelectionOnly: !nativeMainTrafficBlocked - && selectionAdmission?.mainProfileDraining === true, + nativeMainSelectionOnly, isMainAccountTokenLive: options.isMainAccountTokenLive, modelEligibleAccountIds, }; @@ -436,12 +437,13 @@ export async function resolveCodexAuthContext( : "Selected Codex account is unavailable", ); } - // Recovery deliberately makes physical main ineligible. If no healthy - // pool route is configured and main is the intended route, report the - // temporary fence rather than misclassifying that credential as invalid. + // Recovery or a turn drain deliberately makes physical main unobservable. + // If no healthy pool route is available, report the temporary fence rather + // than turning a credential we were forbidden to inspect into a permanent + // model-entitlement denial. // A configured pool retry/exclusion that finds no alternate preserves its // ordinary pool-auth failure instead of being mislabeled as a main fence. - if (nativeMainTrafficBlocked && !options.excludeAccountId) { + if (nativeMainReadsForbidden && !options.excludeAccountId) { throw new CodexMainProfileDrainingError(); } throw new CodexPoolAuthenticationError( diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 5a649d335a..1bc6f43285 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -46,6 +46,18 @@ export interface CodexModelEntitlementResolveOptions { readonly excludeAccountIds?: ReadonlySet; } +/** + * Explicit request-boundary signal for an operational discovery failure that cannot be + * represented by the ordinary fail-closed `confirmed: false` snapshot. Generic throws are + * programming errors and must retain their original error path. + */ +export class CodexModelEntitlementDiscoveryUnavailableError extends Error { + constructor(cause?: unknown) { + super("Codex model entitlement discovery is temporarily unavailable", { cause }); + this.name = "CodexModelEntitlementDiscoveryUnavailableError"; + } +} + const accountModelsCache = new Map(); const accountModelsFlights = new Map>(); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 10160fe913..b9ad137390 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -964,7 +964,7 @@ function getEligiblePoolAccounts( return selectPriorityTier( ids, codexAccountPriorityLookup(config), - id => hasCodexQuotaHeadroom(config, id), + id => hasCodexQuotaHeadroom(config, id, selectionOptions), pinnedCodexAccountId(config), ); } @@ -992,10 +992,17 @@ function stickyLimitForConfig(config: OcxConfig): number { * primed. A genuinely exhausted account 429s into cooldown and leaves * eligibility on its own. */ -function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean { +function hasCodexQuotaHeadroom( + config: OcxConfig, + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { const threshold = config.autoSwitchThreshold ?? 80; if (threshold <= 0) return true; - const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId)); + const usage = computeCodexUsageScore( + getAccountQuota(accountId), + getPoolAccountPlanForSelection(config, accountId, selectionOptions), + ); if (isUnknownUsage(usage)) return true; return usage < threshold; } @@ -1014,7 +1021,7 @@ function pickFillFirstCodexAccount( if (eligible.length === 0) return null; const active = getEffectiveActiveCodexAccountId(config); - if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) { + if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions)) { return active; } @@ -1034,7 +1041,7 @@ function pickNextFillFirstCodexAccount( if (!afterId) { // Prefer an under-threshold account when starting with no active cursor. for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id)) return id; + if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id; } return ordered[0] ?? null; } @@ -1049,7 +1056,7 @@ function pickNextFillFirstCodexAccount( const startIdx = stableAll.indexOf(afterId); if (startIdx < 0) { for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id)) return id; + if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id; } return ordered[0] ?? null; } @@ -1060,7 +1067,7 @@ function pickNextFillFirstCodexAccount( const candidate = stableAll[(startIdx + step) % stableAll.length]!; if (!eligible.includes(candidate)) continue; if (!fallback) fallback = candidate; - if (hasCodexQuotaHeadroom(config, candidate)) return candidate; + if (hasCodexQuotaHeadroom(config, candidate, selectionOptions)) return candidate; } return fallback ?? ordered[0] ?? null; } @@ -1069,10 +1076,10 @@ function pickNextFillFirstCodexAccount( * Unbound new-session pick for round-robin / fill-first. Returns null to fall through * to the legacy quota path (or when the strategy is quota). * - * When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and - * notes RR success. When `commit` is false (preview), returns the same RR/fill-first - * account resolve would pick via a dry-run peek — without mutating ring weights, - * activeKey, sticky counters, config, or affinity. + * When `commit` is true (resolve path), advances RR state. `commitSharedActive` + * and `commitAffinity` independently control the two cross-request side effects: + * model-scoped entitlement selection can bind a new task without replacing an + * existing task binding or global active choice. Preview remains a dry-run peek. * * Automatic strategy picks never sync-write config; only manual selection persists active. * @@ -1087,6 +1094,8 @@ function pickUnboundStrategyAccount( commit: boolean, quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + commitSharedActive = commit, + commitAffinity = commit, ): string | null { const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); if (strategy === "quota") return null; @@ -1101,8 +1110,10 @@ function pickUnboundStrategyAccount( } picked = pickRoundRobinAccount(poolKey, eligible, limit); if (!picked) return null; - if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); - if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope); + if (commitSharedActive) { + if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); + } + if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); notePoolRotationSuccess(poolKey, picked, limit); return picked; } @@ -1110,10 +1121,10 @@ function pickUnboundStrategyAccount( if (strategy === "fill-first") { picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; - if (commit) { + if (commitSharedActive) { if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); - if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope); } + if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); return picked; } @@ -1126,6 +1137,36 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; } +/** Selection-only main routing must not lazily read the fenced native credential for its plan. */ +function getPoolAccountPlanForSelection( + config: OcxConfig, + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, +): string | undefined { + if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) { + return undefined; + } + return getPoolAccountPlan(config, accountId); +} + +/** Shared routing state must ignore a request-scoped entitlement roster. */ +function sharedStateSelectionOptions( + selectionOptions?: CodexAccountUsabilityOptions, +): Pick< + CodexAccountUsabilityOptions, + "nativeMainSelectionOnly" | "isMainAccountTokenLive" +> | undefined { + if (!selectionOptions) return undefined; + return { + ...(selectionOptions.nativeMainSelectionOnly !== undefined + ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly } + : {}), + ...(selectionOptions.isMainAccountTokenLive + ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive } + : {}), + }; +} + function pickLowerUsageAccount( config: OcxConfig, active: string, @@ -1137,7 +1178,10 @@ function pickLowerUsageAccount( let best = active; let bestUsage = activeUsage; for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) { - const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); + const usage = computeCodexUsageScore( + getAccountQuota(id), + getPoolAccountPlanForSelection(config, id, selectionOptions), + ); if (usage < bestUsage) { best = id; bestUsage = usage; @@ -1147,11 +1191,18 @@ function pickLowerUsageAccount( } /** Coolest account in an already-selected candidate list; first index wins ties. */ -function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null { +function pickLowestUsageAmong( + config: OcxConfig, + ids: readonly string[], + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { let best: string | null = null; let bestUsage = Number.POSITIVE_INFINITY; for (const id of ids) { - const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); + const usage = computeCodexUsageScore( + getAccountQuota(id), + getPoolAccountPlanForSelection(config, id, selectionOptions), + ); if (usage < bestUsage) { best = id; bestUsage = usage; @@ -1167,7 +1218,11 @@ export function pickLowestUsageCodexAccount( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions)); + return pickLowestUsageAmong( + config, + getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), + selectionOptions, + ); } /** @@ -1307,12 +1362,20 @@ function pickPriorityPreemption( // A live pin already lowered the tier ceiling; never preempt past an explicit // operator choice. Same liveness test the tier filter applies, so preview and // resolve agree even before the pin is garbage-collected. - if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null; + if ( + pinned !== undefined + && eligible.includes(pinned) + && hasCodexQuotaHeadroom(config, pinned, selectionOptions) + ) return null; const priorityOf = codexAccountPriorityLookup(config); if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; // Members without headroom are in the tier only because a sibling has some; // picking one would hand the request straight back to a drained account. - return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id))); + return pickLowestUsageAmong( + config, + eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions)), + selectionOptions, + ); } /** @@ -1322,13 +1385,27 @@ function pickPriorityPreemption( * on its own. Clearing the pin also removes the condition, so this writes at * most once per pin. */ -function releaseDrainedCodexAccountPin(config: OcxConfig): void { +function releaseDrainedCodexAccountPin( + config: OcxConfig, + selectionOptions?: Pick< + CodexAccountUsabilityOptions, + "nativeMainSelectionOnly" | "isMainAccountTokenLive" + >, +): void { const pinned = pinnedCodexAccountId(config); if (pinned === undefined) return; - const drained = !isCodexAccountUsable(config, pinned) - || isAccountNeedsReauth(pinned) - || isCodexAccountPaused(config, pinned) - || !hasCodexQuotaHeadroom(config, pinned); + const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned); + if (knownUnavailable) { + clearCodexAccountPin(config); + saveConfigPreservingClaudeCode(config); + return; + } + // Temporary drain deliberately forbids every native-main read. A pin on main + // cannot be classified by credential liveness or quota until the fenced profile + // is readable. Cached reauth and configured pause state were handled above. + if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return; + const drained = !isCodexAccountUsable(config, pinned, selectionOptions) + || !hasCodexQuotaHeadroom(config, pinned, selectionOptions); if (!drained) return; clearCodexAccountPin(config); saveConfigPreservingClaudeCode(config); @@ -1340,18 +1417,24 @@ function applyQuotaAutoSwitch( now: number, quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + commitSharedSelection = true, ): string { const threshold = config.autoSwitchThreshold ?? 80; if (threshold <= 0) return active; const quota = getAccountQuota(active); - const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active)); + const activeUsage = computeCodexUsageScore( + quota, + getPoolAccountPlanForSelection(config, active, selectionOptions), + ); // Unknown usage is not evidence that a user's explicit selection crossed the // threshold. Wait for quota priming instead of rotating among guesses. if (isUnknownUsage(activeUsage)) return active; if (activeUsage < threshold) return active; const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions); if (best !== active) { - if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best); + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + setActiveCodexAccount(config, best); + } return best; } @@ -1366,12 +1449,25 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool return !!health && health.consecutiveFailures >= threshold; } +function isHealthySharedCodexSelection( + config: OcxConfig, + accountId: string, + now: number, + quotaScope: CodexQuotaScope | undefined, + selectionOptions: CodexAccountUsabilityOptions | undefined, +): boolean { + return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) + && hasCodexQuotaHeadroom(config, accountId, selectionOptions) + && !shouldFailover(config, accountId, now); +} + function applyFailureFailover( config: OcxConfig, active: string, now: number, quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + commitSharedSelection = true, ): string { if (!shouldFailover(config, active, now)) return active; const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions); @@ -1382,7 +1478,9 @@ function applyFailureFailover( // the moment of the failure; the streak outlives the soft avoid, so a later // scoped resolve reaches here with the streak still tripped and would otherwise // move the shared cursor after all. - if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best); + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + promoteActiveCodexAccount(config, best); + } return best; } return active; @@ -1429,7 +1527,7 @@ export function previewCodexAccountForRequest( if (threshold > 0) { const usage = computeCodexUsageScore( getAccountQuota(entry.accountId), - getPoolAccountPlan(config, entry.accountId), + getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), ); if (!isUnknownUsage(usage) && usage >= threshold) { const best = pickLowerUsageAccount( @@ -1476,7 +1574,10 @@ export function previewCodexAccountForRequest( const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { - const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active)); + const usage = computeCodexUsageScore( + getAccountQuota(active), + getPoolAccountPlanForSelection(config, active, selectionOptions), + ); if (!isUnknownUsage(usage) && usage >= threshold) { active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions); } @@ -1502,11 +1603,31 @@ export function resolveCodexAccountForThreadDetailed( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): CodexThreadResolution { + // An entitlement roster constrains only this model request. It must not rewrite + // the operator's shared active/pin choice or the task's ordinary-model affinity. + const modelScopedSelection = selectionOptions?.modelEligibleAccountIds !== undefined; + let preserveExistingModelScopedAffinity = false; + const sharedSelectionOptions: CodexAccountUsabilityOptions | undefined = modelScopedSelection + ? sharedStateSelectionOptions(selectionOptions) ?? {} + : selectionOptions; // Retiring a spent manual pin is independent of affinity: an existing thread // keeps its account below, but the operator's tier ceiling must not silently // revive after quota resets. Independent model scopes must never persist a // change to shared routing state. - if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config); + if (!isIndependentCodexQuotaScope(quotaScope)) { + releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions)); + } + const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config); + const preserveSharedSelectionForModelDetour = modelScopedSelection && ( + sharedActiveBeforeSelection === undefined + || isHealthySharedCodexSelection( + config, + sharedActiveBeforeSelection, + now, + quotaScope, + sharedSelectionOptions, + ) + ); const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; if (threadId && entry) { @@ -1514,12 +1635,20 @@ export function resolveCodexAccountForThreadDetailed( deleteThreadAffinity(threadId, quotaScope); return { status: "expired", accountId: entry.accountId }; } + const generationLive = isThreadAffinityGenerationLive(entry); + const selectableForSharedState = generationLive + && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, sharedSelectionOptions); + const selectableForRequest = selectableForSharedState + && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions); + const failoverReady = shouldFailover(config, entry.accountId, now); + const healthyForSharedAffinity = selectableForSharedState + && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions) + && !failoverReady; if ( - isThreadAffinityGenerationLive(entry) - && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions) + selectableForRequest // Affined threads must leave a failing account once the streak trips failover // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks). - && !shouldFailover(config, entry.accountId, now) + && !failoverReady ) { entry.lastUsedAt = now; // Periodic quota re-eval: a long-lived bound thread must still switch when @@ -1533,11 +1662,11 @@ export function resolveCodexAccountForThreadDetailed( const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); if (strategy === "quota") { const threshold = config.autoSwitchThreshold ?? 80; - const usage = threshold > 0 - ? computeCodexUsageScore( - getAccountQuota(entry.accountId), - getPoolAccountPlan(config, entry.accountId), - ) + const usage = threshold > 0 + ? computeCodexUsageScore( + getAccountQuota(entry.accountId), + getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), + ) : 0; const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) { @@ -1545,7 +1674,9 @@ export function resolveCodexAccountForThreadDetailed( if (overThreshold) { const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions); if (best !== entry.accountId) { - if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best); + if (!isIndependentCodexQuotaScope(quotaScope)) { + setActiveCodexAccount(config, best); + } bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks return { status: "selected", accountId: best }; } @@ -1554,24 +1685,104 @@ export function resolveCodexAccountForThreadDetailed( } return { status: "selected", accountId: entry.accountId }; } - deleteThreadAffinity(threadId, quotaScope); + // A model-only exclusion does not invalidate the shared task binding. Health, + // generation, pause, cooldown, and failure evidence still retire it normally. + if (!modelScopedSelection || !healthyForSharedAffinity) { + deleteThreadAffinity(threadId, quotaScope); + } else { + preserveExistingModelScopedAffinity = true; + } } - const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions); - if (strategyPick) return { status: "selected", accountId: strategyPick }; + // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return + // before the quota/failover helpers below, so prefer only shared-healthy roster members here; + // otherwise RR/fill-first can immediately re-pick a known failing account even when another + // entitled account is healthy. If no healthy member exists, the normal fallback path below + // still decides whether the sole eligible candidate must be used. + const strategySelectionOptions = modelScopedSelection + ? { + ...selectionOptions, + modelEligibleAccountIds: new Set( + [...selectionOptions!.modelEligibleAccountIds!].filter(accountId => + isHealthySharedCodexSelection( + config, + accountId, + now, + quotaScope, + sharedSelectionOptions, + ) + ), + ), + } + : selectionOptions; + const strategyPick = pickUnboundStrategyAccount( + config, + threadId, + now, + true, + quotaScope, + strategySelectionOptions, + !modelScopedSelection, + !preserveExistingModelScopedAffinity, + ); + if (strategyPick) { + if ( + modelScopedSelection + && !preserveSharedSelectionForModelDetour + && !isIndependentCodexQuotaScope(quotaScope) + ) { + promoteActiveCodexAccount(config, strategyPick); + } + return { status: "selected", accountId: strategyPick }; + } let active = getEffectiveActiveCodexAccountId(config); if (!active) { const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions); - if (!selected) return { status: "none" }; - if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected); + if (!selected) { + if ( + selectionOptions?.nativeMainSelectionOnly === true + && selectionOptions.modelEligibleAccountIds !== undefined + ) { + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; + } + return { status: "none" }; + } + if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) { + setActiveCodexAccount(config, selected); + } active = selected; } + const activeSelectableForSharedState = isCodexAccountSelectable( + config, + active, + now, + quotaScope, + sharedSelectionOptions, + ); + const activeHealthyForSharedSelection = activeSelectableForSharedState + && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions) + && !shouldFailover(config, active, now); if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); if (fallback) { - if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback); + const modelOnlyMove = modelScopedSelection + && preserveSharedSelectionForModelDetour + && activeHealthyForSharedSelection; + if (!isIndependentCodexQuotaScope(quotaScope) && !modelOnlyMove) { + setActiveCodexAccount(config, fallback); + } active = fallback; + } else if ( + selectionOptions?.nativeMainSelectionOnly === true + && selectionOptions.modelEligibleAccountIds !== undefined + ) { + // Entitlement discovery intentionally excludes main while a temporary drain + // fences its credential. Once every eligible non-main candidate is unavailable, + // return main only as a non-mutating sentinel so the caller's atomic claim can + // classify maintenance. Do not fall through to the configured-but-ineligible + // active account or persist/bind this synthetic selection. + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) @@ -1589,11 +1800,30 @@ export function resolveCodexAccountForThreadDetailed( // stays the operator's selection and getEffectiveActiveCodexAccountId is what // surfaces this to the API and dashboard. An independent quota group must not // move the shared cursor at all — its ordering decision is its own. - if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted); + if ( + !preserveSharedSelectionForModelDetour + && !isIndependentCodexQuotaScope(quotaScope) + ) { + rememberActiveCodexAccount(config, preempted); + } active = preempted; } - active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions); - active = applyFailureFailover(config, active, now, quotaScope, selectionOptions); + active = applyQuotaAutoSwitch( + config, + active, + now, + quotaScope, + selectionOptions, + !preserveSharedSelectionForModelDetour, + ); + active = applyFailureFailover( + config, + active, + now, + quotaScope, + selectionOptions, + !preserveSharedSelectionForModelDetour, + ); if (!isCodexAccountUsable(config, active, selectionOptions)) { return hasConfiguredPoolAccount(config, active, selectionOptions) ? { status: "selected", accountId: active } @@ -1605,7 +1835,9 @@ export function resolveCodexAccountForThreadDetailed( ? { status: "selected", accountId: active } : { status: "none" }; } - if (threadId) bindThreadAffinity(threadId, active, now, quotaScope); + if (threadId && !preserveExistingModelScopedAffinity) { + bindThreadAffinity(threadId, active, now, quotaScope); + } return { status: "selected", accountId: active }; } diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 1f56be2634..5b838f9493 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -39,6 +39,7 @@ import { routeModel, type RouteResult } from "../router"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { getUpstreamHostHealth, normalizeUpstreamHostCircuitThreshold, @@ -276,31 +277,68 @@ export function isSubagentModelUnavailable( poolAccountPreview, candidateAccountUsabilityOptions?.modelEligibleAccountIds, ); - if (isModelHealthBlocked(model, config, resolvedAccountId, now)) return true; - if (!isPoolCodexRoute(route)) return false; - - // Pool candidates need a usable account. Derive requirement from the resolved - // route (canonical openai defaults to pool even when codexAccountMode is omitted). - if (!resolvedAccountId) return true; - if (isCodexAccountPaused(config, resolvedAccountId)) return true; - if (!isCodexAccountUsable(config, resolvedAccountId, candidateAccountUsabilityOptions)) return true; - if (route.codexAccountId !== undefined) { - // An account-qualified route is pinned and cannot consume Pool's recovery-probe - // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback - // advances instead of selecting a candidate that exact auth will reject. - const quotaScope = codexQuotaScopeForModel(route.modelId); - if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true; - } else { - const quotaScope = codexQuotaScopeForModel(route.modelId); - const cooldown = getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now); - if (cooldown !== null) { - const probeAvailable = cooldown.quotaScope - ? canAcquireCodexQuotaScopeProbeLease(resolvedAccountId, cooldown.quotaScope, now) - : canAcquireCodexQuotaProbeLease(resolvedAccountId, now); - if (!probeAvailable) return true; + const accountUnavailable = ( + candidateAccountId: string | null, + usabilityOptions: CodexAccountUsabilityOptions | undefined, + includeQuotaExhaustion: boolean, + ): boolean => { + if (isModelHealthBlocked(model, config, candidateAccountId, now)) return true; + if (!isPoolCodexRoute(route)) return false; + + // Pool candidates need a usable account. Derive requirement from the resolved + // route (canonical openai defaults to pool even when codexAccountMode is omitted). + if (!candidateAccountId) return true; + if (isCodexAccountPaused(config, candidateAccountId)) return true; + if (!isCodexAccountUsable(config, candidateAccountId, usabilityOptions)) return true; + if (route.codexAccountId !== undefined) { + // An account-qualified route is pinned and cannot consume Pool's recovery-probe + // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback + // advances instead of selecting a candidate that exact auth will reject. + const quotaScope = codexQuotaScopeForModel(route.modelId); + if (getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now) !== null) return true; + } else { + const quotaScope = codexQuotaScopeForModel(route.modelId); + const cooldown = getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now); + if (cooldown !== null) { + const probeAvailable = cooldown.quotaScope + ? canAcquireCodexQuotaScopeProbeLease(candidateAccountId, cooldown.quotaScope, now) + : canAcquireCodexQuotaProbeLease(candidateAccountId, now); + if (!probeAvailable) return true; + } } - } - return isNativeModelQuotaExhausted(model, config, resolvedAccountId, now); + if ( + !includeQuotaExhaustion + || ( + candidateAccountId === MAIN_CODEX_ACCOUNT_ID + && usabilityOptions?.nativeMainSelectionOnly === true + ) + ) return false; + return isNativeModelQuotaExhausted(model, config, candidateAccountId, now); + }; + + // Prefer a genuinely usable entitled pool account. Preview can deliberately return + // the configured active account even when no selectable candidate exists, so a + // null/main-only check is not enough to detect the temporary-drain case. + if (!accountUnavailable(resolvedAccountId, candidateAccountUsabilityOptions, true)) return false; + + // During a temporary native-main drain, entitlement discovery excludes main to + // preserve the credential fence. If no non-main candidate can serve an unqualified + // gated model, retain main only as a read-free sentinel: final auth owns the atomic + // claim and returns maintenance instead of letting a routed fallback bypass it. + const preserveDrainingMainCandidate = route.codexAccountId === undefined + && candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true + && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId); + if (!preserveDrainingMainCandidate) return true; + const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = { + ...candidateAccountUsabilityOptions, + modelEligibleAccountIds: new Set([ + ...(modelEligibleAccountIds ?? []), + MAIN_CODEX_ACCOUNT_ID, + ]), + }; + // Quota scoring main would lazily read the native credential/plan. Cached health, + // pause, reauth, and cooldown state are safe; defer physical scoring to final auth. + return accountUnavailable(MAIN_CODEX_ACCOUNT_ID, drainingMainUsabilityOptions, false); } export function selectAvailableSubagentModel( diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c018751cfd..ff6a2dea50 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -150,6 +150,7 @@ import { type CodexAuthContext, } from "../../codex/auth-context"; import { + CodexModelEntitlementDiscoveryUnavailableError, entitledCodexAccountIdsForModel, invalidateCodexModelEntitlementsForAccount, resolveCodexModelEntitlements, @@ -893,6 +894,7 @@ interface CodexPoolAccountRetryArgs { codexWsRuntimeIdentity?: BunRuntimeGateInput; translatorBudget: TranslatorBudget; turnAdmissionLease?: AdmissionLease; + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; }; firstAuthCtx: Extract; firstResponse: Response; @@ -917,6 +919,7 @@ type CodexPoolAccountRetryResult = selectedForwardHeaders: Headers; } | { kind: "no-alternate" } + | { kind: "eligibility-unavailable" } | { kind: "transport"; error: unknown; @@ -1011,10 +1014,21 @@ async function retryCodexPoolOnAlternateAccount( outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; const inboundWire = options.inboundWire ?? "responses"; + const entitlementResolver = options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements; let retryAuthCtx: CodexAuthContext | undefined; if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) { invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId); - const refreshed = await resolveCodexModelEntitlements(config); + let refreshed; + try { + refreshed = await resolveCodexModelEntitlementsForRequest(entitlementResolver, config); + } catch (error) { + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + if (error instanceof CodexModelEligibilityUnavailableError) { + return { kind: "eligibility-unavailable" }; + } + throw error; + } if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) { // The authenticated roster still grants this exact model. Retry on the same account: // upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400 @@ -1034,15 +1048,28 @@ async function retryCodexPoolOnAlternateAccount( excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + resolveCodexModelEntitlements: (entitlementConfig, resolveOptions) => + resolveCodexModelEntitlementsForRequest( + entitlementResolver, + entitlementConfig, + resolveOptions, + ), }, ); } catch (error) { - if ( + const unexpectedRetryError = !(error instanceof CodexPoolAuthenticationError) && !(error instanceof CodexAuthContextError) && !(error instanceof CodexAccountCooldownError) - && !(error instanceof CodexMainProfileDrainingError) - ) throw error; + && !(error instanceof CodexMainProfileDrainingError); + if (unexpectedRetryError) { + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + if (error instanceof CodexModelEligibilityUnavailableError) { + return { kind: "eligibility-unavailable" }; + } + throw error; + } } if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") { return { kind: "no-alternate" }; @@ -1131,24 +1158,30 @@ async function retryCodexPoolOnAlternateAccount( try { while (true) { noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); - upstreamResponse = await fetchWithHeaderTimeout( - request.url, - { - method: request.method, - headers: request.headers, - body: request.body, - }, - upstream.signal, - connectMs, - stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - providerName: route.providerName, - modelId: route.modelId, - }), - // Credential-bearing forward send: never follow a redirect into a - // dead-host rejection after the credential was seen (#914). - route.provider.authMode === "forward", - ); + try { + upstreamResponse = await fetchWithHeaderTimeout( + request.url, + { + method: request.method, + headers: request.headers, + body: request.body, + }, + upstream.signal, + connectMs, + stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), + // Credential-bearing forward send: never follow a redirect into a + // dead-host rejection after the credential was seen (#914). + route.provider.authMode === "forward", + ); + } catch (error) { + // Only the forward send is a transport boundary. Entitlement resolver throws below are + // deliberately outside this catch so programming errors retain their original path. + return { kind: "transport", error, authCtx: retryAuthCtx }; + } retrySendCount += 1; args.onResponse?.(upstreamResponse, retryAuthCtx, request); if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break; @@ -1158,13 +1191,22 @@ async function retryCodexPoolOnAlternateAccount( options.abortSignal, )) break; invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId); - const refreshed = await resolveCodexModelEntitlements(config); + let refreshed: Awaited>; + try { + refreshed = await resolveCodexModelEntitlementsForRequest(entitlementResolver, config); + } catch (error) { + await upstreamResponse.body?.cancel().catch(() => undefined); + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + releaseCodexAuthContextProbeLease(retryAuthCtx); + if (error instanceof CodexModelEligibilityUnavailableError) { + return { kind: "eligibility-unavailable" }; + } + throw error; + } if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break; await upstreamResponse.body?.cancel().catch(() => undefined); } - } catch (error) { - // Attribute the transport failure to the alternate account (already selected). - return { kind: "transport", error, authCtx: retryAuthCtx }; } finally { request.releaseBodyObservation?.(); } @@ -1602,7 +1644,12 @@ async function resolveResponsesCodexAuth( modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), - resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, + resolveCodexModelEntitlements: (entitlementConfig, resolveOptions) => + resolveCodexModelEntitlementsForRequest( + options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, + entitlementConfig, + resolveOptions, + ), }); options.onCodexAuthContextResolved?.(authCtx); } else { @@ -1632,6 +1679,9 @@ async function resolveResponsesCodexAuth( if (err instanceof ForwardAdmissionCredentialError) { return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; } + if (err instanceof CodexModelEligibilityUnavailableError) { + return { ok: false, response: codexModelEligibilityUnavailableResponse() }; + } const response = mapCodexAuthContextErrorToResponse(err, { accountSelector: route.codexAccountNamespace, now: Date.now(), @@ -1641,6 +1691,52 @@ async function resolveResponsesCodexAuth( } } +const CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE = + "Codex model eligibility is temporarily unavailable; retry this request"; + +class CodexModelEligibilityUnavailableError extends Error { + constructor() { + super(CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE); + this.name = "CodexModelEligibilityUnavailableError"; + } +} + +/** Return a retryable, redacted failure without letting discovery errors escape the request boundary. */ +function codexModelEligibilityUnavailableResponse(): Response { + return formatErrorResponse(503, "server_error", CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE); +} + +/** Wrap account-roster discovery so both preview and final auth share one fail-closed error contract. */ +async function resolveCodexModelEntitlementsForRequest( + resolver: typeof resolveCodexModelEntitlements, + config: Parameters[0], + options?: Parameters[1], +): ReturnType { + try { + return await resolver(config, options); + } catch (cause) { + if (!(cause instanceof CodexModelEntitlementDiscoveryUnavailableError)) throw cause; + const diagnosticCause = cause.cause ?? cause; + let detail = "unknown error"; + try { + const rawDetail = diagnosticCause instanceof Error + ? `${diagnosticCause.name}: ${diagnosticCause.message}` + : String(diagnosticCause); + detail = sanitizeLogMetadataString(rawDetail, 300) ?? detail; + } catch { + // A hostile thrown value must not replace the fixed retryable response. + } + try { + console.warn( + `[codex-entitlements] model eligibility discovery failed; returning a retryable 503: ${detail}`, + ); + } catch { + // Logging is diagnostic only; the request boundary remains fail-closed below. + } + throw new CodexModelEligibilityUnavailableError(); + } +} + async function resolveSubagentFallbackModelEligibility(args: { config: OcxConfig; fallbackChain: readonly string[] | null; @@ -1651,7 +1747,11 @@ async function resolveSubagentFallbackModelEligibility(args: { const excludeAccountIds = args.nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; - const snapshot = await args.resolver(args.config, { excludeAccountIds }); + const snapshot = await resolveCodexModelEntitlementsForRequest( + args.resolver, + args.config, + { excludeAccountIds }, + ); return (modelId) => { const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId); return entitledAccountIds @@ -2517,12 +2617,19 @@ async function handleResponsesInner( // "legacy" affinity bucket and never find a binding made under "shared" or a native // model scope, making the preview diverge from the account that actually authenticates. const fallbackChain = initialSubagentFallbackChain; - subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ - config, - fallbackChain, - nativeMainReadsForbidden, - resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, - }); + try { + subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ + config, + fallbackChain, + nativeMainReadsForbidden, + resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, + }); + } catch (error) { + if (error instanceof CodexModelEligibilityUnavailableError) { + return codexModelEligibilityUnavailableResponse(); + } + throw error; + } const fallbackNow = Date.now(); subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( poolAffinityKey, @@ -3738,6 +3845,9 @@ async function handleResponsesInner( captureAffinityResponse(response, retryAuthCtx, retryRequest, true); }, }); + if (retry.kind === "eligibility-unavailable") { + return codexModelEligibilityUnavailableResponse(); + } if (retry.kind === "transport") { authCtx = retry.authCtx; return transportFailureResponse(retry.error); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index da4d6cb5d0..d2b4c7d999 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -36,7 +36,11 @@ import { saveCodexAccountCredential, } from "../src/codex/account-store"; import { ConfigMutationLockError, getConfigPath } from "../src/config"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { + getMainAccountPlan, + MAIN_CODEX_ACCOUNT_ID, + setMainAccountPlan, +} from "../src/codex/main-account"; import { clearAccountNeedsReauth, clearAccountQuota, @@ -86,6 +90,7 @@ beforeEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + setMainAccountPlan(null); __resetGuardianState(); clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); @@ -97,6 +102,7 @@ afterEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + setMainAccountPlan(null); __resetGuardianState(); clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); @@ -122,6 +128,12 @@ function config(): OcxConfig { }; } +function chatgptPlanJwt(plan: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify({ chatgpt_plan_type: plan })).toString("base64url"); + return `${header}.${body}.sig`; +} + function guardianConfig(): OcxConfig { const cfg = config(); cfg.defaultProvider = "openai"; @@ -325,12 +337,92 @@ describe("Codex auth context", () => { primeCodexPoolQuotas: async () => { throw new Error("must not prime"); }, })).rejects.toBeInstanceOf(CodexMainProfileDrainingError); expect(nativeReads).toBe(0); + // Selection-only quota scoring must not lazily read/cache the fenced main + // plan before the atomic claim rejects the request. + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + })); + expect(getMainAccountPlan()).toBe("pro"); + } finally { + turn?.release(); + drain?.release(); + } + }); + + test("gated main selection preserves the temporary drain classification without entitlement reads", async () => { + const cfg = config(); + cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; + cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; + let nativeReads = 0; + let entitlementCalls = 0; + const drain = acquireNativeMainProfileDrain("auth-context-gated-main-test"); + const turn = tryAdmitTurn(); + try { + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-daybreak-blue-latest", + beginCodexAccountSelection: codexAccountSelectionForTurn(turn!), + isMainAccountTokenLive: () => { nativeReads += 1; return true; }, + getMainAccountToken: () => { + nativeReads += 1; + return { accessToken: "main", chatgptAccountId: "main-account" }; + }, + resolveCodexModelEntitlements: async (_config, options) => { + entitlementCalls += 1; + expect(options?.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + return { + modelsByAccount: new Map(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), + }; + }, + })).rejects.toBeInstanceOf(CodexMainProfileDrainingError); + expect(entitlementCalls).toBe(1); + expect(nativeReads).toBe(0); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + // Pin retirement must not lazily score/read main while the drain fence is up. + // A prior plan read against the intentionally missing auth.json would cache + // `undefined` and make this post-fence JWT fallback fail. + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + })); + expect(getMainAccountPlan()).toBe("pro"); } finally { turn?.release(); drain?.release(); } }); + test("gated no-active drain reaches the atomic main claim before maintenance classification", async () => { + const cfg = config(); + cfg.activeCodexAccountId = undefined; + let nativeReads = 0; + let claimCalls = 0; + let selectionReleases = 0; + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-daybreak-blue-latest", + beginCodexAccountSelection: () => ({ + mainProfileDraining: true, + claimMainProfile: () => { claimCalls += 1; return false; }, + release: () => { selectionReleases += 1; }, + }), + isMainAccountTokenLive: () => { nativeReads += 1; return true; }, + getMainAccountToken: () => { + nativeReads += 1; + return { accessToken: "main", chatgptAccountId: "main-account" }; + }, + resolveCodexModelEntitlements: async () => ({ + modelsByAccount: new Map(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), + }), + })).rejects.toBeInstanceOf(CodexMainProfileDrainingError); + + expect(claimCalls).toBe(1); + expect(selectionReleases).toBe(1); + expect(nativeReads).toBe(0); + }); + test("direct mode returns caller-owned main context without touching pool selection", async () => { const cfg = { ...config(), activeCodexAccountId: "missing-pool-account" }; await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer caller" }), cfg, "direct")) @@ -390,6 +482,7 @@ describe("Codex auth context", () => { test("account-gated native routing skips an active account without the model grant", async () => { const cfg = config(); + cfg.activeCodexAccountPinned = "pool-a"; writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { access_token: "main-token", account_id: "main-account" }, })); @@ -418,6 +511,20 @@ describe("Codex auth context", () => { kind: "main-pool", accountId: MAIN_CODEX_ACCOUNT_ID, }); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBe("pool-a"); + + // The preceding model-only detour must not replace the operator's shared + // selection; a following ordinary request still uses the pinned pool account. + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-5.5", + isMainAccountTokenLive: () => true, + getMainAccountToken: () => ({ accessToken: "main-token", chatgptAccountId: "main-account" }), + primeCodexPoolQuotas: async () => {}, + })).resolves.toMatchObject({ + kind: "pool", + accountId: "pool-a", + }); }); test("exact account-gated routing fails closed for an unentitled account", async () => { diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 4bb678a95f..f5bfcc31d9 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -1801,6 +1801,411 @@ describe("codex account selection order", () => { expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); }); + test("model eligibility stays request-scoped and preserves shared selection plus affinity", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("model-gated-task", config, now, "shared")).toBe("b"); + expect(resolveCodexAccountForThreadDetailed( + "model-gated-task", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("model-gated-task", config, now + 2, "shared")).toBe("b"); + }); + + test("a gated first request binds its actual account without replacing global active", () => { + const config = makeConfig({ activeCodexAccountId: "b" }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThreadDetailed( + "gated-first-task", + config, + now, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("gated-first-task", config, now + 1, "shared")).toBe("a"); + }); + + test("model-scoped round-robin advances without replacing shared selection", () => { + const config = makeConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + }); + const selectionOptions = { modelEligibleAccountIds: new Set(["a", "b"]) }; + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + Date.now(), + "shared", + selectionOptions, + )).toEqual({ status: "selected", accountId: "a" }); + expect(resolveCodexAccountForThreadDetailed( + null, + config, + Date.now() + 1, + "shared", + selectionOptions, + )).toEqual({ status: "selected", accountId: "b" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + }); + + test.each(["fill-first", "round-robin"] as const)( + "%s preserves healthy shared active, pin, and affinity during a model-only detour", + (strategy) => { + const now = 1_800_000_000_000; + const threadId = `healthy-model-detour-${strategy}`; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, now + 2, "shared")).toBe("b"); + }, + ); + + test.each(["fill-first", "round-robin"] as const)( + "%s skips a failover-ready detour candidate while preserving healthy shared state", + (strategy) => { + const now = 1_800_000_000_000; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "c", + activeCodexAccountPinned: "c", + autoSwitchThreshold: 0, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + resetCodexRoutingForManualSelection("c"); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "a", 503, { + fixedAccount: true, + now: now + attempt + 1, + }); + } + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4, + "shared", + { modelEligibleAccountIds: new Set(["a", "b"]) }, + )).toEqual({ status: "selected", accountId: "b" }); + expect(config.activeCodexAccountId).toBe("c"); + expect(config.activeCodexAccountPinned).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); + }, + ); + + test.each(["fill-first", "round-robin"] as const)( + "%s retires shared state when model ineligibility overlaps quota exhaustion", + (strategy) => { + const now = 1_800_000_000_000; + const threadId = `quota-model-overlap-${strategy}`; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + updateAccountQuota("b", 90); + + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread(threadId, config, now + 2, "shared")).toBe("a"); + }, + ); + + test.each(["fill-first", "round-robin"] as const)( + "%s retires shared state when model ineligibility overlaps failover", + (strategy) => { + const now = 1_800_000_000_000; + const threadId = `failure-model-overlap-${strategy}`; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + }); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt + 1, + }); + } + const resolveAt = now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4; + + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + resolveAt, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + + clearCodexUpstreamHealthForAccount("b"); + expect(resolveCodexAccountForThread(threadId, config, resolveAt + 1, "shared")).toBe("a"); + }, + ); + + test.each(["fill-first", "round-robin"] as const)( + "%s cannot re-pick a quota-drained shared account that remains model-eligible", + (strategy) => { + const now = 1_800_000_000_000; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 90); + resetCodexRoutingForManualSelection("b"); + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + now, + "shared", + { modelEligibleAccountIds: new Set(["a", "b"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }, + ); + + test.each(["fill-first", "round-robin"] as const)( + "%s cannot re-pick a failover-ready shared account that remains model-eligible", + (strategy) => { + const now = 1_800_000_000_000; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + }); + resetCodexRoutingForManualSelection("b"); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt + 1, + }); + } + const resolveAt = now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4; + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + resolveAt, + "shared", + { modelEligibleAccountIds: new Set(["a", "b"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }, + ); + + test("temporary main drain preserves unread health but still retires a known paused pin", () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + activeCodexAccountPinned: MAIN_CODEX_ACCOUNT_ID, + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID], + }); + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + Date.now(), + "shared", + { + nativeMainSelectionOnly: true, + modelEligibleAccountIds: new Set(), + }, + )).toEqual({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("model-only detour failure does not retire the healthy operator pin", () => { + const now = 1_800_000_000_000; + const config = makeConfig({ + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "a", 503, { + fixedAccount: true, + now: now + attempt, + }); + } + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, + "shared", + { modelEligibleAccountIds: new Set(["a", "c"]) }, + )).toEqual({ status: "selected", accountId: "c" }); + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + }); + + test("genuine quota transition still retires an exhausted pin during model-scoped selection", () => { + const config = makeConfig({ + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 90); + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + Date.now(), + "shared", + { modelEligibleAccountIds: new Set(["a", "b"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("genuine failure transition still retires a failing pin during model-scoped selection", () => { + const now = 1_800_000_000_000; + const config = makeConfig({ + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + }); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt, + }); + } + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, + "shared", + { modelEligibleAccountIds: new Set(["a", "b"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("model ineligibility does not preserve a simultaneously exhausted pin", () => { + const config = makeConfig({ + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 90); + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + Date.now(), + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("model ineligibility does not preserve a simultaneously failing pin", () => { + const now = 1_800_000_000_000; + const config = makeConfig({ + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + }); + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt, + }); + } + + expect(resolveCodexAccountForThreadDetailed( + null, + config, + now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + )).toEqual({ status: "selected", accountId: "a" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + test("falls through to the lower tier once the higher one is over threshold", () => { const config = orderedConfig(); updateAccountQuota("a", 90); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 384368eae7..fc1e4c20f6 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -4,10 +4,11 @@ * native effort clamp on final route, pool account preview for native fallback, * encrypted native-only fallback, native passthrough terminal finalization. */ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountQuota, @@ -29,8 +30,10 @@ import { setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; import { + CodexModelEntitlementDiscoveryUnavailableError, resetCodexModelEntitlementCacheForTests, } from "../src/codex/model-entitlements"; +import { getMainAccountPlan, setMainAccountPlan } from "../src/codex/main-account"; import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; @@ -64,6 +67,7 @@ beforeEach(() => { clearAccountQuota(); resetAgentTaskRecoveryState(); resetSubagentModelFallbackStateForTests(); + setMainAccountPlan(null); // Gated-native negative rosters are cached process-wide for 15s; a real-network // miss in one test must not fail-closed the next test's entitlement lookups. resetCodexModelEntitlementCacheForTests(); @@ -77,6 +81,7 @@ afterEach(() => { clearAccountQuota(); resetAgentTaskRecoveryState(); resetSubagentModelFallbackStateForTests(); + setMainAccountPlan(null); rmSync(testDir, { recursive: true, force: true }); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; @@ -95,6 +100,12 @@ function fernetFixture(ciphertextBytes = 16): string { const FERNET_TASK = fernetFixture(); const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; +function chatgptPlanJwt(plan: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify({ chatgpt_plan_type: plan })).toString("base64url"); + return `${header}.${body}.sig`; +} + function encryptedAgentInput(): unknown[] { return [{ type: "agent_message", @@ -251,6 +262,45 @@ async function postSpawn( ); } +async function postDirectCodex( + config: OcxConfig, + body: Record, + options: Parameters[3] = {}, +): Promise { + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer caller-codex-token", + }, + body: JSON.stringify(body), + }), + config, + { model: "", provider: "" }, + options, + ); +} + +function unsupportedCodexModelResponse(model: string): Response { + return new Response(JSON.stringify({ + detail: `The '${model}' model is not supported when using Codex with a ChatGPT account.`, + }), { + status: 400, + headers: { "content-type": "application/json" }, + }); +} + +function entitlementSnapshot(grants: Readonly>) { + return { + modelsByAccount: new Map( + Object.entries(grants).map(([accountId, models]) => [accountId, new Set(models)]), + ), + confirmedAccountIds: new Set(Object.keys(grants)), + credentialIdentities: new Map(), + }; +} + describe("subagent fallback without primary auth cooldown failure", () => { test("exact account child bypasses quota priming and fallback on an empty 503", async () => { const now = 1_800_000_000_000; @@ -802,6 +852,8 @@ describe("native fallback account preview", () => { let resolverCalls = 0; let rejectDiscovery!: (reason: Error) => void; const discovery = new Promise((_resolve, reject) => { rejectDiscovery = reject; }); + let signalResolverEntered!: () => void; + const resolverEntered = new Promise((resolve) => { signalResolverEntered = resolve; }); const turnAdmissionLease = { release() {}, beginCodexAccountSelection() { @@ -814,32 +866,131 @@ describe("native fallback account preview", () => { }, } satisfies Pick; let fetchCalls = 0; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; + + const pending = postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async () => { + resolverCalls += 1; + signalResolverEntered(); + return discovery; + }, + }, + ); + await resolverEntered; + + expect(resolverCalls).toBe(1); + expect(beginCount).toBe(1); + expect(releaseCount).toBe(0); + expect(fetchCalls).toBe(0); + + rejectDiscovery(new CodexModelEntitlementDiscoveryUnavailableError(new Error( + "entitlement discovery unavailable sk-secret123456\nforged-record\u2028next", + ))); + const response = await pending; + expect(response.status).toBe(503); + const responseText = await response.text(); + expect(responseText).toContain("Codex model eligibility is temporarily unavailable"); + expect(responseText).not.toContain("entitlement discovery unavailable"); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).toContain("model eligibility discovery failed"); + expect(warningText).toContain("[REDACTED]"); + expect(warningText).not.toContain("sk-secret123456"); + expect(warningText).not.toMatch(/[\r\n\u2028\u2029]/); + expect(releaseCount).toBe(1); + expect(fetchCalls).toBe(0); + } finally { + warning.mockRestore(); + } + }); + + test("final auth maps a later entitlement discovery failure to a closed 503 response", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest"], + }); + const entitlementSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.6-sol", "gpt-daybreak-blue-latest"])], + ]), + confirmedAccountIds: new Set(["pool-a"]), + credentialIdentities: new Map(), + }; + let entitlementCalls = 0; + let fetchCalls = 0; + const warning = spyOn(console, "warn").mockImplementation(() => { + throw new Error("logger unavailable"); + }); + try { + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; + + const response = await postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) return entitlementSnapshot; + throw new CodexModelEntitlementDiscoveryUnavailableError( + new Error("later entitlement discovery unavailable"), + ); + }, + }, + ); + + expect(response.status).toBe(503); + const responseText = await response.text(); + expect(responseText).toContain("Codex model eligibility is temporarily unavailable"); + expect(responseText).not.toContain("later entitlement discovery unavailable"); + expect(entitlementCalls).toBe(2); + expect(fetchCalls).toBe(0); + expect(warning).toHaveBeenCalled(); + } finally { + warning.mockRestore(); + } + }); + + test("programmer errors from entitlement discovery are not mislabeled as retryable 503s", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest"], + }); + let fetchCalls = 0; globalThis.fetch = (async () => { fetchCalls += 1; throw new Error("must not dispatch"); }) as typeof fetch; - const pending = postSpawn( + await expect(postSpawn( cfg, { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, { - turnAdmissionLease, resolveCodexModelEntitlements: async () => { - resolverCalls += 1; - return discovery; + throw new TypeError("programmer sentinel"); }, }, - ); - for (let i = 0; i < 20 && resolverCalls === 0; i += 1) await Promise.resolve(); - - expect(resolverCalls).toBe(1); - expect(beginCount).toBe(1); - expect(releaseCount).toBe(0); - expect(fetchCalls).toBe(0); - - rejectDiscovery(new Error("entitlement discovery unavailable")); - await expect(pending).rejects.toThrow("entitlement discovery unavailable"); - expect(releaseCount).toBe(1); + )).rejects.toThrow("programmer sentinel"); expect(fetchCalls).toBe(0); }); @@ -917,12 +1068,13 @@ describe("native fallback account preview", () => { }; const mainExclusions: boolean[] = []; let selectionReleases = 0; + let claimCalls = 0; const turnAdmissionLease = { release() {}, beginCodexAccountSelection() { return { mainProfileDraining: true, - claimMainProfile: () => false, + claimMainProfile: () => { claimCalls += 1; return false; }, release: () => { selectionReleases += 1; }, }; }, @@ -947,10 +1099,123 @@ describe("native fallback account preview", () => { expect(response.status).toBe(200); expect(mainExclusions).toEqual([true, true]); expect(selectionReleases).toBe(2); + expect(claimCalls).toBe(0); expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-b" }); expect(capture.auths[0]).toContain("pool-b_token"); }); + test("temporary drain keeps an unread main-only gated candidate ahead of routed fallback", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest", "xai/grok-4.5"], + }); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + fixedAccount: true, + modelId: "gpt-5.6-sol", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + const entitlementSnapshot = { + modelsByAccount: new Map>(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), + }; + const mainExclusions: boolean[] = []; + let selectionReleases = 0; + let claimCalls = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: true, + claimMainProfile: () => { claimCalls += 1; return false; }, + release: () => { selectionReleases += 1; }, + }; + }, + } satisfies Pick; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; + + const response = await postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async (_config, resolveOptions) => { + mainExclusions.push(resolveOptions?.excludeAccountIds?.has("__main__") === true); + return entitlementSnapshot; + }, + }, + ); + + expect(response.status).toBe(503); + expect(await response.text()).toContain("OpenCodex local native-main profile maintenance is active"); + expect(mainExclusions).toEqual([true, true]); + expect(selectionReleases).toBe(2); + expect(claimCalls).toBe(1); + expect(fetchCalls).toBe(0); + // If preview or pin retirement tried to score synthetic main, getMainAccountPlan + // would have consumed the missing auth.json attempt and cached `undefined`. + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + })); + expect(getMainAccountPlan()).toBe("pro"); + }); + + test("temporary drain keeps ordinary native-main fallback read-free until the final claim", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "__main__", + subagentModelFallback: ["gpt-5.5"], + }); + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + let selectionReleases = 0; + let claimCalls = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: true, + claimMainProfile: () => { claimCalls += 1; return false; }, + release: () => { selectionReleases += 1; }, + }; + }, + } satisfies Pick; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { turnAdmissionLease }, + ); + + expect(response.status).toBe(503); + expect(await response.text()).toContain("OpenCodex local native-main profile maintenance is active"); + expect(selectionReleases).toBe(2); + expect(claimCalls).toBe(1); + expect(fetchCalls).toBe(0); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + })); + expect(getMainAccountPlan()).toBe("pro"); + }); + test("Desktop fallback affinity drives the subagent preview and final native account", async () => { const now = 1_800_000_000_000; Date.now = () => now; @@ -1291,7 +1556,7 @@ describe("native fallback account preview", () => { */ test("both fallback preview sites pass the model-eligible account set (#2509)", async () => { const source = await Bun.file( - new URL("../src/server/responses/core.ts", import.meta.url).pathname, + fileURLToPath(new URL("../src/server/responses/core.ts", import.meta.url)), ).text(); const previews = source.match(/subagentFallbackAccountPreview = \([^)]*\)/g) ?? []; @@ -1457,6 +1722,305 @@ describe("native fallback account preview", () => { }); }); +describe("account-gated retry entitlement boundary", () => { + const model = "gpt-daybreak-blue-latest"; + + function retryConfig(secondAccount = false): OcxConfig { + // Keep account selection local to this boundary test. Without known quota, auth performs a + // WHAM prime whose fetch is unrelated to the credential-bearing send count asserted below. + updateAccountQuota("pool-a", 10, undefined, 20); + if (secondAccount) updateAccountQuota("pool-b", 10, undefined, 20); + return poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + ...(secondAccount + ? [{ id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }] + : []), + ], + }); + } + + test("an explicit discovery outage during the first 400 refresh returns a fixed 503", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + let entitlementCalls = 0; + let fetchCalls = 0; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + fetchCalls += 1; + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const response = await postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) return entitlementSnapshot({ "pool-a": [model] }); + throw new CodexModelEntitlementDiscoveryUnavailableError( + new Error("first-refresh-secret"), + ); + }, + }, + ); + + expect(response.status).toBe(503); + const text = await response.text(); + expect(text).toContain("Codex model eligibility is temporarily unavailable"); + expect(text).not.toContain("first-refresh-secret"); + expect(entitlementCalls).toBe(2); + expect(fetchCalls).toBe(1); + expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); + } finally { + warning.mockRestore(); + } + }); + + test("a first-refresh programmer error cancels the 400 and releases its quota probe", async () => { + const cooldownAt = 1_800_000_000_000; + const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + Date.now = () => probeAt; + installPoolCredential("pool-a", "pool_acc_a", probeAt); + const cfg = retryConfig(); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + fixedAccount: true, + modelId: model, + now: cooldownAt, + resetAt: Math.floor((cooldownAt + 4 * 24 * 60 * 60_000) / 1_000), + }); + let entitlementCalls = 0; + let firstAuth: CodexAuthContext | undefined; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + + await expect(postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + onCodexAuthContextResolved: (ctx) => { firstAuth ??= ctx; }, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) return entitlementSnapshot({ "pool-a": [model] }); + throw new TypeError("first-refresh programmer sentinel"); + }, + }, + )).rejects.toThrow("first-refresh programmer sentinel"); + + const firstProbeLeaseId = (firstAuth as { probeLeaseId?: string } | undefined)?.probeLeaseId; + expect(firstProbeLeaseId).toBeTruthy(); + expect(upstreamResponses).toHaveLength(1); + expect(upstreamResponses[0]?.bodyUsed).toBe(true); + + Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + const nextProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: model, + resolveCodexModelEntitlements: async () => entitlementSnapshot({ "pool-a": [model] }), + }); + expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).toBeTruthy(); + expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).not.toBe(firstProbeLeaseId); + }); + + test("an explicit discovery outage while selecting an alternate account returns a fixed 503", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = retryConfig(true); + let entitlementCalls = 0; + let fetchCalls = 0; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + fetchCalls += 1; + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const response = await postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) { + return entitlementSnapshot({ "pool-a": [model], "pool-b": [model] }); + } + if (entitlementCalls === 2) { + return entitlementSnapshot({ + "pool-a": ["gpt-5.6-sol"], + "pool-b": [model], + }); + } + throw new CodexModelEntitlementDiscoveryUnavailableError( + new Error("alternate-refresh-secret"), + ); + }, + }, + ); + + expect(response.status).toBe(503); + const text = await response.text(); + expect(text).toContain("Codex model eligibility is temporarily unavailable"); + expect(text).not.toContain("alternate-refresh-secret"); + expect(entitlementCalls).toBe(3); + expect(fetchCalls).toBe(1); + expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); + } finally { + warning.mockRestore(); + } + }); + + test("an alternate-selection programmer error cancels the 400 and releases its quota probe", async () => { + const cooldownAt = 1_800_000_000_000; + const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + Date.now = () => probeAt; + installPoolCredential("pool-a", "pool_acc_a", probeAt); + installPoolCredential("pool-b", "pool_acc_b", probeAt); + const cfg = retryConfig(true); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + fixedAccount: true, + modelId: model, + now: cooldownAt, + resetAt: Math.floor((cooldownAt + 4 * 24 * 60 * 60_000) / 1_000), + }); + let entitlementCalls = 0; + let firstAuth: CodexAuthContext | undefined; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + + await expect(postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + onCodexAuthContextResolved: (ctx) => { firstAuth ??= ctx; }, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) { + return entitlementSnapshot({ + "pool-a": [model], + "pool-b": ["gpt-5.6-sol"], + }); + } + if (entitlementCalls === 2) { + return entitlementSnapshot({ + "pool-a": ["gpt-5.6-sol"], + "pool-b": [model], + }); + } + throw new TypeError("alternate programmer sentinel"); + }, + }, + )).rejects.toThrow("alternate programmer sentinel"); + + const firstProbeLeaseId = (firstAuth as { probeLeaseId?: string } | undefined)?.probeLeaseId; + expect(firstProbeLeaseId).toBeTruthy(); + expect(upstreamResponses).toHaveLength(1); + expect(upstreamResponses[0]?.bodyUsed).toBe(true); + + Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + const nextProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: model, + resolveCodexModelEntitlements: async () => entitlementSnapshot({ + "pool-a": [model], + "pool-b": ["gpt-5.6-sol"], + }), + }); + expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).toBeTruthy(); + expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).not.toBe(firstProbeLeaseId); + }); + + test("an explicit discovery outage between bounded same-account retries returns a fixed 503", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + let entitlementCalls = 0; + let fetchCalls = 0; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + fetchCalls += 1; + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const response = await postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls <= 2) return entitlementSnapshot({ "pool-a": [model] }); + throw new CodexModelEntitlementDiscoveryUnavailableError( + new Error("same-account-refresh-secret"), + ); + }, + }, + ); + + expect(response.status).toBe(503); + const text = await response.text(); + expect(text).toContain("Codex model eligibility is temporarily unavailable"); + expect(text).not.toContain("same-account-refresh-secret"); + expect(entitlementCalls).toBe(3); + expect(fetchCalls).toBe(2); + expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); + } finally { + warning.mockRestore(); + } + }); + + test("a programmer error between same-account retries keeps its original error path", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + let entitlementCalls = 0; + let fetchCalls = 0; + const upstreamResponses: Response[] = []; + globalThis.fetch = (async () => { + fetchCalls += 1; + const response = unsupportedCodexModelResponse(model); + upstreamResponses.push(response); + return response; + }) as typeof fetch; + + await expect(postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls <= 2) return entitlementSnapshot({ "pool-a": [model] }); + throw new TypeError("retry programmer sentinel"); + }, + }, + )).rejects.toThrow("retry programmer sentinel"); + expect(entitlementCalls).toBe(3); + expect(fetchCalls).toBe(2); + expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); + }); +}); + describe("encrypted child native-only fallback", () => { test("rejects encrypted routed primary when only routed fallbacks exist", async () => { const cfg = poolNativePlusRoutedConfig({ From a270954cba054de7857898396e16c6744e90b69b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:51:14 +0900 Subject: [PATCH 013/132] fix(codex): defer unimplemented entitlement outage contract (cherry picked from commit 8011ff802982cf39cef3b13aab6e277204c10f46) --- src/codex/model-entitlements.ts | 12 - src/server/responses/core.ts | 106 +------ ...subagent-fallback-handle-responses.test.ts | 276 ++++-------------- 3 files changed, 73 insertions(+), 321 deletions(-) diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 1bc6f43285..5a649d335a 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -46,18 +46,6 @@ export interface CodexModelEntitlementResolveOptions { readonly excludeAccountIds?: ReadonlySet; } -/** - * Explicit request-boundary signal for an operational discovery failure that cannot be - * represented by the ordinary fail-closed `confirmed: false` snapshot. Generic throws are - * programming errors and must retain their original error path. - */ -export class CodexModelEntitlementDiscoveryUnavailableError extends Error { - constructor(cause?: unknown) { - super("Codex model entitlement discovery is temporarily unavailable", { cause }); - this.name = "CodexModelEntitlementDiscoveryUnavailableError"; - } -} - const accountModelsCache = new Map(); const accountModelsFlights = new Map>(); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ff6a2dea50..e88c6b4e97 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -150,7 +150,6 @@ import { type CodexAuthContext, } from "../../codex/auth-context"; import { - CodexModelEntitlementDiscoveryUnavailableError, entitledCodexAccountIdsForModel, invalidateCodexModelEntitlementsForAccount, resolveCodexModelEntitlements, @@ -919,7 +918,6 @@ type CodexPoolAccountRetryResult = selectedForwardHeaders: Headers; } | { kind: "no-alternate" } - | { kind: "eligibility-unavailable" } | { kind: "transport"; error: unknown; @@ -1020,13 +1018,10 @@ async function retryCodexPoolOnAlternateAccount( invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId); let refreshed; try { - refreshed = await resolveCodexModelEntitlementsForRequest(entitlementResolver, config); + refreshed = await entitlementResolver(config); } catch (error) { await firstResponse.body?.cancel().catch(() => undefined); releaseCodexAuthContextProbeLease(firstAuthCtx); - if (error instanceof CodexModelEligibilityUnavailableError) { - return { kind: "eligibility-unavailable" }; - } throw error; } if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) { @@ -1048,12 +1043,7 @@ async function retryCodexPoolOnAlternateAccount( excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), - resolveCodexModelEntitlements: (entitlementConfig, resolveOptions) => - resolveCodexModelEntitlementsForRequest( - entitlementResolver, - entitlementConfig, - resolveOptions, - ), + resolveCodexModelEntitlements: entitlementResolver, }, ); } catch (error) { @@ -1065,9 +1055,6 @@ async function retryCodexPoolOnAlternateAccount( if (unexpectedRetryError) { await firstResponse.body?.cancel().catch(() => undefined); releaseCodexAuthContextProbeLease(firstAuthCtx); - if (error instanceof CodexModelEligibilityUnavailableError) { - return { kind: "eligibility-unavailable" }; - } throw error; } } @@ -1193,15 +1180,12 @@ async function retryCodexPoolOnAlternateAccount( invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId); let refreshed: Awaited>; try { - refreshed = await resolveCodexModelEntitlementsForRequest(entitlementResolver, config); + refreshed = await entitlementResolver(config); } catch (error) { await upstreamResponse.body?.cancel().catch(() => undefined); await firstResponse.body?.cancel().catch(() => undefined); releaseCodexAuthContextProbeLease(firstAuthCtx); releaseCodexAuthContextProbeLease(retryAuthCtx); - if (error instanceof CodexModelEligibilityUnavailableError) { - return { kind: "eligibility-unavailable" }; - } throw error; } if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break; @@ -1644,12 +1628,7 @@ async function resolveResponsesCodexAuth( modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), - resolveCodexModelEntitlements: (entitlementConfig, resolveOptions) => - resolveCodexModelEntitlementsForRequest( - options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, - entitlementConfig, - resolveOptions, - ), + resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, }); options.onCodexAuthContextResolved?.(authCtx); } else { @@ -1679,9 +1658,6 @@ async function resolveResponsesCodexAuth( if (err instanceof ForwardAdmissionCredentialError) { return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; } - if (err instanceof CodexModelEligibilityUnavailableError) { - return { ok: false, response: codexModelEligibilityUnavailableResponse() }; - } const response = mapCodexAuthContextErrorToResponse(err, { accountSelector: route.codexAccountNamespace, now: Date.now(), @@ -1691,52 +1667,6 @@ async function resolveResponsesCodexAuth( } } -const CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE = - "Codex model eligibility is temporarily unavailable; retry this request"; - -class CodexModelEligibilityUnavailableError extends Error { - constructor() { - super(CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE); - this.name = "CodexModelEligibilityUnavailableError"; - } -} - -/** Return a retryable, redacted failure without letting discovery errors escape the request boundary. */ -function codexModelEligibilityUnavailableResponse(): Response { - return formatErrorResponse(503, "server_error", CODEX_MODEL_ELIGIBILITY_UNAVAILABLE_MESSAGE); -} - -/** Wrap account-roster discovery so both preview and final auth share one fail-closed error contract. */ -async function resolveCodexModelEntitlementsForRequest( - resolver: typeof resolveCodexModelEntitlements, - config: Parameters[0], - options?: Parameters[1], -): ReturnType { - try { - return await resolver(config, options); - } catch (cause) { - if (!(cause instanceof CodexModelEntitlementDiscoveryUnavailableError)) throw cause; - const diagnosticCause = cause.cause ?? cause; - let detail = "unknown error"; - try { - const rawDetail = diagnosticCause instanceof Error - ? `${diagnosticCause.name}: ${diagnosticCause.message}` - : String(diagnosticCause); - detail = sanitizeLogMetadataString(rawDetail, 300) ?? detail; - } catch { - // A hostile thrown value must not replace the fixed retryable response. - } - try { - console.warn( - `[codex-entitlements] model eligibility discovery failed; returning a retryable 503: ${detail}`, - ); - } catch { - // Logging is diagnostic only; the request boundary remains fail-closed below. - } - throw new CodexModelEligibilityUnavailableError(); - } -} - async function resolveSubagentFallbackModelEligibility(args: { config: OcxConfig; fallbackChain: readonly string[] | null; @@ -1747,11 +1677,7 @@ async function resolveSubagentFallbackModelEligibility(args: { const excludeAccountIds = args.nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; - const snapshot = await resolveCodexModelEntitlementsForRequest( - args.resolver, - args.config, - { excludeAccountIds }, - ); + const snapshot = await args.resolver(args.config, { excludeAccountIds }); return (modelId) => { const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId); return entitledAccountIds @@ -2617,19 +2543,12 @@ async function handleResponsesInner( // "legacy" affinity bucket and never find a binding made under "shared" or a native // model scope, making the preview diverge from the account that actually authenticates. const fallbackChain = initialSubagentFallbackChain; - try { - subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ - config, - fallbackChain, - nativeMainReadsForbidden, - resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, - }); - } catch (error) { - if (error instanceof CodexModelEligibilityUnavailableError) { - return codexModelEligibilityUnavailableResponse(); - } - throw error; - } + subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ + config, + fallbackChain, + nativeMainReadsForbidden, + resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, + }); const fallbackNow = Date.now(); subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( poolAffinityKey, @@ -3845,9 +3764,6 @@ async function handleResponsesInner( captureAffinityResponse(response, retryAuthCtx, retryRequest, true); }, }); - if (retry.kind === "eligibility-unavailable") { - return codexModelEligibilityUnavailableResponse(); - } if (retry.kind === "transport") { authCtx = retry.authCtx; return transportFailureResponse(retry.error); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index fc1e4c20f6..416e1eb3c4 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -29,10 +29,7 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; -import { - CodexModelEntitlementDiscoveryUnavailableError, - resetCodexModelEntitlementCacheForTests, -} from "../src/codex/model-entitlements"; +import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; import { getMainAccountPlan, setMainAccountPlan } from "../src/codex/main-account"; import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; @@ -838,7 +835,7 @@ describe("native fallback account preview", () => { expect(capture.auths[0]).toContain("pool-a_token"); }); - test("entitlement discovery holds and releases preview admission on rejection", async () => { + test("pending preview entitlement errors release admission after preserving the original path", async () => { const now = 1_800_000_000_000; Date.now = () => now; const cfg = poolNativePlusRoutedConfig({ @@ -866,53 +863,37 @@ describe("native fallback account preview", () => { }, } satisfies Pick; let fetchCalls = 0; - const warning = spyOn(console, "warn").mockImplementation(() => {}); - try { - globalThis.fetch = (async () => { - fetchCalls += 1; - throw new Error("must not dispatch"); - }) as typeof fetch; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; - const pending = postSpawn( - cfg, - { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, - { - turnAdmissionLease, - resolveCodexModelEntitlements: async () => { - resolverCalls += 1; - signalResolverEntered(); - return discovery; - }, + const pending = postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async () => { + resolverCalls += 1; + signalResolverEntered(); + return discovery; }, - ); - await resolverEntered; - - expect(resolverCalls).toBe(1); - expect(beginCount).toBe(1); - expect(releaseCount).toBe(0); - expect(fetchCalls).toBe(0); - - rejectDiscovery(new CodexModelEntitlementDiscoveryUnavailableError(new Error( - "entitlement discovery unavailable sk-secret123456\nforged-record\u2028next", - ))); - const response = await pending; - expect(response.status).toBe(503); - const responseText = await response.text(); - expect(responseText).toContain("Codex model eligibility is temporarily unavailable"); - expect(responseText).not.toContain("entitlement discovery unavailable"); - const warningText = warning.mock.calls.flat().join(" "); - expect(warningText).toContain("model eligibility discovery failed"); - expect(warningText).toContain("[REDACTED]"); - expect(warningText).not.toContain("sk-secret123456"); - expect(warningText).not.toMatch(/[\r\n\u2028\u2029]/); - expect(releaseCount).toBe(1); - expect(fetchCalls).toBe(0); - } finally { - warning.mockRestore(); - } + }, + ); + await resolverEntered; + + expect(resolverCalls).toBe(1); + expect(beginCount).toBe(1); + expect(releaseCount).toBe(0); + expect(fetchCalls).toBe(0); + + rejectDiscovery(new TypeError("preview entitlement programmer sentinel")); + await expect(pending).rejects.toThrow("preview entitlement programmer sentinel"); + expect(releaseCount).toBe(1); + expect(fetchCalls).toBe(0); }); - test("final auth maps a later entitlement discovery failure to a closed 503 response", async () => { + test("final-auth entitlement errors release both selection admissions on their original path", async () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); @@ -929,44 +910,46 @@ describe("native fallback account preview", () => { confirmedAccountIds: new Set(["pool-a"]), credentialIdentities: new Map(), }; + let beginCount = 0; + let releaseCount = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + beginCount += 1; + return { + mainProfileDraining: false, + claimMainProfile: () => true, + release: () => { releaseCount += 1; }, + }; + }, + } satisfies Pick; let entitlementCalls = 0; let fetchCalls = 0; - const warning = spyOn(console, "warn").mockImplementation(() => { - throw new Error("logger unavailable"); - }); - try { - globalThis.fetch = (async () => { - fetchCalls += 1; - throw new Error("must not dispatch"); - }) as typeof fetch; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; - const response = await postSpawn( - cfg, - { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, - { - resolveCodexModelEntitlements: async () => { - entitlementCalls += 1; - if (entitlementCalls === 1) return entitlementSnapshot; - throw new CodexModelEntitlementDiscoveryUnavailableError( - new Error("later entitlement discovery unavailable"), - ); - }, + await expect(postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + if (entitlementCalls === 1) return entitlementSnapshot; + throw new TypeError("final-auth entitlement programmer sentinel"); }, - ); + }, + )).rejects.toThrow("final-auth entitlement programmer sentinel"); - expect(response.status).toBe(503); - const responseText = await response.text(); - expect(responseText).toContain("Codex model eligibility is temporarily unavailable"); - expect(responseText).not.toContain("later entitlement discovery unavailable"); - expect(entitlementCalls).toBe(2); - expect(fetchCalls).toBe(0); - expect(warning).toHaveBeenCalled(); - } finally { - warning.mockRestore(); - } + expect(entitlementCalls).toBe(2); + expect(beginCount).toBe(2); + expect(releaseCount).toBe(2); + expect(fetchCalls).toBe(0); }); - test("programmer errors from entitlement discovery are not mislabeled as retryable 503s", async () => { + test("programmer errors from entitlement discovery retain their original path", async () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); @@ -1743,48 +1726,6 @@ describe("account-gated retry entitlement boundary", () => { }); } - test("an explicit discovery outage during the first 400 refresh returns a fixed 503", async () => { - const now = 1_800_000_000_000; - Date.now = () => now; - installPoolCredential("pool-a", "pool_acc_a", now); - const cfg = retryConfig(); - let entitlementCalls = 0; - let fetchCalls = 0; - const upstreamResponses: Response[] = []; - globalThis.fetch = (async () => { - fetchCalls += 1; - const response = unsupportedCodexModelResponse(model); - upstreamResponses.push(response); - return response; - }) as typeof fetch; - const warning = spyOn(console, "warn").mockImplementation(() => {}); - try { - const response = await postDirectCodex( - cfg, - { model, input: "hello", stream: false }, - { - resolveCodexModelEntitlements: async () => { - entitlementCalls += 1; - if (entitlementCalls === 1) return entitlementSnapshot({ "pool-a": [model] }); - throw new CodexModelEntitlementDiscoveryUnavailableError( - new Error("first-refresh-secret"), - ); - }, - }, - ); - - expect(response.status).toBe(503); - const text = await response.text(); - expect(text).toContain("Codex model eligibility is temporarily unavailable"); - expect(text).not.toContain("first-refresh-secret"); - expect(entitlementCalls).toBe(2); - expect(fetchCalls).toBe(1); - expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); - } finally { - warning.mockRestore(); - } - }); - test("a first-refresh programmer error cancels the 400 and releases its quota probe", async () => { const cooldownAt = 1_800_000_000_000; const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; @@ -1833,57 +1774,6 @@ describe("account-gated retry entitlement boundary", () => { expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).not.toBe(firstProbeLeaseId); }); - test("an explicit discovery outage while selecting an alternate account returns a fixed 503", async () => { - const now = 1_800_000_000_000; - Date.now = () => now; - installPoolCredential("pool-a", "pool_acc_a", now); - installPoolCredential("pool-b", "pool_acc_b", now); - const cfg = retryConfig(true); - let entitlementCalls = 0; - let fetchCalls = 0; - const upstreamResponses: Response[] = []; - globalThis.fetch = (async () => { - fetchCalls += 1; - const response = unsupportedCodexModelResponse(model); - upstreamResponses.push(response); - return response; - }) as typeof fetch; - const warning = spyOn(console, "warn").mockImplementation(() => {}); - try { - const response = await postDirectCodex( - cfg, - { model, input: "hello", stream: false }, - { - resolveCodexModelEntitlements: async () => { - entitlementCalls += 1; - if (entitlementCalls === 1) { - return entitlementSnapshot({ "pool-a": [model], "pool-b": [model] }); - } - if (entitlementCalls === 2) { - return entitlementSnapshot({ - "pool-a": ["gpt-5.6-sol"], - "pool-b": [model], - }); - } - throw new CodexModelEntitlementDiscoveryUnavailableError( - new Error("alternate-refresh-secret"), - ); - }, - }, - ); - - expect(response.status).toBe(503); - const text = await response.text(); - expect(text).toContain("Codex model eligibility is temporarily unavailable"); - expect(text).not.toContain("alternate-refresh-secret"); - expect(entitlementCalls).toBe(3); - expect(fetchCalls).toBe(1); - expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); - } finally { - warning.mockRestore(); - } - }); - test("an alternate-selection programmer error cancels the 400 and releases its quota probe", async () => { const cooldownAt = 1_800_000_000_000; const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; @@ -1947,48 +1837,6 @@ describe("account-gated retry entitlement boundary", () => { expect((nextProbe as { probeLeaseId?: string }).probeLeaseId).not.toBe(firstProbeLeaseId); }); - test("an explicit discovery outage between bounded same-account retries returns a fixed 503", async () => { - const now = 1_800_000_000_000; - Date.now = () => now; - installPoolCredential("pool-a", "pool_acc_a", now); - const cfg = retryConfig(); - let entitlementCalls = 0; - let fetchCalls = 0; - const upstreamResponses: Response[] = []; - globalThis.fetch = (async () => { - fetchCalls += 1; - const response = unsupportedCodexModelResponse(model); - upstreamResponses.push(response); - return response; - }) as typeof fetch; - const warning = spyOn(console, "warn").mockImplementation(() => {}); - try { - const response = await postDirectCodex( - cfg, - { model, input: "hello", stream: false }, - { - resolveCodexModelEntitlements: async () => { - entitlementCalls += 1; - if (entitlementCalls <= 2) return entitlementSnapshot({ "pool-a": [model] }); - throw new CodexModelEntitlementDiscoveryUnavailableError( - new Error("same-account-refresh-secret"), - ); - }, - }, - ); - - expect(response.status).toBe(503); - const text = await response.text(); - expect(text).toContain("Codex model eligibility is temporarily unavailable"); - expect(text).not.toContain("same-account-refresh-secret"); - expect(entitlementCalls).toBe(3); - expect(fetchCalls).toBe(2); - expect(upstreamResponses.every(response => response.bodyUsed)).toBe(true); - } finally { - warning.mockRestore(); - } - }); - test("a programmer error between same-account retries keeps its original error path", async () => { const now = 1_800_000_000_000; Date.now = () => now; From 9010b5abfccfd1332f0bd32f50491c07ec4df3ad Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:16:05 +0900 Subject: [PATCH 014/132] fix(codex): fence retry entitlement refresh (cherry picked from commit b4310bd18612cc6f0c711e45b899e0bd41fc4ccc) --- src/server/responses/core.ts | 35 +++++++++++++- ...subagent-fallback-handle-responses.test.ts | 48 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e88c6b4e97..f3d694f77a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -924,6 +924,29 @@ type CodexPoolAccountRetryResult = authCtx: Extract; }; +/** Keep retry-stage entitlement snapshots inside the native-main selection fence. */ +async function resolveCodexRetryModelEntitlements( + config: OcxConfig, + resolver: typeof resolveCodexModelEntitlements, + turnAdmissionLease?: AdmissionLease, +): Promise>> { + // The initial auth selection has already released its admission before the first + // response arrives. Re-enter for every refresh so profile switching cannot overlap + // credential discovery, and omit main entirely when a drain or recovery owns it. + const selectionAdmission = codexAccountSelectionForTurn(turnAdmissionLease)?.(); + const nativeMainReadsForbidden = isNativeMainTrafficBlocked() + || selectionAdmission?.mainProfileDraining === true; + try { + return await resolver(config, { + excludeAccountIds: nativeMainReadsForbidden + ? new Set([MAIN_CODEX_ACCOUNT_ID]) + : undefined, + }); + } finally { + selectionAdmission?.release(); + } +} + const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap = new Map([ // The authenticated catalog currently advertises Daybreak Blue, while successful responses // identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent: @@ -1018,7 +1041,11 @@ async function retryCodexPoolOnAlternateAccount( invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId); let refreshed; try { - refreshed = await entitlementResolver(config); + refreshed = await resolveCodexRetryModelEntitlements( + config, + entitlementResolver, + options.turnAdmissionLease, + ); } catch (error) { await firstResponse.body?.cancel().catch(() => undefined); releaseCodexAuthContextProbeLease(firstAuthCtx); @@ -1180,7 +1207,11 @@ async function retryCodexPoolOnAlternateAccount( invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId); let refreshed: Awaited>; try { - refreshed = await entitlementResolver(config); + refreshed = await resolveCodexRetryModelEntitlements( + config, + entitlementResolver, + options.turnAdmissionLease, + ); } catch (error) { await upstreamResponse.body?.cancel().catch(() => undefined); await firstResponse.body?.cancel().catch(() => undefined); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 416e1eb3c4..3997bc8670 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -1726,6 +1726,54 @@ describe("account-gated retry entitlement boundary", () => { }); } + test("temporary main drain fences every retry-stage entitlement refresh", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + let selectionReleases = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: true, + claimMainProfile: () => false, + release: () => { selectionReleases += 1; }, + }; + }, + } satisfies Pick; + const mainExclusions: boolean[] = []; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + if (fetchCalls <= 2) return unsupportedCodexModelResponse(model); + return Response.json({ + id: "resp_retry_fenced", + object: "response", + status: "completed", + model, + output: [], + }); + }) as typeof fetch; + + const response = await postDirectCodex( + cfg, + { model, input: "hello", stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async (_config, resolveOptions) => { + mainExclusions.push(resolveOptions?.excludeAccountIds?.has("__main__") === true); + return entitlementSnapshot({ "pool-a": [model] }); + }, + }, + ); + + expect(response.status).toBe(200); + expect(fetchCalls).toBe(3); + expect(mainExclusions).toEqual([true, true, true]); + expect(selectionReleases).toBe(3); + }); + test("a first-refresh programmer error cancels the 400 and releases its quota probe", async () => { const cooldownAt = 1_800_000_000_000; const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; From da15d8663e126788074c6b95eae6bf177a5e6502 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 20:44:00 +0900 Subject: [PATCH 015/132] fix(codex): preserve main claims and model detours (cherry picked from commit 28a20456222c2c79b98bea5cbe75be699661dcc7) --- src/codex/auth-context.ts | 64 +++- src/codex/routing.ts | 330 +++++++++++++----- src/server/responses/core.ts | 24 +- .../bearer-admission-routed-provider.test.ts | 118 ++++++- tests/codex-auth-context.test.ts | 170 ++++++++- tests/codex-routing.test.ts | 296 +++++++++++++++- ...subagent-fallback-handle-responses.test.ts | 4 +- 7 files changed, 901 insertions(+), 105 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index acfb82ccfb..dcf9bb88df 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -361,21 +361,50 @@ export async function resolveCodexAuthContext( // selected stored credential even while the canonical OpenAI provider is globally Direct. if (mode === "direct" && fixedAccountId === undefined) { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); - if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { - const entitled = options.substituteMainCredentialForDirect - ? entitledCodexAccountIdsForModel( - await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config), - options.modelId, - )?.has(MAIN_CODEX_ACCOUNT_ID) === true - : await (options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel)( - headers, - options.modelId, - ); - if (!entitled) { - throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + const substituteStoredMain = options.substituteMainCredentialForDirect === true; + if (!substituteStoredMain) { + if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { + const entitled = await ( + options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel + )(headers, options.modelId); + if (!entitled) { + throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + } } + return { kind: "main", accountId: null }; + } + + // Admission-bearer Direct requests later replace the proxy secret with the stored + // native-main credential. Reserve and claim that physical profile before entitlement + // discovery or materialization can read it; caller-owned Direct credentials never enter + // this branch. A missing turn admission must fail closed instead of recreating an + // untracked native-main read. + if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError(); + const directSelectionAdmission = options.beginCodexAccountSelection?.(); + if (!directSelectionAdmission) throw new CodexMainProfileDrainingError(); + try { + if ( + directSelectionAdmission.mainProfileDraining + || !directSelectionAdmission.claimMainProfile() + || isNativeMainTrafficBlocked() + ) { + throw new CodexMainProfileDrainingError(); + } + if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { + const entitled = entitledCodexAccountIdsForModel( + await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config), + options.modelId, + )?.has(MAIN_CODEX_ACCOUNT_ID) === true; + if (!entitled) { + throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + } + } + return { kind: "main", accountId: null }; + } finally { + // The short selector reservation ends here. A successful claim remains owned by + // the enclosing turn lease until the request or transferred stream settles. + directSelectionAdmission.release(); } - return { kind: "main", accountId: null }; } const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined; // Retained startup recovery makes the physical main identity ineligible. Routing @@ -426,7 +455,14 @@ export async function resolveCodexAuthContext( ? { status: "selected" as const, accountId: selected } : { status: "none" as const }; })() - : resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions); + : resolveCodexAccountForThreadDetailed( + affinityKey ?? null, + config, + Date.now(), + quotaScope, + selectionOptions, + options.modelId, + ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; if (!selected) { diff --git a/src/codex/routing.ts b/src/codex/routing.ts index b9ad137390..100a7e0a9e 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -158,22 +158,21 @@ export type CodexQuotaRecoveryProbeProof = { * affinity so a Spark failover cannot displace the same thread's Terra/Luna * account (and vice versa). */ -type ThreadAffinityScope = CodexQuotaScope | "legacy"; +type BaseThreadAffinityScope = CodexQuotaScope | "legacy"; +type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`; +type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope; const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const; const threadAccountMap = new Map>(); +let threadAffinityEntryTotal = 0; + +function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope { + return scope.startsWith("model-detour:"); +} const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = { "gpt-5.3-codex-spark": "spark", }; -// A thread can have one legacy binding plus one binding for each known scope. -// This upper-bound guard avoids an exact map scan until it can be over capacity. -const MAX_THREAD_AFFINITY_SCOPES = new Set([ - LEGACY_THREAD_AFFINITY_SCOPE, - "shared", - ...Object.values(NATIVE_MODEL_QUOTA_SCOPES), -]).size; - export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined { if (!modelId?.trim()) return undefined; return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared"; @@ -253,12 +252,15 @@ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet export function clearThreadAccountMap(): void { threadAccountMap.clear(); + threadAffinityEntryTotal = 0; } export function clearThreadAccountMapForAccount(accountId: string): void { for (const [threadId, affinities] of threadAccountMap) { for (const [scope, entry] of affinities) { - if (entry.accountId === accountId) affinities.delete(scope); + if (entry.accountId === accountId && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } } if (affinities.size === 0) threadAccountMap.delete(threadId); } @@ -830,7 +832,7 @@ function isCodexAccountSelectable( && isCodexAccountUsable(config, accountId, selectionOptions); } -function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope { +function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope { return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE; } @@ -838,34 +840,74 @@ function admissibleAffinityComponent(value: string): boolean { return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES; } -function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined { +function modelDetourAffinityScope( + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): ModelDetourAffinityScope | undefined { + const canonicalModelId = modelId?.trim().toLowerCase(); + if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined; + return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`; +} + +function getThreadAffinityForScope( + threadId: string, + scope: ThreadAffinityScope, +): ThreadAffinityEntry | undefined { if (!admissibleAffinityComponent(threadId)) return undefined; - return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope)); + return threadAccountMap.get(threadId)?.get(scope); } -function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void { +function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined { + return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); +} + +function getModelDetourAffinity( + threadId: string, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): ThreadAffinityEntry | undefined { + const scope = modelDetourAffinityScope(modelId, quotaScope); + return scope ? getThreadAffinityForScope(threadId, scope) : undefined; +} + +function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void { if (!admissibleAffinityComponent(threadId)) return; const affinities = threadAccountMap.get(threadId); if (!affinities) return; - affinities.delete(threadAffinityScope(quotaScope)); + if (affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } if (affinities.size === 0) threadAccountMap.delete(threadId); } +function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void { + deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); +} + +function deleteModelDetourAffinity( + threadId: string, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): void { + const scope = modelDetourAffinityScope(modelId, quotaScope); + if (scope) deleteThreadAffinityForScope(threadId, scope); +} + /** Remove only the matching failed account's affinities for one thread. */ function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void { if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; const affinities = threadAccountMap.get(threadId); if (!affinities) return; for (const [scope, entry] of affinities) { - if (entry.accountId === accountId) affinities.delete(scope); + if (entry.accountId === accountId && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } } if (affinities.size === 0) threadAccountMap.delete(threadId); } function threadAffinityEntryCount(): number { - let count = 0; - for (const affinities of threadAccountMap.values()) count += affinities.size; - return count; + return threadAffinityEntryTotal; } function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean { @@ -880,43 +922,50 @@ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean { function pruneExpiredThreadAffinities(now: number): void { for (const [threadId, affinities] of threadAccountMap) { for (const [scope, entry] of affinities) { - if (isThreadAffinityExpired(entry, now)) affinities.delete(scope); + if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } } if (affinities.size === 0) threadAccountMap.delete(threadId); } } function pruneLruThreadAffinities(): void { - if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return; + if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return; while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { let oldestThreadId: string | null = null; let oldestScope: ThreadAffinityScope | null = null; let oldestLastUsedAt = Number.POSITIVE_INFINITY; + let oldestIsDetour = false; for (const [threadId, affinities] of threadAccountMap) { for (const [scope, entry] of affinities) { - if (entry.lastUsedAt < oldestLastUsedAt) { + const candidateIsDetour = isModelDetourAffinityScope(scope); + if ( + (candidateIsDetour && !oldestIsDetour) + || (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt) + ) { oldestThreadId = threadId; oldestScope = scope; oldestLastUsedAt = entry.lastUsedAt; + oldestIsDetour = candidateIsDetour; } } } if (!oldestThreadId || !oldestScope) return; - deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope); + deleteThreadAffinityForScope(oldestThreadId, oldestScope); } } -function bindThreadAffinity( +function bindThreadAffinityForScope( threadId: string, accountId: string, now: number, - quotaScope?: CodexQuotaScope, + scope: ThreadAffinityScope, ): void { if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return; pruneExpiredThreadAffinities(now); - const scope = threadAffinityScope(quotaScope); const affinities = threadAccountMap.get(threadId) ?? new Map(); const previous = affinities.get(scope); affinities.set(scope, { @@ -926,10 +975,31 @@ function bindThreadAffinity( lastUsedAt: now, lastReevalAt: now, }); + if (!previous) threadAffinityEntryTotal += 1; threadAccountMap.set(threadId, affinities); pruneLruThreadAffinities(); } +function bindThreadAffinity( + threadId: string, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, +): void { + bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope)); +} + +function bindModelDetourAffinity( + threadId: string, + accountId: string, + now: number, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): void { + const scope = modelDetourAffinityScope(modelId, quotaScope); + if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope); +} + function getEligiblePoolAccounts( config: OcxConfig, excludeId?: string, @@ -1461,6 +1531,30 @@ function isHealthySharedCodexSelection( && !shouldFailover(config, accountId, now); } +function strategySelectionOptionsForModelDetour( + config: OcxConfig, + now: number, + quotaScope: CodexQuotaScope | undefined, + selectionOptions: CodexAccountUsabilityOptions | undefined, +): CodexAccountUsabilityOptions | undefined { + if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions; + const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {}; + return { + ...selectionOptions, + modelEligibleAccountIds: new Set( + [...selectionOptions.modelEligibleAccountIds].filter(accountId => + isHealthySharedCodexSelection( + config, + accountId, + now, + quotaScope, + sharedSelectionOptions, + ) + ), + ), + }; +} + function applyFailureFailover( config: OcxConfig, active: string, @@ -1496,6 +1590,47 @@ export function resolveCodexAccountForThread( return resolution.status === "selected" ? resolution.accountId : null; } +function previewReusableAffinityAccount( + entry: ThreadAffinityEntry | undefined, + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + if ( + !entry + || isThreadAffinityExpired(entry, now) + || !isThreadAffinityGenerationLive(entry) + || !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions) + || shouldFailover(config, entry.accountId, now) + ) { + return null; + } + // Quota strategy only: non-quota strategies keep affinity for ongoing threads + // (new-session-only rotation — docs / affinity policy A). + if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold > 0) { + const usage = computeCodexUsageScore( + getAccountQuota(entry.accountId), + getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), + ); + if (!isUnknownUsage(usage) && usage >= threshold) { + const best = pickLowerUsageAccount( + config, + entry.accountId, + usage, + now, + quotaScope, + selectionOptions, + ); + if (best !== entry.accountId) return best; + } + } + } + return entry.accountId; +} + /** * Side-effect-free preview of the Codex pool account native routing would prefer. * Used for subagent fallback quota decisions before final auth. @@ -1510,42 +1645,31 @@ export function previewCodexAccountForRequest( now = Date.now(), quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + modelId?: string, ): string | null { - const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; - if (threadId && entry) { - if ( - !isThreadAffinityExpired(entry, now) - && isThreadAffinityGenerationLive(entry) - && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions) - && !shouldFailover(config, entry.accountId, now) - ) { - // Quota strategy only: non-quota strategies keep affinity for ongoing threads - // (new-session-only rotation — docs / affinity policy A). - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); - if (strategy === "quota") { - const threshold = config.autoSwitchThreshold ?? 80; - if (threshold > 0) { - const usage = computeCodexUsageScore( - getAccountQuota(entry.accountId), - getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), - ); - if (!isUnknownUsage(usage) && usage >= threshold) { - const best = pickLowerUsageAccount( - config, - entry.accountId, - usage, - now, - quotaScope, - selectionOptions, - ); - if (best !== entry.accountId) return best; - } - } - } - return entry.accountId; - } - // Stale/unusable affinity is ignored for preview (no map mutation). + // A request-scoped model detour keeps its own serving-account affinity. Preview + // reads it before the ordinary lane, but never repairs or deletes it. Roster + // expansion therefore preserves the already-serving account, and preview mirrors + // final resolution even when the ordinary lane was independently retired. + if (threadId && selectionOptions?.modelEligibleAccountIds !== undefined) { + const detourPreview = previewReusableAffinityAccount( + getModelDetourAffinity(threadId, modelId, quotaScope), + config, + now, + quotaScope, + selectionOptions, + ); + if (detourPreview) return detourPreview; } + const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; + const ordinaryPreview = previewReusableAffinityAccount( + entry, + config, + now, + quotaScope, + selectionOptions, + ); + if (ordinaryPreview) return ordinaryPreview; const strategyPick = pickUnboundStrategyAccount( config, @@ -1553,7 +1677,7 @@ export function previewCodexAccountForRequest( now, false, quotaScope, - selectionOptions, + strategySelectionOptionsForModelDetour(config, now, quotaScope, selectionOptions), ); if (strategyPick) return strategyPick; @@ -1602,6 +1726,7 @@ export function resolveCodexAccountForThreadDetailed( now = Date.now(), quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + modelId?: string, ): CodexThreadResolution { // An entitlement roster constrains only this model request. It must not rewrite // the operator's shared active/pin choice or the task's ordinary-model affinity. @@ -1629,6 +1754,56 @@ export function resolveCodexAccountForThreadDetailed( ) ); + if (threadId && modelScopedSelection) { + const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope); + if (detourEntry) { + const detourReusable = !isThreadAffinityExpired(detourEntry, now) + && isThreadAffinityGenerationLive(detourEntry) + && isCodexAccountSelectable(config, detourEntry.accountId, now, quotaScope, selectionOptions) + && !shouldFailover(config, detourEntry.accountId, now); + if (detourReusable) { + detourEntry.lastUsedAt = now; + // Model detours follow the same affinity policy as ordinary bindings: + // RR/fill-first stay sticky, while quota strategy may re-evaluate an + // over-threshold account without changing the ordinary lane. + if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + const threshold = config.autoSwitchThreshold ?? 80; + const usage = threshold > 0 + ? computeCodexUsageScore( + getAccountQuota(detourEntry.accountId), + getPoolAccountPlanForSelection(config, detourEntry.accountId, selectionOptions), + ) + : 0; + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if ( + overThreshold + || now - detourEntry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + ) { + detourEntry.lastReevalAt = now; + if (overThreshold) { + const best = pickLowerUsageAccount( + config, + detourEntry.accountId, + usage, + now, + quotaScope, + selectionOptions, + ); + if (best !== detourEntry.accountId) { + bindModelDetourAffinity(threadId, best, now, modelId, quotaScope); + return { status: "selected", accountId: best }; + } + } + } + } + return { status: "selected", accountId: detourEntry.accountId }; + } + // Detour expiry or invalidation must not expire the ordinary task. Drop only + // this model lane and select from ordinary/shared state below. + deleteModelDetourAffinity(threadId, modelId, quotaScope); + } + } + const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; if (threadId && entry) { if (isThreadAffinityExpired(entry, now)) { @@ -1699,22 +1874,12 @@ export function resolveCodexAccountForThreadDetailed( // otherwise RR/fill-first can immediately re-pick a known failing account even when another // entitled account is healthy. If no healthy member exists, the normal fallback path below // still decides whether the sole eligible candidate must be used. - const strategySelectionOptions = modelScopedSelection - ? { - ...selectionOptions, - modelEligibleAccountIds: new Set( - [...selectionOptions!.modelEligibleAccountIds!].filter(accountId => - isHealthySharedCodexSelection( - config, - accountId, - now, - quotaScope, - sharedSelectionOptions, - ) - ), - ), - } - : selectionOptions; + const strategySelectionOptions = strategySelectionOptionsForModelDetour( + config, + now, + quotaScope, + selectionOptions, + ); const strategyPick = pickUnboundStrategyAccount( config, threadId, @@ -1726,6 +1891,9 @@ export function resolveCodexAccountForThreadDetailed( !preserveExistingModelScopedAffinity, ); if (strategyPick) { + if (threadId && preserveExistingModelScopedAffinity) { + bindModelDetourAffinity(threadId, strategyPick, now, modelId, quotaScope); + } if ( modelScopedSelection && !preserveSharedSelectionForModelDetour @@ -1835,8 +2003,12 @@ export function resolveCodexAccountForThreadDetailed( ? { status: "selected", accountId: active } : { status: "none" }; } - if (threadId && !preserveExistingModelScopedAffinity) { - bindThreadAffinity(threadId, active, now, quotaScope); + if (threadId) { + if (preserveExistingModelScopedAffinity) { + bindModelDetourAffinity(threadId, active, now, modelId, quotaScope); + } else { + bindThreadAffinity(threadId, active, now, quotaScope); + } } return { status: "selected", accountId: active }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f3d694f77a..561cd72d70 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -214,7 +214,13 @@ import { import { shouldAttemptImageTierRetry } from "../image-retry"; import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; import type { WsData } from "../ws-bridge"; -import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle"; +import { + codexAccountSelectionForTurn, + registerTurn, + trackStreamLifetime, + tryClaimNativeMainProfileForTurn, + unregisterTurn, +} from "../lifecycle"; import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import type { AdmissionLease } from "../../lib/admission"; @@ -1663,6 +1669,20 @@ async function resolveResponsesCodexAuth( }); options.onCodexAuthContextResolved?.(authCtx); } else { + // A custom-named canonical-forward provider has no Codex account mode, but an + // admission bearer still substitutes the stored main credential below. Claim the + // same physical profile before synthesizing the main context so transport-based + // substitution cannot bypass a switch drain. + if ( + substituteMainCredential + && ( + isNativeMainTrafficBlocked() + || !tryClaimNativeMainProfileForTurn(options.turnAdmissionLease) + || isNativeMainTrafficBlocked() + ) + ) { + throw new CodexMainProfileDrainingError(); + } authCtx = { kind: "main", accountId: null }; options.onCodexAuthContextResolved?.(undefined); } @@ -2587,6 +2607,7 @@ async function handleResponsesInner( previewNow, codexQuotaScopeForModel(modelId), { ...previewSelectionOptions, modelEligibleAccountIds }, + modelId, ); const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview( route.modelId, @@ -2708,6 +2729,7 @@ async function handleResponsesInner( previewNow, codexQuotaScopeForModel(modelId), { ...recoverySelectionOptions, modelEligibleAccountIds }, + modelId, ); const recoveryPreviewAccountId = subagentFallbackAccountPreview( parsed.modelId, diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts index 51f1fd99ee..82a8c75400 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -4,7 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { + acquireNativeMainProfileDrain, + getNativeMainProfileRequestCount, +} from "../src/server/lifecycle"; +import { waitForNativeMainStartupGate } from "../src/codex/native-profile-startup"; +import { handleNativeProfileAPI } from "../src/codex/native-profile-api"; +import type { NativeProfileManager } from "../src/codex/native-profile-manager"; import type { OcxConfig } from "../src/types"; +import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspection"; /** * Issue #2132: bearer admission must not require a stored ChatGPT credential. @@ -33,6 +41,7 @@ let nativeAuth: Array = []; const ADMISSION_SECRET = "ocx_data_2132secret"; const ROUTED_KEY = "sk-routed-provider-key"; +const inspectNativeCodexOwnership = ownedServiceHomeInspection("bearer admission routed provider test"); /** A JWT whose `exp` is far in the future, so a stored main token reads as live. */ function liveJwt(): string { @@ -131,7 +140,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route // The reported install: no ChatGPT login was ever performed. writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0); + const server = startServer(0, { inspectNativeCodexOwnership }); try { const response = await postResponses(server.url, "gateway/gateway-model"); @@ -151,8 +160,9 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route saveConfig(mixedConfig()); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0); + const server = startServer(0, { inspectNativeCodexOwnership }); try { + await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "gpt-5.5"); // This is the #1686 guarantee and it must survive: a native route genuinely needs the @@ -173,8 +183,9 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), ); - const server = startServer(0); + const server = startServer(0, { inspectNativeCodexOwnership }); try { + await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "gpt-5.5"); expect(response.status).toBe(200); @@ -223,8 +234,9 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate saveConfig(customNamedCanonicalConfig()); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0); + const server = startServer(0, { inspectNativeCodexOwnership }); try { + await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "mirror/gpt-5.5"); // Fail-before-I/O is the contract (src/codex/auth-context.ts): the only two acceptable @@ -245,8 +257,9 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), ); - const server = startServer(0); + const server = startServer(0, { inspectNativeCodexOwnership }); try { + await waitForNativeMainStartupGate(); await postResponses(server.url, "mirror/gpt-5.5"); expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); @@ -255,4 +268,99 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate await server.stop(true); } }); + + test("stored-main substitution respects a native-main drain", async () => { + saveConfig(customNamedCanonicalConfig()); + const stored = liveJwt(); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = startServer(0, { inspectNativeCodexOwnership }); + await waitForNativeMainStartupGate(); + const drain = acquireNativeMainProfileDrain("custom-forward-substitution"); + expect(drain).not.toBeNull(); + try { + const response = await postResponses(server.url, "mirror/gpt-5.5"); + + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect(nativeAuth).toHaveLength(0); + expect(routedAuth).toHaveLength(0); + } finally { + drain?.release(); + await server.stop(true); + } + }); + + test("stored-main substitution holds ownership until the upstream request settles", async () => { + saveConfig(customNamedCanonicalConfig()); + const stored = liveJwt(); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + let signalUpstreamStarted!: () => void; + const upstreamStarted = new Promise((resolve) => { signalUpstreamStarted = resolve; }); + let releaseUpstream!: () => void; + const upstreamGate = new Promise((resolve) => { releaseUpstream = resolve; }); + globalThis.fetch = (async (input, init) => { + const raw = input instanceof Request ? input.url : String(input); + const headers = new Headers(input instanceof Request ? input.headers : init?.headers); + if (new URL(raw).hostname === "chatgpt.com") { + nativeAuth.push(headers.get("authorization")); + signalUpstreamStarted(); + await upstreamGate; + return Response.json({ id: "resp_2132_held", object: "response", status: "completed", output: [] }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0, { inspectNativeCodexOwnership }); + await waitForNativeMainStartupGate(); + const pending = postResponses(server.url, "mirror/gpt-5.5"); + const switchUrl = new URL("http://localhost/api/native-main-profiles/switch"); + const switchRequest = () => new Request(switchUrl, { + method: "POST", + body: JSON.stringify({ target: "target", confirmedStopped: true }), + }); + let switches = 0; + const manager = { + switch: async () => { + switches += 1; + return { ok: true }; + }, + } as unknown as NativeProfileManager; + try { + await upstreamStarted; + expect(getNativeMainProfileRequestCount()).toBe(1); + const blocked = await handleNativeProfileAPI( + switchRequest(), + switchUrl, + {} as OcxConfig, + { manager, drainTimeoutMs: 0 }, + ); + expect(blocked?.status).toBe(409); + expect(switches).toBe(0); + + releaseUpstream(); + const response = await pending; + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + expect(getNativeMainProfileRequestCount()).toBe(0); + const switched = await handleNativeProfileAPI( + switchRequest(), + switchUrl, + {} as OcxConfig, + { manager, drainTimeoutMs: 0 }, + ); + expect(switched?.status).toBe(200); + expect(switches).toBe(1); + } finally { + releaseUpstream(); + await pending.catch(() => {}); + await server.stop(true); + } + }); }); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index d2b4c7d999..6135066cdb 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -55,6 +55,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, } from "../src/codex/routing"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { setIcaclsRunnerForTests } from "../src/lib/windows-secret-acl"; @@ -425,7 +426,11 @@ describe("Codex auth context", () => { test("direct mode returns caller-owned main context without touching pool selection", async () => { const cfg = { ...config(), activeCodexAccountId: "missing-pool-account" }; - await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer caller" }), cfg, "direct")) + await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer caller" }), cfg, "direct", { + beginCodexAccountSelection: () => { + throw new Error("caller-owned Direct must not reserve stored main"); + }, + })) .resolves.toEqual({ kind: "main", accountId: null }); }); test("direct mode fails locally without a caller bearer", async () => { @@ -463,6 +468,9 @@ describe("Codex auth context", () => { credentialIdentities: new Map(), }; let callerChecks = 0; + let claimCalls = 0; + let selectionReleases = 0; + let claimed = false; await expect(resolveCodexAuthContext( new Headers({ authorization: "Bearer ocx-admission" }), config(), @@ -470,7 +478,19 @@ describe("Codex auth context", () => { { modelId: "gpt-daybreak-blue-latest", substituteMainCredentialForDirect: true, - resolveCodexModelEntitlements: async () => entitledMain, + beginCodexAccountSelection: () => ({ + mainProfileDraining: false, + claimMainProfile: () => { + claimCalls += 1; + claimed = true; + return true; + }, + release: () => { selectionReleases += 1; }, + }), + resolveCodexModelEntitlements: async () => { + expect(claimed).toBe(true); + return entitledMain; + }, isDirectCallerEntitledToCodexModel: async () => { callerChecks += 1; return false; @@ -478,6 +498,86 @@ describe("Codex auth context", () => { }, )).resolves.toEqual({ kind: "main", accountId: null }); expect(callerChecks).toBe(0); + expect(claimCalls).toBe(1); + expect(selectionReleases).toBe(1); + }); + + test("Direct admission-bearer substitution fails closed during native-main drain", async () => { + let entitlementCalls = 0; + let claimCalls = 0; + let selectionReleases = 0; + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer ocx-admission" }), + config(), + "direct", + { + modelId: "gpt-5.5", + substituteMainCredentialForDirect: true, + beginCodexAccountSelection: () => ({ + mainProfileDraining: true, + claimMainProfile: () => { + claimCalls += 1; + return false; + }, + release: () => { selectionReleases += 1; }, + }), + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + throw new Error("must not read stored-main entitlements while draining"); + }, + }, + )).rejects.toBeInstanceOf(CodexMainProfileDrainingError); + expect(entitlementCalls).toBe(0); + expect(claimCalls).toBe(0); + expect(selectionReleases).toBe(1); + }); + + test("Direct admission-bearer substitution fails closed when the atomic claim loses", async () => { + let entitlementCalls = 0; + let claimCalls = 0; + let selectionReleases = 0; + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer ocx-admission" }), + config(), + "direct", + { + modelId: "gpt-daybreak-blue-latest", + substituteMainCredentialForDirect: true, + beginCodexAccountSelection: () => ({ + mainProfileDraining: false, + claimMainProfile: () => { + claimCalls += 1; + return false; + }, + release: () => { selectionReleases += 1; }, + }), + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + throw new Error("must not read stored-main entitlements after a lost claim"); + }, + }, + )).rejects.toBeInstanceOf(CodexMainProfileDrainingError); + expect(entitlementCalls).toBe(0); + expect(claimCalls).toBe(1); + expect(selectionReleases).toBe(1); + }); + + test("Direct admission-bearer substitution requires a turn-owned native-main claim", async () => { + let entitlementCalls = 0; + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer ocx-admission" }), + config(), + "direct", + { + modelId: "gpt-daybreak-blue-latest", + substituteMainCredentialForDirect: true, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + throw new Error("must not read stored-main entitlements without admission"); + }, + }, + )).rejects.toBeInstanceOf(CodexMainProfileDrainingError); + expect(entitlementCalls).toBe(0); }); test("account-gated native routing skips an active account without the model grant", async () => { @@ -527,6 +627,72 @@ describe("Codex auth context", () => { }); }); + test("auth resolution preserves per-model detours without replacing ordinary affinity", async () => { + const cfg = config(); + cfg.accountPoolStrategy = "round-robin"; + cfg.accountPoolStickyLimit = 1; + cfg.activeCodexAccountId = "pool-b"; + cfg.activeCodexAccountPinned = "pool-b"; + cfg.codexAccounts = [ + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + { id: "pool-c", email: "c@example.test", isMain: false, chatgptAccountId: "pool_acc_c" }, + ]; + for (const id of ["pool-a", "pool-b", "pool-c"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}-token`, + refreshToken: `${id}-refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}-chatgpt`, + }); + } + resetCodexRoutingForManualSelection("pool-b"); + const headers = new Headers({ "x-codex-parent-thread-id": "auth-model-detour-thread" }); + const primeCodexPoolQuotas = async () => {}; + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + modelId: "gpt-5.5", + primeCodexPoolQuotas, + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + + const entitlementSnapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-daybreak-blue-latest", "gpt-5.6-sol"])], + ["pool-c", new Set(["gpt-daybreak-blue-latest", "gpt-5.6-sol"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b", "pool-c"]), + credentialIdentities: new Map(), + }; + const gatedOptions = { + primeCodexPoolQuotas, + resolveCodexModelEntitlements: async () => entitlementSnapshot, + }; + const first = await resolveCodexAuthContext(headers, cfg, "pool", { + ...gatedOptions, + modelId: "gpt-daybreak-blue-latest", + }); + expect(first).toMatchObject({ kind: "pool" }); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + ...gatedOptions, + modelId: "gpt-daybreak-blue-latest", + })).resolves.toMatchObject({ kind: "pool", accountId: first.accountId }); + + const secondModel = await resolveCodexAuthContext(headers, cfg, "pool", { + ...gatedOptions, + modelId: "gpt-5.6-sol", + }); + expect(secondModel).toMatchObject({ kind: "pool" }); + expect(secondModel.accountId).not.toBe(first.accountId); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + ...gatedOptions, + modelId: "gpt-5.6-sol", + })).resolves.toMatchObject({ kind: "pool", accountId: secondModel.accountId }); + + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + modelId: "gpt-5.5", + primeCodexPoolQuotas, + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("exact account-gated routing fails closed for an unentitled account", async () => { const cfg = config(); saveCodexAccountCredential("pool-a", { diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index f5bfcc31d9..0c651daf2d 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -1822,6 +1822,266 @@ describe("codex account selection order", () => { expect(resolveCodexAccountForThread("model-gated-task", config, now + 2, "shared")).toBe("b"); }); + test("repeated model-gated round-robin requests reuse a separate detour affinity", () => { + const now = 1_800_000_000_000; + const threadId = "model-detour-affinity"; + const modelId = "gpt-daybreak-blue-latest"; + const config = makeConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + + const eligible = { modelEligibleAccountIds: new Set(["a", "c"]) }; + const firstPreview = previewCodexAccountForRequest( + threadId, + config, + now + 1, + "shared", + eligible, + modelId, + ); + const first = resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + eligible, + modelId, + ); + expect(first).toEqual({ status: "selected", accountId: firstPreview }); + expect(["a", "c"]).toContain(firstPreview); + + expect(previewCodexAccountForRequest( + threadId, + config, + now + 2, + "shared", + eligible, + modelId, + )).toBe(firstPreview); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 2, + "shared", + eligible, + modelId, + )).toEqual(first); + + expect(config.activeCodexAccountId).toBe("b"); + expect(config.activeCodexAccountPinned).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, now + 3, "shared")).toBe("b"); + + const other = firstPreview === "a" ? "c" : "a"; + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 4, + "shared", + { modelEligibleAccountIds: new Set([other]) }, + modelId, + )).toEqual({ status: "selected", accountId: other }); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 5, + "shared", + { modelEligibleAccountIds: new Set(["a", "b", "c"]) }, + modelId, + )).toEqual({ status: "selected", accountId: other }); + expect(resolveCodexAccountForThread(threadId, config, now + 6, "shared")).toBe("b"); + }); + + test("model preview and final keep a live detour after ordinary affinity cleanup", () => { + const now = 1_800_000_000_000; + const threadId = "detour-after-ordinary-cleanup"; + const modelId = "gpt-daybreak-blue-latest"; + const config = makeConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + const eligible = { modelEligibleAccountIds: new Set(["a", "c"]) }; + const first = resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + eligible, + modelId, + ); + expect(first.status).toBe("selected"); + + clearThreadAccountMapForAccount("b"); + if (first.status === "selected") { + expect(previewCodexAccountForRequest( + threadId, + config, + now + 2, + "shared", + eligible, + modelId, + )).toBe(first.accountId); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 2, + "shared", + eligible, + modelId, + )).toEqual(first); + } + expect(config.activeCodexAccountPinned).toBe("b"); + }); + + test("model detour affinities are independent within one quota scope", () => { + const now = 1_800_000_000_000; + const threadId = "independent-model-detours"; + const config = makeConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + const eligible = { modelEligibleAccountIds: new Set(["a", "c"]) }; + + const firstModel = resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + eligible, + "gpt-daybreak-blue-latest", + ); + const secondModel = resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 2, + "shared", + eligible, + "gpt-other-account-gated", + ); + expect(firstModel.status).toBe("selected"); + expect(secondModel.status).toBe("selected"); + if (firstModel.status === "selected" && secondModel.status === "selected") { + expect(secondModel.accountId).not.toBe(firstModel.accountId); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 3, + "shared", + eligible, + "gpt-daybreak-blue-latest", + )).toEqual(firstModel); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 4, + "shared", + eligible, + "gpt-other-account-gated", + )).toEqual(secondModel); + } + expect(resolveCodexAccountForThread(threadId, config, now + 5, "shared")).toBe("b"); + }); + + test("model detour LRU stays bounded without evicting ordinary affinity", () => { + const now = 1_800_000_000_000; + const threadId = "bounded-model-detours"; + const config = makeConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "b", + activeCodexAccountPinned: "b", + autoSwitchThreshold: 0, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + resetCodexRoutingForManualSelection("b"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("b"); + const eligible = { modelEligibleAccountIds: new Set(["a", "c"]) }; + + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + eligible, + "gated-model-0", + )).toEqual({ status: "selected", accountId: "a" }); + for (let index = 1; index <= CODEX_THREAD_AFFINITY_MAX_ENTRIES; index += 1) { + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + index + 1, + "shared", + eligible, + `gated-model-${index}`, + ).status).toBe("selected"); + } + + // Detours are the preferred LRU victims, so model churn cannot displace the + // task's ordinary account. The oldest detour was evicted; recreating it takes + // the next RR account and then becomes sticky again. + expect(resolveCodexAccountForThread( + threadId, + config, + now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 3, + "shared", + )).toBe("b"); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 4, + "shared", + eligible, + "gated-model-0", + )).toEqual({ status: "selected", accountId: "c" }); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 5, + "shared", + eligible, + "gated-model-0", + )).toEqual({ status: "selected", accountId: "c" }); + }, STORE_BUDGET_MS); + test("a gated first request binds its actual account without replacing global active", () => { const config = makeConfig({ activeCodexAccountId: "b" }); const now = Date.now(); @@ -1922,12 +2182,22 @@ describe("codex account selection order", () => { }); } + const selectionOptions = { modelEligibleAccountIds: new Set(["a", "b"]) }; + expect(previewCodexAccountForRequest( + null, + config, + now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4, + "shared", + selectionOptions, + "gpt-daybreak-blue-latest", + )).toBe("b"); + expect(resolveCodexAccountForThreadDetailed( null, config, now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4, "shared", - { modelEligibleAccountIds: new Set(["a", "b"]) }, + selectionOptions, )).toEqual({ status: "selected", accountId: "b" }); expect(config.activeCodexAccountId).toBe("c"); expect(config.activeCodexAccountPinned).toBe("c"); @@ -2020,12 +2290,22 @@ describe("codex account selection order", () => { updateAccountQuota("b", 90); resetCodexRoutingForManualSelection("b"); + const selectionOptions = { modelEligibleAccountIds: new Set(["a", "b"]) }; + expect(previewCodexAccountForRequest( + null, + config, + now, + "shared", + selectionOptions, + "gpt-daybreak-blue-latest", + )).toBe("a"); + expect(resolveCodexAccountForThreadDetailed( null, config, now, "shared", - { modelEligibleAccountIds: new Set(["a", "b"]) }, + selectionOptions, )).toEqual({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); @@ -2053,12 +2333,22 @@ describe("codex account selection order", () => { } const resolveAt = now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4; + const selectionOptions = { modelEligibleAccountIds: new Set(["a", "b"]) }; + expect(previewCodexAccountForRequest( + null, + config, + resolveAt, + "shared", + selectionOptions, + "gpt-daybreak-blue-latest", + )).toBe("a"); + expect(resolveCodexAccountForThreadDetailed( null, config, resolveAt, "shared", - { modelEligibleAccountIds: new Set(["a", "b"]) }, + selectionOptions, )).toEqual({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 3997bc8670..9212cbafe3 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -1551,7 +1551,9 @@ describe("native fallback account preview", () => { } // And both must actually forward it into the preview call, not merely accept it. - const forwarded = source.match(/\{ \.\.\.(previewSelectionOptions|recoverySelectionOptions), modelEligibleAccountIds \}/g) ?? []; + const forwarded = source.match( + /\{ \.\.\.(previewSelectionOptions|recoverySelectionOptions), modelEligibleAccountIds \},\s*modelId,\s*\)/g, + ) ?? []; expect(forwarded).toHaveLength(2); }); From ced264d1c91ee821f614d93013d3169cd535b3ea Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 21:20:41 +0900 Subject: [PATCH 016/132] fix(codex): address routing review follow-ups (cherry picked from commit 80d679463130473e0b82835bfc116ff36fffda06) --- src/codex/routing.ts | 106 +++++++++--------- .../bearer-admission-routed-provider.test.ts | 38 ++++--- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 100a7e0a9e..95e6ed06b7 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1631,6 +1631,45 @@ function previewReusableAffinityAccount( return entry.accountId; } +/** + * Re-evaluate an affined account under the quota strategy. Returns a strictly + * cooler replacement, or null when the current binding should remain. + */ +function reevaluateAffinityQuota( + entry: ThreadAffinityEntry, + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; + const threshold = config.autoSwitchThreshold ?? 80; + const usage = threshold > 0 + ? computeCodexUsageScore( + getAccountQuota(entry.accountId), + getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), + ) + : 0; + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if ( + !overThreshold + && now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + ) { + return null; + } + entry.lastReevalAt = now; + if (!overThreshold) return null; + const best = pickLowerUsageAccount( + config, + entry.accountId, + usage, + now, + quotaScope, + selectionOptions, + ); + return best === entry.accountId ? null : best; +} + /** * Side-effect-free preview of the Codex pool account native routing would prefer. * Used for subagent fallback quota decisions before final auth. @@ -1766,35 +1805,16 @@ export function resolveCodexAccountForThreadDetailed( // Model detours follow the same affinity policy as ordinary bindings: // RR/fill-first stay sticky, while quota strategy may re-evaluate an // over-threshold account without changing the ordinary lane. - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { - const threshold = config.autoSwitchThreshold ?? 80; - const usage = threshold > 0 - ? computeCodexUsageScore( - getAccountQuota(detourEntry.accountId), - getPoolAccountPlanForSelection(config, detourEntry.accountId, selectionOptions), - ) - : 0; - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; - if ( - overThreshold - || now - detourEntry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS - ) { - detourEntry.lastReevalAt = now; - if (overThreshold) { - const best = pickLowerUsageAccount( - config, - detourEntry.accountId, - usage, - now, - quotaScope, - selectionOptions, - ); - if (best !== detourEntry.accountId) { - bindModelDetourAffinity(threadId, best, now, modelId, quotaScope); - return { status: "selected", accountId: best }; - } - } - } + const cooler = reevaluateAffinityQuota( + detourEntry, + config, + now, + quotaScope, + selectionOptions, + ); + if (cooler) { + bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope); + return { status: "selected", accountId: cooler }; } return { status: "selected", accountId: detourEntry.accountId }; } @@ -1834,29 +1854,13 @@ export function resolveCodexAccountForThreadDetailed( // serving for up to 60s after a secondary with quota is available (#584). // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads — // rotation is new-session-only (affinity policy A). - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); - if (strategy === "quota") { - const threshold = config.autoSwitchThreshold ?? 80; - const usage = threshold > 0 - ? computeCodexUsageScore( - getAccountQuota(entry.accountId), - getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), - ) - : 0; - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; - if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) { - entry.lastReevalAt = now; - if (overThreshold) { - const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions); - if (best !== entry.accountId) { - if (!isIndependentCodexQuotaScope(quotaScope)) { - setActiveCodexAccount(config, best); - } - bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks - return { status: "selected", accountId: best }; - } - } + const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions); + if (cooler) { + if (!isIndependentCodexQuotaScope(quotaScope)) { + setActiveCodexAccount(config, cooler); } + bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks + return { status: "selected", accountId: cooler }; } return { status: "selected", accountId: entry.accountId }; } diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts index 82a8c75400..6b6b2af943 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -278,10 +278,11 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate ); const server = startServer(0, { inspectNativeCodexOwnership }); - await waitForNativeMainStartupGate(); - const drain = acquireNativeMainProfileDrain("custom-forward-substitution"); - expect(drain).not.toBeNull(); + let drain: ReturnType = null; try { + await waitForNativeMainStartupGate(); + drain = acquireNativeMainProfileDrain("custom-forward-substitution"); + expect(drain).not.toBeNull(); const response = await postResponses(server.url, "mirror/gpt-5.5"); expect(response.status).toBe(503); @@ -318,21 +319,22 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate }) as typeof fetch; const server = startServer(0, { inspectNativeCodexOwnership }); - await waitForNativeMainStartupGate(); - const pending = postResponses(server.url, "mirror/gpt-5.5"); - const switchUrl = new URL("http://localhost/api/native-main-profiles/switch"); - const switchRequest = () => new Request(switchUrl, { - method: "POST", - body: JSON.stringify({ target: "target", confirmedStopped: true }), - }); - let switches = 0; - const manager = { - switch: async () => { - switches += 1; - return { ok: true }; - }, - } as unknown as NativeProfileManager; + let pending: Promise | null = null; try { + await waitForNativeMainStartupGate(); + pending = postResponses(server.url, "mirror/gpt-5.5"); + const switchUrl = new URL("http://localhost/api/native-main-profiles/switch"); + const switchRequest = () => new Request(switchUrl, { + method: "POST", + body: JSON.stringify({ target: "target", confirmedStopped: true }), + }); + let switches = 0; + const manager = { + switch: async () => { + switches += 1; + return { ok: true }; + }, + } as unknown as NativeProfileManager; await upstreamStarted; expect(getNativeMainProfileRequestCount()).toBe(1); const blocked = await handleNativeProfileAPI( @@ -359,7 +361,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate expect(switches).toBe(1); } finally { releaseUpstream(); - await pending.catch(() => {}); + await pending?.catch(() => {}); await server.stop(true); } }); From 071787ad9395080c03cff52922d3389f6f48d9b9 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 23:18:53 +0900 Subject: [PATCH 017/132] fix(grok): preserve retired orphan model tables (cherry picked from commit 0df51fd5af2d67720cbd2d0a6576fb01b1a731ab) --- src/grok/inject.ts | 6 +++++- tests/grok-orphan-adoption.test.ts | 34 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index fee7e60180..4636470f47 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -395,7 +395,11 @@ export function injectGrokConfig( // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale // duplicate is replaced instead of routed around forever. Runs inside the normalized // window so the user's dominant EOL is still restored below. - const orphans = findOpencodexOrphans(originalContent, originalRegion); + // Use the full catalog, not the emitted subset: an explicitly excluded current model must + // still lose its stale unfenced table, or that table would bypass the user's exclusion. + const catalogModelIds = new Set(models.map(model => model.id)); + const orphans = findOpencodexOrphans(originalContent, originalRegion) + .filter(orphan => orphan.modelId !== undefined && catalogModelIds.has(orphan.modelId)); const content = removeOrphanTables(originalContent, orphans); // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic, // so the splice below cannot cut the file in the wrong place. diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index aba8af6e27..e05719ba2e 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -150,6 +150,9 @@ describe("Grok orphan adoption (#511)", () => { "[models]", 'default = "ocx-retired"', "", + "[ui]", + 'fork_secondary_model = "ocx-retired"', + "", "[model.ocx-retired]", 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', @@ -160,6 +163,37 @@ describe("Grok orphan adoption (#511)", () => { injectGrokConfig(10100, MODELS, { grokHome }); const content = readFileSync(configPath, "utf8"); expect(content).toContain('default = "ocx-retired"'); + expect(content).toContain('fork_secondary_model = "ocx-retired"'); + expect(content).toContain("[model.ocx-retired]"); + expect(content).toContain('model = "retired/model"'); + + const second = injectGrokConfig(10100, MODELS, { grokHome }); + expect(second).toMatchObject({ ok: true, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(content); + }); + + test("keeps an owned-looking orphan whose model id is missing", () => { + writeFileSync(configPath, [ + "[model.ocx-unknown]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("[model.ocx-unknown]"); + expect(content).toContain('api_key = "opencodex-loopback"'); + }); + + test("still removes a catalog orphan when that model is excluded", () => { + writeOrphanedConfig(); + + injectGrokConfig(10100, MODELS, { + grokHome, + excluded: new Set(["gpt-5.6-sol"]), + }); + expect(modelTables(readFileSync(configPath, "utf8"))).toEqual([]); }); // F7: the sweep must converge, or `changed` is meaningless to callers. From c8534f631ee2c3d3275a545f4924f68e4cf61874 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 00:12:24 +0900 Subject: [PATCH 018/132] fix(grok): close orphan lifecycle review gaps (cherry picked from commit 9a9bace3278e6ac801564860dc23a3fd0ce04e05) --- src/grok/inject.ts | 79 +++++++++---- src/grok/sync.ts | 11 +- .../management/native-integration-routes.ts | 13 ++- tests/grok-orphan-adoption.test.ts | 110 +++++++++++++++++- tests/grok-sync.test.ts | 62 +++++++++- tests/native-grok-toggle.test.ts | 23 +++- 6 files changed, 261 insertions(+), 37 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 4636470f47..e19192c583 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -144,9 +144,11 @@ function tableBodyKeys(body: string): Map { const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/.exec(line); if (!match) continue; const raw = match[2]!; - const value = raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2 + const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? decodeTomlBasicString(raw.slice(1, -1)) - : raw; + : raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'") + ? raw.slice(1, -1) // TOML literal strings do not process escapes. + : raw; if (!keys.has(match[1]!)) keys.set(match[1]!, value); } return keys; @@ -344,7 +346,13 @@ export function buildGrokManagedBlock( export function injectGrokConfig( port: number, models: GrokInjectModel[], - opts: { grokHome?: string; hostname?: string; excluded?: ReadonlySet } = {}, + opts: { + grokHome?: string; + hostname?: string; + excluded?: ReadonlySet; + /** Unfiltered known ids used only to distinguish hidden current models from retired ones. */ + catalogModelIds?: ReadonlySet; + } = {}, ): GrokInjectResult { const grokHome = resolveGrokHome(opts.grokHome); if (!isDirectory(grokHome)) { @@ -395,9 +403,11 @@ export function injectGrokConfig( // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale // duplicate is replaced instead of routed around forever. Runs inside the normalized // window so the user's dominant EOL is still restored below. - // Use the full catalog, not the emitted subset: an explicitly excluded current model must - // still lose its stale unfenced table, or that table would bypass the user's exclusion. - const catalogModelIds = new Set(models.map(model => model.id)); + // Use the full UNFILTERED catalog, not the emitted subset: explicitly excluded and otherwise + // hidden current models must still lose stale unfenced tables, or those tables would bypass + // the user's visibility choice. Direct callers that do not have a separate catalog keep the + // historical `models` behavior. + const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id)); const orphans = findOpencodexOrphans(originalContent, originalRegion) .filter(orphan => orphan.modelId !== undefined && catalogModelIds.has(orphan.modelId)); const content = removeOrphanTables(originalContent, orphans); @@ -481,29 +491,52 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes const rawContent = readFileSync(configPath, "utf8"); const eol = dominantEol(rawContent); const content = applyEol(rawContent, "\n"); - const region = findManagedRegion(content); - if (!region) { - return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." }; + const originalRegion = findManagedRegion(content); + if (originalRegion?.orphaned) return orphanedMarkerResult("cleanup"); + + // Remove the fence against its ORIGINAL offsets first. A pre-fence orphan's span is clamped + // at the fence start and can include the separator newline injection added. Sweeping that + // orphan first and then applying this separator undo would remove one additional USER newline. + let stripped: string; + let orphanCount = 0; + if (originalRegion) { + let removalEnd = originalRegion.end; + if (content.startsWith("\n", removalEnd)) removalEnd += 1; + let prefix = content.slice(0, originalRegion.start); + const restOfFile = content.slice(removalEnd); + // Undo the single separator newline injection added. Two cases, mirroring inject: + // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one. + // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it. + // A block the user has appended content after is left alone: we never shrink their bytes. + if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); + else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); + // Keep the old fence boundary while sweeping. Concatenating first would let the last + // pre-fence orphan absorb comment-only or bare-key user content appended after the fence. + const prefixOrphans = findOpencodexOrphans(prefix, null); + const tailOrphans = findOpencodexOrphans(restOfFile, null); + orphanCount = prefixOrphans.length + tailOrphans.length; + stripped = removeOrphanTables(prefix, prefixOrphans) + + removeOrphanTables(restOfFile, tailOrphans); + } else { + // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the + // fence while the integration is enabled. Teardown owns those strictly identified tables + // even after Grok has re-serialized the file and dropped our marker comments. + const orphans = findOpencodexOrphans(content, null); + if (orphans.length === 0) { + return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." }; + } + orphanCount = orphans.length; + stripped = removeOrphanTables(content, orphans); } - if (region.orphaned) return orphanedMarkerResult("cleanup"); - - let removalEnd = region.end; - if (content.startsWith("\n", removalEnd)) removalEnd += 1; - let prefix = content.slice(0, region.start); - const restOfFile = content.slice(removalEnd); - // Undo the single separator newline injection added. Two cases, mirroring inject: - // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one. - // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it. - // A block the user has appended content after is left alone: we never shrink their bytes. - if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); - else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); - const stripped = prefix + restOfFile; + if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex")); atomicWriteFile(configPath, applyEol(stripped, eol)); return { ok: true, changed: true, - message: "Removed the opencodex managed block from Grok config.", + message: originalRegion + ? "Removed the opencodex managed block from Grok config." + : "Removed stale opencodex-managed model entries from Grok config.", }; } catch (error) { return errorResult("strip", error); diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 6e24b528fb..3d2957f4f4 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -6,7 +6,7 @@ * * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy. */ -import { visibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog"; +import { visibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, nativeOpenAiSlugs, type CatalogModel } from "../codex/catalog"; import type { OcxConfig } from "../types"; import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject"; @@ -33,8 +33,14 @@ export async function syncGrokConfig( deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig }, ): Promise { let models: GrokInjectModel[]; + let catalogModelIds: Set; try { - const routed = filterCatalogVisibleModels(await deps.fetchAllModels(config), config); + const allRouted = await deps.fetchAllModels(config); + const routed = filterCatalogVisibleModels(allRouted, config); + catalogModelIds = new Set([ + ...nativeOpenAiSlugs(), + ...allRouted.map(model => model.alias ?? `${model.provider}/${model.id}`), + ]); models = [ // Native slugs carry their context window too. Without it Grok falls back to its own // default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same @@ -62,5 +68,6 @@ export async function syncGrokConfig( ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}), ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}), excluded: new Set(config.grokExcludedModels ?? []), + catalogModelIds, }); } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 06c4bbfb3b..357971a525 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -19,7 +19,7 @@ */ import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; -import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog"; +import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, nativeOpenAiSlugs, visibleNativeSlugs } from "../../codex/catalog"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; @@ -503,8 +503,14 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { */ const fetchModels = deps.fetchAllModels ?? defaultFetchAllModels; let models: GrokInjectModel[]; + let catalogModelIds: Set; try { - const routed = filterCatalogVisibleModels(await fetchModels(config), config); + const allRouted = await fetchModels(config); + const routed = filterCatalogVisibleModels(allRouted, config); + catalogModelIds = new Set([ + ...nativeOpenAiSlugs(), + ...allRouted.map(model => model.alias ?? `${model.provider}/${model.id}`), + ]); models = [ // Native slugs carry their context window: without it Grok falls back // to its own 200k default and understates a 372k model. @@ -535,6 +541,9 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { // writer allocates aliases over everything, so a model's alias never // depends on its neighbours' switches. excluded: new Set(config.grokExcludedModels ?? []), + // Visibility filters decide what to emit, not whether an owned pre-fence table is still + // current. Otherwise a hidden model is mistaken for retired state and survives outside. + catalogModelIds, }); if (result.skippedReason === "non-loopback") { diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index e05719ba2e..b7d7876a5b 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { injectGrokConfig } from "../src/grok/inject"; +import { injectGrokConfig, stripGrokConfig } from "../src/grok/inject"; /** * #511 — Grok Build reported 200k for every model. @@ -180,12 +180,118 @@ describe("Grok orphan adoption (#511)", () => { "", ].join("\n")); - injectGrokConfig(10100, MODELS, { grokHome }); + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); const content = readFileSync(configPath, "utf8"); expect(content).toContain("[model.ocx-unknown]"); expect(content).toContain('api_key = "opencodex-loopback"'); }); + test("removes a hidden current orphan but preserves a genuinely retired one", () => { + writeFileSync(configPath, [ + "[model.ocx-hidden]", + 'model = "hidden/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { + grokHome, + catalogModelIds: new Set(["gpt-5.6-sol", "hidden/model"]), + }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + expect(content).not.toContain("[model.ocx-hidden]"); + expect(content).not.toContain('model = "hidden/model"'); + expect(content).toContain("[model.ocx-retired]"); + expect(content).toContain('model = "retired/model"'); + }); + + test("adopts a current orphan whose TOML model id uses literal quotes", () => { + writeOrphanedConfig(); + writeFileSync( + configPath, + readFileSync(configPath, "utf8").replace('model = "gpt-5.6-sol"', "model = 'gpt-5.6-sol'"), + ); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + expect(modelTables(content)).toEqual(["ocx-gpt-5-6-sol"]); + expect(content).not.toContain("model = 'gpt-5.6-sol'"); + expect(content).toContain('model = "gpt-5.6-sol"'); + }); + + test("teardown removes a preserved retired orphan and restores user bytes exactly", () => { + for (const eol of ["\n", "\r\n"]) { + const userPrefix = [`theme = "${eol === "\n" ? "lf" : "crlf"}"`, "", ""].join(eol); + const orphan = [ + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join(eol); + writeFileSync(configPath, userPrefix + orphan); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toContain("[model.ocx-retired]"); + + const stripped = stripGrokConfig({ grokHome }); + expect(stripped).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(userPrefix); + } + }); + + test("teardown preserves a comment-only tail beyond the fence byte-for-byte", () => { + for (const eol of ["\n", "\r\n"]) { + const userPrefix = [`theme = "${eol === "\n" ? "lf" : "crlf"}"`, "", ""].join(eol); + const orphan = [ + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join(eol); + const tail = ["# keep this post-fence note", "bare_user_key = true", ""].join(eol); + writeFileSync(configPath, userPrefix + orphan); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + writeFileSync(configPath, readFileSync(configPath, "utf8") + tail); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(userPrefix + tail); + } + }); + + test("markerless teardown removes only ownership-proven orphan tables", () => { + const owned = [ + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n"); + const userOwned = [ + "[model.user-owned]", + 'model = "user/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "not-ours"', + "", + ].join("\n"); + writeFileSync(configPath, owned + userOwned); + + const stripped = stripGrokConfig({ grokHome }); + expect(stripped).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(userOwned); + }); + test("still removes a catalog orphan when that model is excluded", () => { writeOrphanedConfig(); diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index 0ed57bcfb9..177fc15f0c 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { injectGrokConfig } from "../src/grok/inject"; +import { injectGrokConfig, type GrokInjectModel } from "../src/grok/inject"; import { syncGrokConfig } from "../src/grok/sync"; -import { nativeOpenAiContextWindow, visibleNativeSlugs } from "../src/codex/catalog"; +import { nativeOpenAiContextWindow, nativeOpenAiSlugs, visibleNativeSlugs } from "../src/codex/catalog"; import type { CatalogModel } from "../src/codex/catalog"; import { resetCodexModelEntitlementCacheForTests, @@ -46,6 +46,62 @@ describe("syncGrokConfig", () => { } }); + test("classifies provider-hidden models from the unfiltered catalog without emitting them", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const config = { + ...baseConfig, + providers: { stub: { selectedModels: ["visible"] } }, + } as unknown as OcxConfig; + const catalog = [ + { id: "visible", provider: "stub" } as CatalogModel, + { id: "hidden", provider: "stub" } as CatalogModel, + ]; + writeFileSync(join(grokHome, "config.toml"), [ + "[model.ocx-stub-hidden]", + 'model = "stub/hidden"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + + const result = await syncGrokConfig(10190, config, { grokHome }, { + fetchAllModels: async () => catalog, + injectGrokConfig, + }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + expect(content).toContain('model = "stub/visible"'); + expect(content).not.toContain("[model.ocx-stub-hidden]"); + expect(content).not.toContain('model = "stub/hidden"'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("keeps disabled native ids in the orphan-classification catalog", async () => { + const hiddenNative = nativeOpenAiSlugs()[0]!; + let emitted: GrokInjectModel[] | undefined; + let catalogModelIds: ReadonlySet | undefined; + const result = await syncGrokConfig( + 10190, + { ...baseConfig, disabledModels: [hiddenNative] } as OcxConfig, + {}, + { + fetchAllModels: async () => [], + injectGrokConfig: ((port: number, models: GrokInjectModel[], opts: Parameters[2]) => { + void port; + emitted = models; + catalogModelIds = opts.catalogModelIds; + return { ok: true, changed: false, message: "captured" }; + }) as typeof injectGrokConfig, + }, + ); + expect(result.ok).toBe(true); + expect(emitted?.some(model => model.id === hiddenNative)).toBe(false); + expect(catalogModelIds?.has(hiddenNative)).toBe(true); + }); + // Native slugs used to be injected as a bare { id }, so no `context_window` line was written // and Grok fell back to its own 200k default — understating gpt-5.6-sol, which is 372k. The // window comes from the same accessor the dashboard uses, so the two surfaces agree. diff --git a/tests/native-grok-toggle.test.ts b/tests/native-grok-toggle.test.ts index 2a405e9e35..5fa24382f9 100644 --- a/tests/native-grok-toggle.test.ts +++ b/tests/native-grok-toggle.test.ts @@ -346,18 +346,24 @@ test("the route never calls syncGrokConfig, and the inspector never re-implement test("the route's model list is byte-identical to syncGrokConfig's", async () => { writeConfig("# user only\n"); - const config = baseConfig({ grokExcludedModels: ["stub/m2"] }); + const config = baseConfig({ + disabledModels: ["stub/m2"], + grokExcludedModels: ["stub/m3"], + }); const catalog = [ { provider: "stub", id: "m1", alias: "fast", contextWindow: 64000 }, { provider: "stub", id: "m2" }, + { provider: "stub", id: "m3" }, ]; let routeModels: GrokInjectModel[] | null = null; let routeExcluded: ReadonlySet | null = null; + let routeCatalogModelIds: ReadonlySet | null = null; const routeDeps = testDeps({ fetchAllModels: (async () => catalog) as never, injectGrokConfig: ((port: number, models: GrokInjectModel[], opts: Parameters[2]) => { routeModels = models; routeExcluded = opts?.excluded ?? null; + routeCatalogModelIds = opts?.catalogModelIds ?? null; return injectGrokConfig(port, models, opts); }) as typeof injectGrokConfig, }); @@ -366,29 +372,36 @@ test("the route's model list is byte-identical to syncGrokConfig's", async () => let syncModels: GrokInjectModel[] | null = null; let syncExcluded: ReadonlySet | null = null; + let syncCatalogModelIds: ReadonlySet | null = null; await syncGrokConfig(10100, config, { hostname: "127.0.0.1" }, { fetchAllModels: (async () => catalog) as never, injectGrokConfig: ((port: number, models: GrokInjectModel[], opts: Parameters[2]) => { syncModels = models; syncExcluded = opts?.excluded ?? null; + syncCatalogModelIds = opts?.catalogModelIds ?? null; return injectGrokConfig(port, models, opts); }) as typeof injectGrokConfig, }); expect(JSON.stringify(routeModels)).toBe(JSON.stringify(syncModels)); + expect(routeCatalogModelIds && [...routeCatalogModelIds].sort()) + .toEqual(syncCatalogModelIds && [...syncCatalogModelIds].sort()); + expect(routeCatalogModelIds?.has("stub/m2")).toBe(true); /* * The exclusion half of the clause (C-gate blocker): the FULL list goes to * the writer together with the exclusion SET, never a pre-filtered list — * dropping `excluded` here would leave the models arrays identical while * excluded models silently leaked into the fence. */ - expect(routeExcluded && [...routeExcluded].sort()).toEqual(["stub/m2"]); - expect(syncExcluded && [...syncExcluded].sort()).toEqual(["stub/m2"]); - // And the exclusion actually reached the fence both times: each write went - // through the real writer into the fixture file, and m2 appears in neither. + expect(routeExcluded && [...routeExcluded].sort()).toEqual(["stub/m3"]); + expect(syncExcluded && [...syncExcluded].sort()).toEqual(["stub/m3"]); + // Visibility and Grok-specific exclusion both reached the fence, while the hidden model + // stayed in the separate classification catalog for stale-orphan cleanup. const fence = readConfig(); expect(fence).toContain('model = "fast"'); expect(fence).not.toContain("stub/m2"); expect(fence).not.toContain("ocx-stub-m2"); + expect(fence).not.toContain("stub/m3"); + expect(fence).not.toContain("ocx-stub-m3"); }); test("a late orphan surfaced by the WRITER still maps to 409, never to absent", () => { From 78b536626f24f31563bf605b8e5eddca0981c0f0 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 00:57:38 +0900 Subject: [PATCH 019/132] fix(grok): harden orphan ownership classification (cherry picked from commit ccba4967e9a292bd2af284e5c5700a0824408ec5) --- src/grok/catalog.ts | 55 +++++++ src/grok/inject.ts | 111 +++++++++++--- src/grok/sync.ts | 33 ++--- .../management/native-integration-routes.ts | 32 ++-- tests/grok-orphan-adoption.test.ts | 137 +++++++++++++++++- tests/grok-sync.test.ts | 79 ++++++++++ tests/native-grok-toggle.test.ts | 28 ++++ 7 files changed, 406 insertions(+), 69 deletions(-) create mode 100644 src/grok/catalog.ts diff --git a/src/grok/catalog.ts b/src/grok/catalog.ts new file mode 100644 index 0000000000..2593a18374 --- /dev/null +++ b/src/grok/catalog.ts @@ -0,0 +1,55 @@ +import { comboPublicModelId } from "../combos"; +import { + filterCatalogVisibleModels, + nativeContextLimits, + nativeOpenAiContextWindow, + nativeOpenAiSlugs, + visibleNativeSlugs, + type CatalogModel, +} from "../codex/catalog"; +import type { OcxConfig } from "../types"; +import type { GrokInjectModel } from "./inject"; + +export interface GrokCatalogProjection { + models: GrokInjectModel[]; + catalogModelIds: ReadonlySet; + disabledProviderNamespaces: ReadonlySet; + comboPublicModelIds: ReadonlySet; +} + +/** + * Project one fetched catalog into both emitted Grok rows and orphan-classification evidence. + * Keeping this shared prevents `ocx start` and the management toggle from disagreeing. + */ +export function projectGrokCatalog( + allRouted: CatalogModel[], + config: OcxConfig, +): GrokCatalogProjection { + const routed = filterCatalogVisibleModels(allRouted, config); + const limits = nativeContextLimits(config); + return { + catalogModelIds: new Set([ + ...nativeOpenAiSlugs(), + ...allRouted.map(model => model.alias ?? `${model.provider}/${model.id}`), + ]), + disabledProviderNamespaces: new Set( + Object.entries(config.providers) + .filter(([, provider]) => provider?.disabled === true) + .map(([name]) => name), + ), + comboPublicModelIds: new Set( + Object.entries(config.combos ?? {}) + .map(([id, combo]) => comboPublicModelId(id, combo)), + ), + models: [ + ...visibleNativeSlugs(config).map(id => { + const contextWindow = nativeOpenAiContextWindow(id, limits); + return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) }; + }), + ...routed.map(model => ({ + id: model.alias ?? `${model.provider}/${model.id}`, + ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), + })), + ], + }; +} diff --git a/src/grok/inject.ts b/src/grok/inject.ts index e19192c583..dc407d8109 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -121,17 +121,17 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set]` table outside the fence that opencodex itself wrote. */ interface OrphanTable { alias: string; /** The model id this entry routes to — used to find its replacement alias. */ - modelId: string | undefined; + modelId: string; + /** Explicit markers authorize teardown; legacy fingerprints authorize replacement only. */ + ownership: "explicit" | "legacy"; /** Offsets into the NORMALIZED content: header start .. next header start (or EOF). */ start: number; end: number; @@ -164,6 +164,44 @@ function isLoopbackBaseUrl(value: string | undefined): boolean { } } +/** Exact marker emitted inside every modern generated model table. */ +function hasInlineOwnershipMarker(value: string | undefined): boolean { + return value !== undefined + && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*["']1["'][ \t]*\}$/.test(value); +} + +/** Historical deterministic alias, including collision suffixes allocated by the writer. */ +function isGeneratedAliasForModel(alias: string, modelId: string): boolean { + const base = `ocx-${modelId.replace(/[^A-Za-z0-9_-]/g, "-")}`; + if (alias === base) return true; + if (!alias.startsWith(`${base}-`)) return false; + const suffix = alias.slice(base.length + 1); + return /^[1-9][0-9]*$/.test(suffix) && Number(suffix) >= 2; +} + +/** Pre-marker auto-generated row shape. Manual rows never carried the generated name. */ +function isLegacyGeneratedTable(alias: string, keys: ReadonlyMap): boolean { + const modelId = keys.get("model"); + return modelId !== undefined + && modelId.length > 0 + && keys.get("api_backend") === "chat_completions" + && keys.get("name") === `OCX ${modelId}` + && isGeneratedAliasForModel(alias, modelId); +} + +/** Classify a direct provider/model id without stealing a slash-shaped configured combo alias. */ +function isDisabledProviderModelId( + modelId: string, + disabledProviderNamespaces: ReadonlySet | undefined, + comboPublicModelIds: ReadonlySet | undefined, +): boolean { + if (!disabledProviderNamespaces || comboPublicModelIds?.has(modelId)) return false; + const slash = modelId.indexOf("/"); + return slash > 0 + && slash < modelId.length - 1 + && disabledProviderNamespaces.has(modelId.slice(0, slash)); +} + /** * Model tables OUTSIDE the fence that opencodex itself wrote (#511). * @@ -174,12 +212,16 @@ function isLoopbackBaseUrl(value: string | undefined): boolean { * resolves the original, finds no `context_window`, and falls back to its own 200k. * * Ownership is CONJUNCTIVE and deliberately strict, because a false positive deletes a - * hand-written user model: + * hand-written user model. The public manual recipe intentionally uses the same loopback key, + * endpoint, and Responses backend, so those fields are not ownership proof. We additionally + * require either the durable generated marker or the exact pre-marker legacy fingerprint: * - a plain `[model.x]` header (never `[[model.x]]` / `[model.x.sub]` — those spellings * mark human authorship and stay reserved); * - `api_key` equal to our own literal; * - a loopback `base_url`, so an entry that merely copied our key while pointing at a * remote host is left alone. + * - `x-opencodex-grok = "1"` in generated inline/child extra_headers, OR the historical + * chat_completions + `name = "OCX "` + deterministic generated alias shape. * A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a * legitimate thing to do. */ @@ -213,6 +255,9 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd)); if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; + const modelId = keys.get("model"); + if (!modelId) continue; + let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers")); // Swallow the entry's OWN sub-tables (`[model..extra_headers]`). Grok writes // them when it re-serializes the file, and leaving one behind keeps the alias // reserved by `userModelAliases` — so the sweep would remove the parent and STILL @@ -226,9 +271,22 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or if (fenceStart >= 0 && header.index < fenceStart && child.index >= fenceStart) break; if (child.segments.length <= 2) break; if (child.segments[0] !== "model" || child.segments[1] !== header.segments[1]) break; - end = clampEnd(header.index, headers[next + 1]?.index ?? content.length); + const childEnd = clampEnd(header.index, headers[next + 1]?.index ?? content.length); + if (!child.array && child.segments.length === 3 && child.segments[2] === "extra_headers") { + const childKeys = tableBodyKeys(content.slice(child.index + child.length, childEnd)); + if (childKeys.get(OPENCODEX_GROK_MARKER) === "1") hasOwnershipMarker = true; + } + end = childEnd; } - orphans.push({ alias: header.segments[1]!, modelId: keys.get("model"), start: header.index, end }); + const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys); + if (!hasOwnershipMarker && !legacyGenerated) continue; + orphans.push({ + alias: header.segments[1]!, + modelId, + ownership: hasOwnershipMarker ? "explicit" : "legacy", + start: header.index, + end, + }); } return orphans; } @@ -352,6 +410,10 @@ export function injectGrokConfig( excluded?: ReadonlySet; /** Unfiltered known ids used only to distinguish hidden current models from retired ones. */ catalogModelIds?: ReadonlySet; + /** Canonical provider keys disabled in config and therefore absent from catalog fetching. */ + disabledProviderNamespaces?: ReadonlySet; + /** Configured combo public ids that may syntactically resemble provider/model ids. */ + comboPublicModelIds?: ReadonlySet; } = {}, ): GrokInjectResult { const grokHome = resolveGrokHome(opts.grokHome); @@ -403,13 +465,25 @@ export function injectGrokConfig( // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale // duplicate is replaced instead of routed around forever. Runs inside the normalized // window so the user's dominant EOL is still restored below. - // Use the full UNFILTERED catalog, not the emitted subset: explicitly excluded and otherwise - // hidden current models must still lose stale unfenced tables, or those tables would bypass - // the user's visibility choice. Direct callers that do not have a separate catalog keep the - // historical `models` behavior. + // Durably marked rows use the full UNFILTERED catalog: explicitly excluded and otherwise + // hidden current models must still lose stale generated tables. Ambiguous pre-marker legacy + // rows are migrated only when this write emits their replacement. Direct callers that do not + // have a separate catalog keep the historical `models` behavior. const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id)); + const emittedModelIds = new Set(models + .filter(model => !opts.excluded?.has(model.id)) + .map(model => model.id)); const orphans = findOpencodexOrphans(originalContent, originalRegion) - .filter(orphan => orphan.modelId !== undefined && catalogModelIds.has(orphan.modelId)); + .filter(orphan => orphan.ownership === "legacy" + // A legacy fingerprint is not durable deletion authority. Migrate it only when this + // same write will replace the row with a marked managed table. + ? emittedModelIds.has(orphan.modelId) + : catalogModelIds.has(orphan.modelId) + || isDisabledProviderModelId( + orphan.modelId, + opts.disabledProviderNamespaces, + opts.comboPublicModelIds, + )); const content = removeOrphanTables(originalContent, orphans); // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic, // so the splice below cannot cut the file in the wrong place. @@ -443,7 +517,7 @@ export function injectGrokConfig( } const renames = new Map(); for (const orphan of orphans) { - const replacement = orphan.modelId === undefined ? undefined : survivors.get(orphan.modelId); + const replacement = survivors.get(orphan.modelId); if (replacement && replacement !== orphan.alias) renames.set(orphan.alias, replacement); } nextContent = rewriteAliasReferences(nextContent, renames); @@ -512,8 +586,10 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); // Keep the old fence boundary while sweeping. Concatenating first would let the last // pre-fence orphan absorb comment-only or bare-key user content appended after the fence. - const prefixOrphans = findOpencodexOrphans(prefix, null); - const tailOrphans = findOpencodexOrphans(restOfFile, null); + const prefixOrphans = findOpencodexOrphans(prefix, null) + .filter(orphan => orphan.ownership === "explicit"); + const tailOrphans = findOpencodexOrphans(restOfFile, null) + .filter(orphan => orphan.ownership === "explicit"); orphanCount = prefixOrphans.length + tailOrphans.length; stripped = removeOrphanTables(prefix, prefixOrphans) + removeOrphanTables(restOfFile, tailOrphans); @@ -521,7 +597,8 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the // fence while the integration is enabled. Teardown owns those strictly identified tables // even after Grok has re-serialized the file and dropped our marker comments. - const orphans = findOpencodexOrphans(content, null); + const orphans = findOpencodexOrphans(content, null) + .filter(orphan => orphan.ownership === "explicit"); if (orphans.length === 0) { return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." }; } diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 3d2957f4f4..561df07dbf 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -6,9 +6,10 @@ * * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy. */ -import { visibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, nativeOpenAiSlugs, type CatalogModel } from "../codex/catalog"; +import type { CatalogModel } from "../codex/catalog"; import type { OcxConfig } from "../types"; -import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject"; +import { projectGrokCatalog } from "./catalog"; +import { injectGrokConfig, type GrokInjectResult } from "./inject"; export interface GrokSyncDeps { fetchAllModels: (config: OcxConfig) => Promise; @@ -32,28 +33,10 @@ export async function syncGrokConfig( opts: { hostname?: string; grokHome?: string } = {}, deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig }, ): Promise { - let models: GrokInjectModel[]; - let catalogModelIds: Set; + let projection: ReturnType; try { const allRouted = await deps.fetchAllModels(config); - const routed = filterCatalogVisibleModels(allRouted, config); - catalogModelIds = new Set([ - ...nativeOpenAiSlugs(), - ...allRouted.map(model => model.alias ?? `${model.provider}/${model.id}`), - ]); - models = [ - // Native slugs carry their context window too. Without it Grok falls back to its own - // default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same - // accessor the dashboard's native rows use, so the two cannot disagree. - ...visibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); - return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) }; - }), - ...routed.map(m => ({ - id: m.alias ?? `${m.provider}/${m.id}`, - ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), - })), - ]; + projection = projectGrokCatalog(allRouted, config); } catch (err) { return { ok: false, @@ -64,10 +47,12 @@ export async function syncGrokConfig( // Pass the FULL list plus the exclusion set: the writer allocates aliases over // everything and emits only what is switched on, so a model's alias never depends on // its neighbours' switches. Absent/empty selection keeps today's behaviour exactly. - return deps.injectGrokConfig(port, models, { + return deps.injectGrokConfig(port, projection.models, { ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}), ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}), excluded: new Set(config.grokExcludedModels ?? []), - catalogModelIds, + catalogModelIds: projection.catalogModelIds, + disabledProviderNamespaces: projection.disabledProviderNamespaces, + comboPublicModelIds: projection.comboPublicModelIds, }); } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 357971a525..af56e8866c 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -19,11 +19,12 @@ */ import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; -import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, nativeOpenAiSlugs, visibleNativeSlugs } from "../../codex/catalog"; +import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; -import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject"; +import { projectGrokCatalog } from "../../grok/catalog"; +import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; @@ -502,27 +503,10 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { * synchronous from entry (012 §One preflight is not enough). */ const fetchModels = deps.fetchAllModels ?? defaultFetchAllModels; - let models: GrokInjectModel[]; - let catalogModelIds: Set; + let projection: ReturnType; try { const allRouted = await fetchModels(config); - const routed = filterCatalogVisibleModels(allRouted, config); - catalogModelIds = new Set([ - ...nativeOpenAiSlugs(), - ...allRouted.map(model => model.alias ?? `${model.provider}/${model.id}`), - ]); - models = [ - // Native slugs carry their context window: without it Grok falls back - // to its own 200k default and understates a 372k model. - ...visibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); - return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) }; - }), - ...routed.map(m => ({ - id: m.alias ?? `${m.provider}/${m.id}`, - ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), - })), - ]; + projection = projectGrokCatalog(allRouted, config); } catch (error) { // A catalog failure must never write an empty fence (syncGrokConfig // guards this; the route inherits the rule). Nothing was written. @@ -535,7 +519,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { if (recheck.kind === "orphaned_marker") return postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled }); const inject = deps.injectGrokConfig ?? injectGrokConfig; - const result = inject(port, models, { + const result = inject(port, projection.models, { ...(hostname !== undefined ? { hostname } : {}), // The FULL list plus the exclusion set, never a pre-filtered list: the // writer allocates aliases over everything, so a model's alias never @@ -543,7 +527,9 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { excluded: new Set(config.grokExcludedModels ?? []), // Visibility filters decide what to emit, not whether an owned pre-fence table is still // current. Otherwise a hidden model is mistaken for retired state and survives outside. - catalogModelIds, + catalogModelIds: projection.catalogModelIds, + disabledProviderNamespaces: projection.disabledProviderNamespaces, + comboPublicModelIds: projection.comboPublicModelIds, }); if (result.skippedReason === "non-loopback") { diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index b7d7876a5b..635bd5e1b5 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -19,6 +19,7 @@ import { injectGrokConfig, stripGrokConfig } from "../src/grok/inject"; const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; const MODELS = [{ id: "gpt-5.6-sol", contextWindow: 372_000 }]; +const OWNERSHIP_MARKER = 'extra_headers = { "x-opencodex-grok" = "1" }'; describe("Grok orphan adoption (#511)", () => { let root: string; @@ -45,7 +46,7 @@ describe("Grok orphan adoption (#511)", () => { "[model.ocx-gpt-5-6-sol]", 'model = "gpt-5.6-sol"', 'base_url = "http://127.0.0.1:10100/v1"', - 'api_backend = "responses"', + 'api_backend = "chat_completions"', 'api_key = "opencodex-loopback"', 'name = "OCX gpt-5.6-sol"', "", @@ -110,6 +111,56 @@ describe("Grok orphan adoption (#511)", () => { expect(readFileSync(configPath, "utf8")).toContain("[model.ocx-remote]"); }); + test("preserves documented and generated-looking markerless manual tables", () => { + const fixtures = [ + [ + "[model.ocx-opus]", + 'model = "anthropic/claude-opus-4-8"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + ], + [ + "[model.ocx-opus]", + 'model = "anthropic/claude-opus-4-8"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "chat_completions"', + 'api_key = "opencodex-loopback"', + "", + ], + [ + "[model.ocx-anthropic-claude-opus-4-8]", + 'model = "anthropic/claude-opus-4-8"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'name = "OCX anthropic/claude-opus-4-8"', + "", + ], + [ + "[model.ocx-anthropic-claude-opus-4-8]", + 'model = "anthropic/claude-opus-4-8"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'name = "OCX anthropic/claude-opus-4-8"', + 'extra_headers = { "x-opencodex-grok" = "0" }', + "", + ], + ]; + + for (const lines of fixtures) { + const original = lines.join("\n"); + writeFileSync(configPath, original); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toContain(original); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(original); + } + }); + // F3: `[[model.x]]` collides with a generated `[model.x]` and makes Grok reject the // WHOLE config layer, so that spelling must stay reserved rather than adopted. test("leaves an array-of-table model reserved", () => { @@ -157,6 +208,7 @@ describe("Grok orphan adoption (#511)", () => { 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", ].join("\n")); @@ -173,18 +225,57 @@ describe("Grok orphan adoption (#511)", () => { }); test("keeps an owned-looking orphan whose model id is missing", () => { - writeFileSync(configPath, [ + const original = [ "[model.ocx-unknown]", 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", - ].join("\n")); + ].join("\n"); + writeFileSync(configPath, original); const result = injectGrokConfig(10100, MODELS, { grokHome }); expect(result).toMatchObject({ ok: true, changed: true }); const content = readFileSync(configPath, "utf8"); expect(content).toContain("[model.ocx-unknown]"); expect(content).toContain('api_key = "opencodex-loopback"'); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("preserves empty-model and array-child marker lookalikes", () => { + const fixtures = [ + [ + "[model.ocx-empty]", + 'model = ""', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ], + [ + "[model.ocx-array-marker]", + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[[model.ocx-array-marker.extra_headers]]", + 'x-opencodex-grok = "1"', + "", + ], + ]; + + for (const lines of fixtures) { + const original = lines.join("\n"); + writeFileSync(configPath, original); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(original); + } }); test("removes a hidden current orphan but preserves a genuinely retired one", () => { @@ -193,11 +284,13 @@ describe("Grok orphan adoption (#511)", () => { 'model = "hidden/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", "[model.ocx-retired]", 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", ].join("\n")); @@ -236,6 +329,7 @@ describe("Grok orphan adoption (#511)", () => { 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", ].join(eol); writeFileSync(configPath, userPrefix + orphan); @@ -257,6 +351,7 @@ describe("Grok orphan adoption (#511)", () => { 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", ].join(eol); const tail = ["# keep this post-fence note", "bare_user_key = true", ""].join(eol); @@ -276,6 +371,7 @@ describe("Grok orphan adoption (#511)", () => { 'model = "retired/model"', 'base_url = "http://127.0.0.1:10100/v1"', 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, "", ].join("\n"); const userOwned = [ @@ -292,8 +388,35 @@ describe("Grok orphan adoption (#511)", () => { expect(readFileSync(configPath, "utf8")).toBe(userOwned); }); + test("markerless teardown preserves an ambiguous legacy row", () => { + const legacy = [ + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "chat_completions"', + 'api_key = "opencodex-loopback"', + 'name = "OCX gpt-5.6-sol"', + "", + ].join("\n"); + writeFileSync(configPath, legacy); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(legacy); + expect(injectGrokConfig(10100, MODELS, { + grokHome, + excluded: new Set(["gpt-5.6-sol"]), + })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toContain('api_backend = "chat_completions"'); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(legacy); + // Injection can migrate the same legacy row because it writes a marked replacement now. + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).not.toContain('api_backend = "chat_completions"'); + }); + test("still removes a catalog orphan when that model is excluded", () => { - writeOrphanedConfig(); + writeOrphanedConfig(OWNERSHIP_MARKER); injectGrokConfig(10100, MODELS, { grokHome, @@ -340,7 +463,9 @@ describe("Grok orphan adoption (#511)", () => { "[model.ocx-gpt-5-6-sol]", // stale: no context_window 'model = "gpt-5.6-sol"', 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "chat_completions"', 'api_key = "opencodex-loopback"', + 'name = "OCX gpt-5.6-sol"', "", "[model.ocx-gpt-5-6-sol-2]", // the correct duplicate, also unfenced now 'model = "gpt-5.6-sol"', @@ -477,7 +602,9 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => { `[model.${alias}]`, 'model = "gpt-5.6-sol"', 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "chat_completions"', 'api_key = "opencodex-loopback"', + 'name = "OCX gpt-5.6-sol"', ]; const fence = (alias: string): string[] => [ @@ -534,7 +661,7 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => { ...orphan("ocx-gpt-5-6-sol"), "", "[model.ocx-gpt-5-6-sol.extra_headers]", - 'x-opencodex = "1"', + 'x-opencodex-grok = "1"', "", ].join("\n")); diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index 177fc15f0c..ada2360aaf 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -61,7 +61,9 @@ describe("syncGrokConfig", () => { "[model.ocx-stub-hidden]", 'model = "stub/hidden"', 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', "", ].join("\n")); @@ -79,6 +81,83 @@ describe("syncGrokConfig", () => { } }); + test("removes owned orphans from a disabled provider even without fetched ids", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const config = { + ...baseConfig, + providers: { + "disabled-provider": { + adapter: "openai-responses", + baseUrl: "https://example.invalid/v1", + disabled: true, + }, + }, + } as unknown as OcxConfig; + writeFileSync(join(grokHome, "config.toml"), [ + "[model.ocx-disabled-provider-legacy]", + 'model = "disabled-provider/legacy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + ].join("\n")); + + const result = await syncGrokConfig(10190, config, { grokHome }, { + fetchAllModels: async () => [], + injectGrokConfig, + }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + expect(content).not.toContain("[model.ocx-disabled-provider-legacy]"); + expect(content).not.toContain('model = "disabled-provider/legacy"'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not reinterpret a slash-shaped combo alias as a disabled provider model", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const config = { + ...baseConfig, + providers: { + "disabled-provider": { + adapter: "openai-responses", + baseUrl: "https://example.invalid/v1", + disabled: true, + }, + }, + combos: { + fallback: { + alias: "disabled-provider/legacy", + targets: [{ provider: "other", model: "m1" }], + }, + }, + } as unknown as OcxConfig; + const manual = [ + "[model.ocx-disabled-provider-legacy]", + 'model = "disabled-provider/legacy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + ].join("\n"); + writeFileSync(join(grokHome, "config.toml"), manual); + + const result = await syncGrokConfig(10190, config, { grokHome }, { + fetchAllModels: async () => [], + injectGrokConfig, + }); + expect(result).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toContain(manual); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("keeps disabled native ids in the orphan-classification catalog", async () => { const hiddenNative = nativeOpenAiSlugs()[0]!; let emitted: GrokInjectModel[] | undefined; diff --git a/tests/native-grok-toggle.test.ts b/tests/native-grok-toggle.test.ts index 5fa24382f9..ccd5c864af 100644 --- a/tests/native-grok-toggle.test.ts +++ b/tests/native-grok-toggle.test.ts @@ -347,6 +347,20 @@ test("the route never calls syncGrokConfig, and the inspector never re-implement test("the route's model list is byte-identical to syncGrokConfig's", async () => { writeConfig("# user only\n"); const config = baseConfig({ + providers: { + stub: { adapter: "openai-responses", baseUrl: "https://example.invalid/v1" }, + "disabled-stub": { + adapter: "openai-responses", + baseUrl: "https://example.invalid/v1", + disabled: true, + }, + }, + combos: { + slashy: { + alias: "disabled-stub/m4", + targets: [{ provider: "stub", model: "m1" }], + }, + }, disabledModels: ["stub/m2"], grokExcludedModels: ["stub/m3"], }); @@ -358,12 +372,16 @@ test("the route's model list is byte-identical to syncGrokConfig's", async () => let routeModels: GrokInjectModel[] | null = null; let routeExcluded: ReadonlySet | null = null; let routeCatalogModelIds: ReadonlySet | null = null; + let routeDisabledProviderNamespaces: ReadonlySet | null = null; + let routeComboPublicModelIds: ReadonlySet | null = null; const routeDeps = testDeps({ fetchAllModels: (async () => catalog) as never, injectGrokConfig: ((port: number, models: GrokInjectModel[], opts: Parameters[2]) => { routeModels = models; routeExcluded = opts?.excluded ?? null; routeCatalogModelIds = opts?.catalogModelIds ?? null; + routeDisabledProviderNamespaces = opts?.disabledProviderNamespaces ?? null; + routeComboPublicModelIds = opts?.comboPublicModelIds ?? null; return injectGrokConfig(port, models, opts); }) as typeof injectGrokConfig, }); @@ -373,12 +391,16 @@ test("the route's model list is byte-identical to syncGrokConfig's", async () => let syncModels: GrokInjectModel[] | null = null; let syncExcluded: ReadonlySet | null = null; let syncCatalogModelIds: ReadonlySet | null = null; + let syncDisabledProviderNamespaces: ReadonlySet | null = null; + let syncComboPublicModelIds: ReadonlySet | null = null; await syncGrokConfig(10100, config, { hostname: "127.0.0.1" }, { fetchAllModels: (async () => catalog) as never, injectGrokConfig: ((port: number, models: GrokInjectModel[], opts: Parameters[2]) => { syncModels = models; syncExcluded = opts?.excluded ?? null; syncCatalogModelIds = opts?.catalogModelIds ?? null; + syncDisabledProviderNamespaces = opts?.disabledProviderNamespaces ?? null; + syncComboPublicModelIds = opts?.comboPublicModelIds ?? null; return injectGrokConfig(port, models, opts); }) as typeof injectGrokConfig, }); @@ -386,6 +408,12 @@ test("the route's model list is byte-identical to syncGrokConfig's", async () => expect(routeCatalogModelIds && [...routeCatalogModelIds].sort()) .toEqual(syncCatalogModelIds && [...syncCatalogModelIds].sort()); expect(routeCatalogModelIds?.has("stub/m2")).toBe(true); + expect(routeDisabledProviderNamespaces && [...routeDisabledProviderNamespaces].sort()) + .toEqual(syncDisabledProviderNamespaces && [...syncDisabledProviderNamespaces].sort()); + expect(routeDisabledProviderNamespaces?.has("disabled-stub")).toBe(true); + expect(routeComboPublicModelIds && [...routeComboPublicModelIds].sort()) + .toEqual(syncComboPublicModelIds && [...syncComboPublicModelIds].sort()); + expect(routeComboPublicModelIds?.has("disabled-stub/m4")).toBe(true); /* * The exclusion half of the clause (C-gate blocker): the FULL list goes to * the writer together with the exclusion SET, never a pre-filtered list — From 57b5eefa1a2fa38da2bfd04b0a99e2e724278810 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 02:32:15 +0900 Subject: [PATCH 020/132] fix(grok): make orphan cleanup TOML-safe (cherry picked from commit 387d9f2b1035afa16af36ba7acd020555859a756) --- src/grok/inject.ts | 589 +++++++++++++++++++++++++---- tests/grok-orphan-adoption.test.ts | 457 +++++++++++++++++++++- 2 files changed, 969 insertions(+), 77 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index dc407d8109..7d1478811f 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -71,30 +71,283 @@ export function findManagedRegion(content: string): ManagedRegion | null { * `[model.]` header must be canonicalized before comparison. */ const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; +const DOTTED_KEY = String.raw`${KEY_SEGMENT}(?:[ \t]*\.[ \t]*${KEY_SEGMENT})*`; +/** One complete TOML table-header line; paired brackets reject array-value lookalikes. */ +const TABLE_HEADER_LINE = new RegExp( + String.raw`^[ \t]*(?:\[\[[ \t]*(${DOTTED_KEY})[ \t]*\]\]|\[[ \t]*(${DOTTED_KEY})[ \t]*\])[ \t]*(?:#[^\r\n]*)?$`, +); + +interface TomlTableHeader { + index: number; + length: number; + segments: string[]; + array: boolean; +} + +interface TomlStructure { + view: string; + headers: TomlTableHeader[]; + containerRootLineStarts: Set; +} + +/** End of a TOML multi-line basic/literal string, or EOF when it is unclosed. */ +function tomlMultilineStringEnd(content: string, start: number, quote: '"' | "'"): number { + let cursor = start + 3; + while (cursor < content.length) { + if (quote === '"' && content[cursor] === "\\") { + cursor += 2; + continue; + } + if (content[cursor] === quote + && content[cursor + 1] === quote + && content[cursor + 2] === quote) { + let end = cursor + 3; + // TOML permits one or two quote characters immediately before the closing delimiter. + if (content[end] === quote) { + end += 1; + if (content[end] === quote) end += 1; + } + return end; + } + cursor += 1; + } + return content.length; +} + +/** Find one TOML string value's exact source span; semantic decoding uses Bun's parser. */ +function tomlStringSpanAt(content: string, start: number): { end: number } | null { + const quote = content[start]; + if (quote !== '"' && quote !== "'") return null; + if (content[start + 1] === quote && content[start + 2] === quote) { + const end = tomlMultilineStringEnd(content, start, quote); + const token = content.slice(start, end); + if (token.length < 6 || !token.endsWith(quote.repeat(3))) return null; + return { end }; + } + + for (let cursor = start + 1; cursor < content.length; cursor += 1) { + const char = content[cursor]!; + if (char === "\r" || char === "\n") return null; + if (quote === '"' && char === "\\") { + cursor += 1; + continue; + } + if (char === quote) { + return { end: cursor + 1 }; + } + } + return null; +} + +/** Find the matching end of one inline table / array while skipping strings and comments. */ +function tomlContainerEnd(content: string, start: number): number | null { + const opener = content[start]; + if (opener !== "{" && opener !== "[") return null; + const stack: string[] = [opener]; + for (let index = start + 1; index < content.length;) { + const char = content[index]!; + if (char === "#") { + const newline = content.indexOf("\n", index); + index = newline === -1 ? content.length : newline + 1; + continue; + } + if (char === '"' || char === "'") { + const span = tomlStringSpanAt(content, index); + if (span === null) return null; + index = span.end; + continue; + } + if (char === "{" || char === "[") stack.push(char); + else if (char === "}" || char === "]") { + const expected = char === "}" ? "{" : "["; + if (stack.pop() !== expected) return null; + if (stack.length === 0) return index + 1; + } + index += 1; + } + return null; +} + +/** Locate one top-level string member inside an inline table without scanning nested prose. */ +function tomlInlineStringValueSpan( + content: string, + start: number, + end: number, + targetKey: string, +): { start: number; end: number } | null { + const skipTrivia = (from: number): number => { + let cursor = from; + while (cursor < end) { + while (/[ \t\r\n]/.test(content[cursor] ?? "")) cursor += 1; + if (content[cursor] !== "#") break; + const newline = content.indexOf("\n", cursor); + cursor = newline === -1 || newline >= end ? end : newline + 1; + } + return cursor; + }; + const keyPattern = new RegExp(DOTTED_KEY, "y"); + let entryStart = start + 1; + while (entryStart < end - 1) { + entryStart = skipTrivia(entryStart); + if (entryStart >= end - 1 || content[entryStart] === "}") return null; + keyPattern.lastIndex = entryStart; + const key = keyPattern.exec(content); + if (key === null) return null; + let cursor = skipTrivia(keyPattern.lastIndex); + if (content[cursor] !== "=") return null; + const valueStart = skipTrivia(cursor + 1); + const segments = canonicalDottedKey(key[0]); + if (segments.length === 1 && segments[0] === targetKey) { + const value = tomlStringSpanAt(content, valueStart); + return value === null ? null : { start: valueStart, end: value.end }; + } + + const stack: string[] = []; + cursor = valueStart; + let foundNext = false; + while (cursor < end - 1) { + const char = content[cursor]!; + if (char === "#") { + const newline = content.indexOf("\n", cursor); + cursor = newline === -1 || newline >= end ? end : newline + 1; + continue; + } + if (char === '"' || char === "'") { + const value = tomlStringSpanAt(content, cursor); + if (value === null) return null; + cursor = value.end; + continue; + } + if (char === "{" || char === "[") stack.push(char); + else if (char === "}" || char === "]") { + if (stack.length === 0) return null; + const expected = char === "}" ? "{" : "["; + if (stack.pop() !== expected) return null; + } else if (char === "," && stack.length === 0) { + entryStart = cursor + 1; + foundNext = true; + break; + } + cursor += 1; + } + if (!foundNext) return null; + } + return null; +} + /** - * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table - * (`[model.x.sub]`) spellings. `[[model.x]]` genuinely collides with a generated `[model.x]`, - * and one collision makes grok reject the ENTIRE config layer ("duplicate key"), taking every - * unrelated user setting with it; `[model.x.sub]` does not strictly collide, but reserving it - * costs only a suffixed alias and keeps us clear of the user's namespace. - * - * Every character class here is newline-free ON PURPOSE. With `[^\]]*` the optional sub-table - * tail runs past the end of its own line, so an unclosed `[model.…` inside a multiline string - * swallows the following lines — including a real `[model.]` header, which then goes - * unreserved and produces the very duplicate-key config this scan exists to prevent. + * A same-length lexical projection for structural scans. Triple-quoted string bytes become + * spaces while line endings and every byte outside those values keep their original offsets. */ -const MODEL_TABLE_HEADER = new RegExp( - String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`, - "gm", -); +function tomlStructuralView(content: string): string { + let state: "code" | "comment" | "basic" | "literal" = "code"; + let cursor = 0; + let output = ""; + for (let index = 0; index < content.length;) { + const char = content[index]!; + if (state === "comment") { + if (char === "\n") state = "code"; + index += 1; + continue; + } + if (state === "basic") { + if (char === "\\") index += 2; + else { + if (char === '"') state = "code"; + index += 1; + } + continue; + } + if (state === "literal") { + if (char === "'") state = "code"; + index += 1; + continue; + } + if (char === "#") { + state = "comment"; + index += 1; + continue; + } + if (char === '"' || char === "'") { + if (content[index + 1] === char && content[index + 2] === char) { + const end = tomlMultilineStringEnd(content, index, char); + output += content.slice(cursor, index); + output += content.slice(index, end).replace(/[^\r\n]/g, " "); + cursor = end; + index = end; + continue; + } + state = char === '"' ? "basic" : "literal"; + } + index += 1; + } + return output.length === 0 ? content : output + content.slice(cursor); +} + +/** Update array / inline-table nesting for one non-header line in the structural view. */ +function tomlContainerDepthAfterLine(line: string, initialDepth: number): number { + let depth = initialDepth; + let state: "code" | "basic" | "literal" = "code"; + for (let index = 0; index < line.length;) { + const char = line[index]!; + if (state === "basic") { + if (char === "\\") index += 2; + else { + if (char === '"') state = "code"; + index += 1; + } + continue; + } + if (state === "literal") { + if (char === "'") state = "code"; + index += 1; + continue; + } + if (char === "#") break; + if (char === '"' || char === "'") { + state = char === '"' ? "basic" : "literal"; + index += 1; + continue; + } + if (char === "[" || char === "{") depth += 1; + else if (char === "]" || char === "}") depth = Math.max(0, depth - 1); + index += 1; + } + return depth; +} /** - * ANY table header, capturing its full dotted key. Used to compute table SPANS: a table - * body runs from its own header to the next header of any kind, so the orphan sweep can - * remove a whole table instead of a guessed line range (a partial removal would re-parent - * the leftover keys onto the preceding table). + * Find real table headers and assignment-eligible lines while excluding arrays, inline tables, + * comments, and multi-line strings. Offsets remain exact because `view` is length-preserving. */ -const ANY_TABLE_HEADER = /^[ \t]*\[\[?[ \t]*([^\]\r\n]*?)[ \t]*\]\]?[ \t]*(?:#.*)?$/gm; +function analyzeTomlStructure(content: string): TomlStructure { + const view = tomlStructuralView(content); + const headers: TomlTableHeader[] = []; + const containerRootLineStarts = new Set(); + let depth = 0; + for (let lineStart = 0; lineStart <= view.length;) { + const newline = view.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? view.length : newline; + const rawLine = view.slice(lineStart, lineEnd); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const header = depth === 0 ? TABLE_HEADER_LINE.exec(line) : null; + if (header) { + const dottedKey = header[1] ?? header[2]!; + headers.push({ + index: lineStart, + length: header[0].length, + segments: canonicalDottedKey(dottedKey), + array: header[1] !== undefined, + }); + } else { + if (depth === 0) containerRootLineStarts.add(lineStart); + depth = tomlContainerDepthAfterLine(line, depth); + } + if (newline === -1) break; + lineStart = newline + 1; + } + return { view, headers, containerRootLineStarts }; +} /** Resolve a header key segment (bare / basic / literal) to the key it actually addresses. */ function canonicalKeySegment(raw: string): string { @@ -103,6 +356,12 @@ function canonicalKeySegment(raw: string): string { return raw; } +/** Split a TOML dotted key without treating dots inside quoted segments as separators. */ +function canonicalDottedKey(raw: string): string[] { + return [...raw.matchAll(new RegExp(KEY_SEGMENT, "g"))] + .map(match => canonicalKeySegment(match[0]!)); +} + /** * `[model.]` table headers the USER owns (outside our fence) — reserved for collisions. * TOML admits equivalent header spellings for BOTH segments (`["model"."ocx-mine"]`, @@ -114,9 +373,9 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set(); - for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) { - if (canonicalKeySegment(match[1]!) !== "model") continue; - aliases.add(canonicalKeySegment(match[2]!)); + for (const header of analyzeTomlStructure(outsideManagedRegion).headers) { + if (header.segments[0] !== "model" || header.segments.length < 2) continue; + aliases.add(header.segments[1]!); } return aliases; } @@ -135,14 +394,17 @@ interface OrphanTable { /** Offsets into the NORMALIZED content: header start .. next header start (or EOF). */ start: number; end: number; + /** Re-serialized child tables may be separated from the parent by unrelated tables. */ + additionalRanges: Array<{ start: number; end: number }>; } /** `key = "value"` / `key = value` pairs at the top level of one table body. */ function tableBodyKeys(body: string): Map { const keys = new Map(); - for (const line of body.split("\n")) { - const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/.exec(line); - if (!match) continue; + const structure = analyzeTomlStructure(body); + const assignment = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/gm; + for (const match of structure.view.matchAll(assignment)) { + if (!structure.containerRootLineStarts.has(match.index!)) continue; const raw = match[2]!; const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? decodeTomlBasicString(raw.slice(1, -1)) @@ -238,15 +500,7 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or const clampEnd = (start: number, end: number): number => fenceStart >= 0 && start < fenceStart ? Math.min(end, fenceStart) : end; // Collect every table header first: a table body runs to the NEXT header, whatever it is. - const headers: Array<{ index: number; length: number; segments: string[]; array: boolean }> = []; - for (const match of content.matchAll(ANY_TABLE_HEADER)) { - headers.push({ - index: match.index!, - length: match[0].length, - segments: match[1]!.split(".").map(part => canonicalKeySegment(part.trim())), - array: match[0].trimStart().startsWith("[["), - }); - } + const headers = analyzeTomlStructure(content).headers; for (const [position, header] of headers.entries()) { if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; // Inside the fence the regular splice already owns it. @@ -258,25 +512,23 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or const modelId = keys.get("model"); if (!modelId) continue; let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers")); - // Swallow the entry's OWN sub-tables (`[model..extra_headers]`). Grok writes - // them when it re-serializes the file, and leaving one behind keeps the alias - // reserved by `userModelAliases` — so the sweep would remove the parent and STILL - // allocate a suffixed duplicate, which is the exact #511 loop we came to close. - let end = bodyEnd; - for (let next = position + 1; next < headers.length; next += 1) { + // Swallow the entry's OWN sub-tables (`[model..extra_headers]`). Grok may + // re-serialize them non-contiguously, so collect exact descendant spans globally rather + // than stopping at the first unrelated table. + const additionalRanges: Array<{ start: number; end: number }> = []; + for (let next = 0; next < headers.length; next += 1) { + if (next === position) continue; const child = headers[next]!; - // Only a PRE-fence parent may be cut short by the fence. Without the parent test a - // below-fence orphan would break on its first child (every index is past the fence), - // leaving the sub-table behind to keep the alias reserved — the -2 loop again. - if (fenceStart >= 0 && header.index < fenceStart && child.index >= fenceStart) break; - if (child.segments.length <= 2) break; - if (child.segments[0] !== "model" || child.segments[1] !== header.segments[1]) break; - const childEnd = clampEnd(header.index, headers[next + 1]?.index ?? content.length); + if (region && child.index >= region.start && child.index < region.end) continue; + if (child.segments.length <= 2 + || child.segments[0] !== "model" + || child.segments[1] !== header.segments[1]) continue; + const childEnd = clampEnd(child.index, headers[next + 1]?.index ?? content.length); + additionalRanges.push({ start: child.index, end: childEnd }); if (!child.array && child.segments.length === 3 && child.segments[2] === "extra_headers") { const childKeys = tableBodyKeys(content.slice(child.index + child.length, childEnd)); if (childKeys.get(OPENCODEX_GROK_MARKER) === "1") hasOwnershipMarker = true; } - end = childEnd; } const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys); if (!hasOwnershipMarker && !legacyGenerated) continue; @@ -285,36 +537,197 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or modelId, ownership: hasOwnershipMarker ? "explicit" : "legacy", start: header.index, - end, + end: bodyEnd, + additionalRanges, }); } return orphans; } -/** Remove whole tables, back to front so earlier offsets stay valid. */ +function orphanRanges(orphans: readonly OrphanTable[]): Array<{ start: number; end: number }> { + const unique = new Map(); + for (const orphan of orphans) { + for (const range of [{ start: orphan.start, end: orphan.end }, ...orphan.additionalRanges]) { + unique.set(`${range.start}:${range.end}`, range); + } + } + return [...unique.values()]; +} + +/** Remove exact whole-table ranges, back to front so earlier offsets stay valid. */ +function removeTableRanges(content: string, ranges: readonly { start: number; end: number }[]): string { + let next = content; + const unique = new Map(ranges.map(range => [`${range.start}:${range.end}`, range])); + for (const range of [...unique.values()].sort((a, b) => b.start - a.start)) { + next = next.slice(0, range.start) + next.slice(range.end); + } + return next; +} + function removeOrphanTables(content: string, orphans: OrphanTable[]): string { + return removeTableRanges(content, orphanRanges(orphans)); +} + +/** Read one exact path from an already parsed TOML document. */ +function tomlPathString(document: unknown, path: readonly string[]): string | null { + let value = document; + for (const segment of path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + value = (value as Record)[segment]; + } + return typeof value === "string" ? value : null; +} + +/** Parse a probe document and read one exact semantic path. */ +function parsedTomlPathString(content: string, path: readonly string[]): string | null { + try { + return tomlPathString(Bun.TOML.parse(content), path); + } catch { + return null; + } +} + +/** Rename or remove the two semantic model references without touching user prose. */ +function transformAliasReferences( + content: string, + replacements: ReadonlyMap, + allowRootDotted = true, +): string { + if (replacements.size === 0) return content; + let document: unknown; + try { + document = Bun.TOML.parse(content); + } catch { + throw new Error( + "Grok config rewrite refused: Bun could not parse the TOML document safely.", + ); + } + const structure = analyzeTomlStructure(content); + const edits: Array<{ start: number; end: number; replacement: string }> = []; + const candidates: Array<{ + pathKey: "models.default" | "ui.fork_secondary_model"; + valueStart: number; + valueEnd: number; + line: { start: number; end: number } | null; + }> = []; + const assignment = new RegExp(String.raw`^([ \t]*(${DOTTED_KEY})[ \t]*=)`, "gm"); + let headerPosition = -1; + for (const match of structure.view.matchAll(assignment)) { + const assignmentStart = match.index!; + if (!structure.containerRootLineStarts.has(assignmentStart)) continue; + while ((structure.headers[headerPosition + 1]?.index ?? Number.POSITIVE_INFINITY) + < assignmentStart) headerPosition += 1; + const currentHeader = headerPosition >= 0 ? structure.headers[headerPosition]! : null; + if (currentHeader?.array) continue; + if (!allowRootDotted && currentHeader === null) continue; + const segments = canonicalDottedKey(match[2]!); + const semanticPath = [...(currentHeader?.segments ?? []), ...segments]; + let valueStart = assignmentStart + match[1]!.length; + while (content[valueStart] === " " || content[valueStart] === "\t") valueStart += 1; + const pathKey = semanticPath.length === 2 && semanticPath[0] === "models" + && semanticPath[1] === "default" + ? "models.default" + : semanticPath.length === 2 && semanticPath[0] === "ui" + && semanticPath[1] === "fork_secondary_model" + ? "ui.fork_secondary_model" + : null; + if (pathKey !== null) { + const value = tomlStringSpanAt(content, valueStart); + if (value === null) continue; + const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(value.end)); + if (suffix === null) continue; + candidates.push({ + pathKey, + valueStart, + valueEnd: value.end, + line: { start: assignmentStart, end: value.end + suffix[0].length }, + }); + continue; + } + + // The only non-line form we support is a root inline table (`models = { ... }` / `ui =`). + const inlineTarget = currentHeader === null && semanticPath.length === 1 + && semanticPath[0] === "models" + ? { pathKey: "models.default" as const, key: "default" } + : currentHeader === null && semanticPath.length === 1 && semanticPath[0] === "ui" + ? { pathKey: "ui.fork_secondary_model" as const, key: "fork_secondary_model" } + : null; + if (inlineTarget === null || content[valueStart] !== "{") continue; + const inlineEnd = tomlContainerEnd(content, valueStart); + if (inlineEnd === null) continue; + const value = tomlInlineStringValueSpan(content, valueStart, inlineEnd, inlineTarget.key); + if (value === null) continue; + candidates.push({ + pathKey: inlineTarget.pathKey, + valueStart: value.start, + valueEnd: value.end, + line: null, + }); + } + + const targets = [ + { path: ["models", "default"] as const, pathKey: "models.default" as const }, + { path: ["ui", "fork_secondary_model"] as const, pathKey: "ui.fork_secondary_model" as const }, + ]; + for (const [targetIndex, target] of targets.entries()) { + const currentAlias = tomlPathString(document, target.path); + if (currentAlias === null || !replacements.has(currentAlias)) continue; + const replacement = replacements.get(currentAlias)!; + const probeCandidates = candidates.filter(candidate => candidate.pathKey === target.pathKey); + if (probeCandidates.length === 0 && !allowRootDotted) continue; + if (probeCandidates.length === 0 || probeCandidates.length > 32) { + throw new Error( + "Grok config rewrite refused: the model-reference source could not be bounded safely.", + ); + } + let located = false; + for (const candidate of probeCandidates) { + let sentinel = `__opencodex_reference_probe_${targetIndex}_${candidate.valueStart}__`; + while (sentinel === currentAlias) sentinel += "_"; + const probe = content.slice(0, candidate.valueStart) + + tomlString(sentinel) + + content.slice(candidate.valueEnd); + if (parsedTomlPathString(probe, target.path) !== sentinel) continue; + if (replacement === null && candidate.line === null) { + throw new Error( + "Grok teardown refused: a model reference uses an inline TOML shape that cannot " + + "be removed without rewriting user-owned bytes.", + ); + } + edits.push(replacement === null + ? { start: candidate.line!.start, end: candidate.line!.end, replacement: "" } + : { start: candidate.valueStart, end: candidate.valueEnd, replacement: tomlString(replacement) }); + located = true; + break; + } + if (!located) { + throw new Error( + "Grok config rewrite refused: the semantic model reference could not be located safely.", + ); + } + } let next = content; - for (const orphan of [...orphans].sort((a, b) => b.start - a.start)) { - next = next.slice(0, orphan.start) + next.slice(orphan.end); + for (const edit of edits.sort((a, b) => b.start - a.start)) { + next = next.slice(0, edit.start) + edit.replacement + next.slice(edit.end); } return next; } -/** - * Repoint `default` / `fork_secondary_model` at the alias that survived. - * - * Removing an adopted orphan that `[models] default` names would leave Grok pointing at - * a model that no longer exists — and on a real machine `default` DOES name one, so this - * is the common path rather than an edge case. - */ +/** Repoint references at whichever alias survived orphan adoption. */ function rewriteAliasReferences(content: string, renames: Map): string { - if (renames.size === 0) return content; - return content.replace( - /^([ \t]*(?:default|fork_secondary_model)[ \t]*=[ \t]*")([^"]*)(")/gm, - (whole, prefix: string, value: string, suffix: string) => { - const replacement = renames.get(value); - return replacement ? `${prefix}${replacement}${suffix}` : whole; - }, + return transformAliasReferences(content, renames); +} + +/** Remove only references that name model aliases teardown actually swept. */ +function removeAliasReferences( + content: string, + removedAliases: ReadonlySet, + allowRootDotted = true, +): string { + return transformAliasReferences( + content, + new Map([...removedAliases].map(alias => [alias, null] as const)), + allowRootDotted, ); } @@ -508,11 +921,14 @@ export function injectGrokConfig( // file beats a dangling one. if (orphans.length > 0) { const survivors = new Map(); - for (const match of nextContent.matchAll(MODEL_TABLE_HEADER)) { - if (canonicalKeySegment(match[1]!) !== "model") continue; - const alias = canonicalKeySegment(match[2]!); - const body = nextContent.slice(match.index! + match[0].length); - const modelId = tableBodyKeys(body.slice(0, body.search(/^[ \t]*\[/m) + 1 || body.length)).get("model"); + const structure = analyzeTomlStructure(nextContent); + const managedRegion = findManagedRegion(nextContent); + for (const [position, header] of structure.headers.entries()) { + if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; + if (!managedRegion || header.index < managedRegion.start || header.index >= managedRegion.end) continue; + const alias = header.segments[1]!; + const bodyEnd = structure.headers[position + 1]?.index ?? nextContent.length; + const modelId = tableBodyKeys(nextContent.slice(header.index + header.length, bodyEnd)).get("model"); if (modelId !== undefined && !survivors.has(modelId)) survivors.set(modelId, alias); } const renames = new Map(); @@ -574,6 +990,8 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes let stripped: string; let orphanCount = 0; if (originalRegion) { + const fullOrphans = findOpencodexOrphans(content, originalRegion) + .filter(orphan => orphan.ownership === "explicit"); let removalEnd = originalRegion.end; if (content.startsWith("\n", removalEnd)) removalEnd += 1; let prefix = content.slice(0, originalRegion.start); @@ -590,9 +1008,31 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes .filter(orphan => orphan.ownership === "explicit"); const tailOrphans = findOpencodexOrphans(restOfFile, null) .filter(orphan => orphan.ownership === "explicit"); - orphanCount = prefixOrphans.length + tailOrphans.length; - stripped = removeOrphanTables(prefix, prefixOrphans) - + removeOrphanTables(restOfFile, tailOrphans); + const removedAliases = new Set( + [...fullOrphans, ...prefixOrphans, ...tailOrphans].map(orphan => orphan.alias), + ); + orphanCount = removedAliases.size; + const fullRanges = orphanRanges(fullOrphans); + const prefixRanges = [ + ...orphanRanges(prefixOrphans), + ...fullRanges.filter(range => range.end <= originalRegion.start), + ]; + const tailRanges = [ + ...orphanRanges(tailOrphans), + ...fullRanges + .filter(range => range.start >= removalEnd) + .map(range => ({ start: range.start - removalEnd, end: range.end - removalEnd })), + ]; + // Preserve the original fence as a structural boundary while cleaning references too. + // Joining first can re-parent a headerless tail under the last table in `prefix`. + stripped = removeAliasReferences( + removeTableRanges(prefix, prefixRanges), + removedAliases, + ) + removeAliasReferences( + removeTableRanges(restOfFile, tailRanges), + removedAliases, + false, + ); } else { // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the // fence while the integration is enabled. Teardown owns those strictly identified tables @@ -604,6 +1044,7 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes } orphanCount = orphans.length; stripped = removeOrphanTables(content, orphans); + stripped = removeAliasReferences(stripped, new Set(orphans.map(orphan => orphan.alias))); } if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex")); atomicWriteFile(configPath, applyEol(stripped, eol)); diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 635bd5e1b5..2b36fd0956 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -343,9 +343,14 @@ describe("Grok orphan adoption (#511)", () => { } }); - test("teardown preserves a comment-only tail beyond the fence byte-for-byte", () => { + test("teardown preserves a headerless tail beyond the fence byte-for-byte", () => { for (const eol of ["\n", "\r\n"]) { - const userPrefix = [`theme = "${eol === "\n" ? "lf" : "crlf"}"`, "", ""].join(eol); + const userPrefix = [ + "[models]", + `keep = "${eol === "\n" ? "lf" : "crlf"}"`, + "", + "", + ].join(eol); const orphan = [ "[model.ocx-retired]", 'model = "retired/model"', @@ -354,7 +359,13 @@ describe("Grok orphan adoption (#511)", () => { OWNERSHIP_MARKER, "", ].join(eol); - const tail = ["# keep this post-fence note", "bare_user_key = true", ""].join(eol); + const tail = [ + "# keep this post-fence note", + 'default = "ocx-retired"', + 'models.default = "ocx-retired"', + "bare_user_key = true", + "", + ].join(eol); writeFileSync(configPath, userPrefix + orphan); expect(injectGrokConfig(10100, MODELS, { grokHome })) .toMatchObject({ ok: true, changed: true }); @@ -388,6 +399,446 @@ describe("Grok orphan adoption (#511)", () => { expect(readFileSync(configPath, "utf8")).toBe(userOwned); }); + test("teardown clears only section-owned references to swept aliases", () => { + for (const withFence of [false, true]) { + const modelsHeader = withFence ? '["models"]' : "[models]"; + const defaultKey = withFence ? '"default"' : "default"; + const uiHeader = withFence ? "['ui']" : "[ui]"; + const secondaryKey = withFence ? "'fork_secondary_model'" : "fork_secondary_model"; + const otherHeader = withFence ? '["other]"]' : "[other]"; + const expected = [ + modelsHeader, + 'keep = "models"', + "", + uiHeader, + 'keep = "ui"', + "", + otherHeader, + 'default = "ocx-retired"', + 'fork_secondary_model = "ocx-retired"', + "", + "", + ].join("\n"); + writeFileSync(configPath, [ + modelsHeader, + `${defaultKey} = "ocx-retired"`, + 'keep = "models"', + "", + uiHeader, + `${secondaryKey} = 'ocx-retired' # removed with its table`, + 'keep = "ui"', + "", + otherHeader, + 'default = "ocx-retired"', + 'fork_secondary_model = "ocx-retired"', + "", + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + if (withFence) { + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + } + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(expected); + } + }); + + test("teardown clears multiline section-owned references to swept aliases", () => { + for (const delimiter of ['"""', "'''"]) { + const original = [ + "[models]", + `default = ${delimiter}ocx-retired${delimiter}`, + 'keep = "models"', + "", + "[ui]", + `fork_secondary_model = ${delimiter}`, + "ocx-retired" + delimiter, + 'keep = "ui"', + "", + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + expect(content).toBe([ + "[models]", + 'keep = "models"', + "", + "[ui]", + 'keep = "ui"', + "", + "", + ].join("\n")); + } + }); + + test("teardown preserves an escaped multiline value that is not the swept alias", () => { + const reference = [ + "[models]", + 'default = """\\\\', + 'u006Fcx-retired"""', + 'keep = "models"', + "", + ].join("\n"); + writeFileSync(configPath, reference + [ + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(reference); + }); + + test("teardown does not reinterpret nested array elements as table headers", () => { + const userContent = [ + "[other]", + "model_names = [", + ' ["models"],', + "]", + 'default = "ocx-retired"', + "ui_names = [", + ' ["ui"],', + "]", + 'fork_secondary_model = "ocx-retired"', + "", + "", + ].join("\n"); + writeFileSync(configPath, userContent + [ + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(userContent); + }); + + test("teardown clears quoted root dotted references to swept aliases", () => { + writeFileSync(configPath, [ + '"models".\'default\' = "ocx-retired"', + '\'ui\'."fork_secondary_model" = \'ocx-retired\'', + 'keep = "root"', + "", + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(['keep = "root"', "", ""].join("\n")); + }); + + test("adoption rewrites a quoted root dotted reference", () => { + const oldAlias = "ocx-gpt-5-6-sol-2"; + writeFileSync(configPath, [ + `"models".'default' = '${oldAlias}'`, + "", + `[model.${oldAlias}]`, + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + const defaultAlias = /^"models"\.'default' = "([^"]+)"$/m.exec(content)?.[1]; + expect(defaultAlias).toBeDefined(); + expect(defaultAlias).not.toBe(oldAlias); + expect(content).toContain(`[model.${defaultAlias}]`); + expect(content).not.toContain(`[model.${oldAlias}]`); + }); + + test("adoption rewrites an inline-table reference", () => { + const oldAlias = "ocx-gpt-5-6-sol-2"; + const decoys = Array.from({ length: 40 }, () => "default = 'not-a-key'").join(", "); + writeFileSync(configPath, [ + `models = { note = "{ ${decoys} }", default = "${oldAlias}", keep = true }`, + "", + `[model.${oldAlias}]`, + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + const defaultAlias = /models = \{ note = .*?, default = "([^"]+)", keep = true \}/.exec(content)?.[1]; + expect(defaultAlias).toBeDefined(); + expect(defaultAlias).not.toBe(oldAlias); + expect(content).toContain(`[model.${defaultAlias}]`); + }); + + test("teardown fails closed on an inline-table reference", () => { + const original = [ + 'models = { default = "ocx-retired", keep = true }', + "", + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: false, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("semantic probing cannot confuse an existing sentinel-shaped alias", () => { + const unrelated = 'default = "keep"\n'; + const alias = `__opencodex_reference_probe_0_${unrelated.indexOf('"')}__`; + writeFileSync(configPath, unrelated + [ + `models.default = "${alias}"`, + "", + `[model.${alias}]`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe('default = "keep"\n\n'); + }); + + test("adoption prefers the managed survivor over a same-model user table", () => { + const oldAlias = "ocx-gpt-5-6-sol-2"; + writeFileSync(configPath, [ + "[models]", + `default = "${oldAlias}"`, + "", + "[model.manual]", + 'model = "gpt-5.6-sol"', + 'base_url = "https://example.com/v1"', + 'api_key = "user-secret"', + "", + `[model.${oldAlias}]`, + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + const defaultAlias = /^default = "([^"]+)"$/m.exec(content)?.[1]; + expect(defaultAlias).toBeDefined(); + expect(defaultAlias).not.toBe("manual"); + expect(defaultAlias).not.toBe(oldAlias); + expect(content).toContain(`[model.${defaultAlias}]`); + }); + + test("teardown follows a non-contiguous ownership child table", () => { + const alias = "ocx-retired"; + const preserved = ["[other]", "keep = true", "", ""].join("\n"); + writeFileSync(configPath, [ + `[model.${alias}]`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + "[other]", + "keep = true", + "", + `[model.${alias}.extra_headers]`, + 'x-opencodex-grok = "1"', + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(preserved); + }); + + test("teardown follows an ownership child written before its parent", () => { + const alias = "ocx-retired"; + const preserved = ["[other]", "keep = true", "", ""].join("\n"); + writeFileSync(configPath, [ + `[model.${alias}.extra_headers]`, + 'x-opencodex-grok = "1"', + "", + "[other]", + "keep = true", + "", + `[model.${alias}]`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(preserved); + }); + + test("teardown follows an ownership child re-serialized beyond the fence", () => { + const alias = "ocx-retired"; + writeFileSync(configPath, [ + `[model.${alias}]`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + writeFileSync(configPath, readFileSync(configPath, "utf8") + [ + `[model.${alias}.extra_headers]`, + 'x-opencodex-grok = "1"', + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8").trim()).toBe(""); + }); + + test("teardown follows a pre-fence ownership child to a post-fence parent", () => { + const alias = "ocx-retired"; + writeFileSync(configPath, [ + `[model.${alias}.extra_headers]`, + 'x-opencodex-grok = "1"', + "", + ].join("\n")); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + writeFileSync(configPath, readFileSync(configPath, "utf8") + [ + `[model.${alias}]`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ].join("\n")); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8").trim()).toBe(""); + }); + + test("a Unicode line separator inside a comment does not hide a user model header", () => { + const alias = "ocx-gpt-5-6-sol"; + writeFileSync(configPath, [ + `[model.${alias}] # alpha\u2028omega`, + 'model = "user/model"', + 'base_url = "https://example.com/v1"', + 'api_key = "user-secret"', + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + expect(content).toContain(`[model.${alias}] # alpha\u2028omega`); + expect(content).toContain(`[model.${alias}-2]`); + }); + + test("teardown ignores generated-looking tables inside multiline TOML strings", () => { + for (const delimiter of ['"""', "'''"]) { + const original = [ + `notes = ${delimiter}`, + "[model.ocx-retired]", + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + delimiter, + "", + "[model.user-owned]", + 'model = "user/model"', + 'base_url = "https://example.com/v1"', + 'api_key = "user-secret"', + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(original); + } + }); + + test("ownership keys inside a multiline value do not claim a manual table", () => { + for (const delimiter of ['"""', "'''"]) { + const original = [ + "[model.hand-written]", + `notes = ${delimiter}`, + 'model = "retired/model"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + delimiter, + 'model = "user/model"', + 'base_url = "https://example.com/v1"', + 'api_key = "user-secret"', + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: false }); + expect(readFileSync(configPath, "utf8")).toBe(original); + } + }); + + test("adoption ignores fake survivors and references inside multiline strings", () => { + const oldAlias = "ocx-gpt-5-6-sol-2"; + writeFileSync(configPath, [ + "[models]", + `default = "${oldAlias}"`, + 'notes = """', + "[model.fake-survivor]", + 'model = "gpt-5.6-sol"', + `default = "${oldAlias}"`, + '"""', + "", + `[model.${oldAlias}]`, + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "chat_completions"', + 'api_key = "opencodex-loopback"', + 'name = "OCX gpt-5.6-sol"', + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + expect(content).toContain('default = "ocx-gpt-5-6-sol"'); + expect(content).toContain(`[model.fake-survivor]\nmodel = "gpt-5.6-sol"\ndefault = "${oldAlias}"`); + expect(content).not.toContain(`[model.${oldAlias}]`); + }); + test("markerless teardown preserves an ambiguous legacy row", () => { const legacy = [ "[model.ocx-gpt-5-6-sol]", From 0ed3f351c5c9a05f984b264759498e8fbffa8792 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 02:55:00 +0900 Subject: [PATCH 021/132] test(grok): assert retired orphan injection (cherry picked from commit 019c792607808614a0b9f61be1c9aa9170733d7e) --- tests/grok-orphan-adoption.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 2b36fd0956..300d5c9649 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -212,8 +212,10 @@ describe("Grok orphan adoption (#511)", () => { "", ].join("\n")); - injectGrokConfig(10100, MODELS, { grokHome }); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); const content = readFileSync(configPath, "utf8"); + expect(content).toContain(BEGIN_MARKER); expect(content).toContain('default = "ocx-retired"'); expect(content).toContain('fork_secondary_model = "ocx-retired"'); expect(content).toContain("[model.ocx-retired]"); From a52d00a6eeb9e9255966acea5f6b70a28d77c7d8 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 21:51:48 +0900 Subject: [PATCH 022/132] fix(codex): skip failing quota candidates (cherry picked from commit 375e6f8fb849db0d60f61220cfe8716d6ab15445) --- src/codex/routing.ts | 17 +++++- tests/codex-routing.test.ts | 101 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 95e6ed06b7..b1d5a26b01 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1006,12 +1006,14 @@ function getEligiblePoolAccounts( now = Date.now(), quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + skipFailoverReadyCandidates = false, ): readonly string[] { const ids = (config.codexAccounts ?? []) .filter(account => isSelectableCodexPoolAccount(account) && account.id !== excludeId && !isCodexAccountPaused(config, account.id) - && !isAccountNeedsReauth(account.id)) + && !isAccountNeedsReauth(account.id) + && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now))) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id, selectionOptions)) @@ -1024,6 +1026,7 @@ function getEligiblePoolAccounts( && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) + && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now)) && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) ) { ids.unshift(MAIN_CODEX_ACCOUNT_ID); @@ -1244,10 +1247,18 @@ function pickLowerUsageAccount( now: number, quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, + skipFailoverReadyCandidates = false, ): string { let best = active; let bestUsage = activeUsage; - for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) { + for (const id of getEligiblePoolAccounts( + config, + active, + now, + quotaScope, + selectionOptions, + skipFailoverReadyCandidates, + )) { const usage = computeCodexUsageScore( getAccountQuota(id), getPoolAccountPlanForSelection(config, id, selectionOptions), @@ -1623,6 +1634,7 @@ function previewReusableAffinityAccount( now, quotaScope, selectionOptions, + true, ); if (best !== entry.accountId) return best; } @@ -1666,6 +1678,7 @@ function reevaluateAffinityQuota( now, quotaScope, selectionOptions, + true, ); return best === entry.accountId ? null : best; } diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 0c651daf2d..eb93bc14c2 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -1906,6 +1906,107 @@ describe("codex account selection order", () => { expect(resolveCodexAccountForThread(threadId, config, now + 6, "shared")).toBe("b"); }); + test("quota detour re-evaluation skips failover-ready cooler candidates", () => { + const now = 1_800_000_000_000; + const threadId = "quota-detour-failover-candidate"; + const modelId = "gpt-daybreak-blue-latest"; + const config = makeConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "c", + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 5); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("c"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("c"); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["a"]) }, + modelId, + )).toEqual({ status: "selected", accountId: "a" }); + // B is the highest tier after the detour exists. Filtering only after tier + // selection would drop B without ever exposing healthy C to the picker. + config.codexAccountPriorities = { b: 2, c: 1 }; + + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt + 2, + }); + } + updateAccountQuota("a", 90); + const resolveAt = now + CODEX_TRANSIENT_SOFT_AVOID_MS + 5; + const eligible = { modelEligibleAccountIds: new Set(["a", "b", "c"]) }; + + expect(previewCodexAccountForRequest( + threadId, + config, + resolveAt, + "shared", + eligible, + modelId, + )).toBe("c"); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + resolveAt, + "shared", + eligible, + modelId, + )).toEqual({ status: "selected", accountId: "c" }); + expect(config.activeCodexAccountId).toBe("c"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); + }); + + test("ordinary quota affinity re-evaluation skips a failover-ready higher tier", () => { + const now = 1_800_000_000_000; + const threadId = "ordinary-quota-failover-candidate"; + const config = makeConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 5); + updateAccountQuota("c", 10); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("a"); + config.codexAccountPriorities = { b: 2, c: 1 }; + + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(config, "b", 503, { + fixedAccount: true, + now: now + attempt + 1, + }); + } + updateAccountQuota("a", 90); + const resolveAt = now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4; + + expect(previewCodexAccountForRequest(threadId, config, resolveAt, "shared")).toBe("c"); + expect(resolveCodexAccountForThreadDetailed( + threadId, + config, + resolveAt, + "shared", + )).toEqual({ status: "selected", accountId: "c" }); + expect(config.activeCodexAccountId).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); + }); + test("model preview and final keep a live detour after ordinary affinity cleanup", () => { const now = 1_800_000_000_000; const threadId = "detour-after-ordinary-cleanup"; From ecd6225fcd88e355dfb735ea8d67f3a1d8353ca5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 05:06:53 +0900 Subject: [PATCH 023/132] fix(grok): clear references to removed models When orphan adoption removes an excluded managed model without emitting a replacement table, remove its semantic model references through the existing TOML-safe transform instead of leaving dangling aliases. --- src/grok/inject.ts | 20 ++++++++++---------- tests/grok-orphan-adoption.test.ts | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 7d1478811f..4cb416efaf 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -713,9 +713,9 @@ function transformAliasReferences( return next; } -/** Repoint references at whichever alias survived orphan adoption. */ -function rewriteAliasReferences(content: string, renames: Map): string { - return transformAliasReferences(content, renames); +/** Repoint references at whichever alias survived orphan adoption, or remove them. */ +function rewriteAliasReferences(content: string, replacements: Map): string { + return transformAliasReferences(content, replacements); } /** Remove only references that name model aliases teardown actually swept. */ @@ -916,9 +916,9 @@ export function injectGrokConfig( nextContent = `${content}\n${block}\n`; } - // Repoint `default` / `fork_secondary_model` at whichever alias survived. A removed - // model with no replacement keeps its reference untouched — a stale name in a working - // file beats a dangling one. + // Repoint `default` / `fork_secondary_model` at whichever alias survived. If an + // excluded or removed model has no replacement, clear its references with the same + // TOML-aware transform used by teardown so the new config cannot point at a deleted table. if (orphans.length > 0) { const survivors = new Map(); const structure = analyzeTomlStructure(nextContent); @@ -931,12 +931,12 @@ export function injectGrokConfig( const modelId = tableBodyKeys(nextContent.slice(header.index + header.length, bodyEnd)).get("model"); if (modelId !== undefined && !survivors.has(modelId)) survivors.set(modelId, alias); } - const renames = new Map(); + const replacements = new Map(); for (const orphan of orphans) { - const replacement = survivors.get(orphan.modelId); - if (replacement && replacement !== orphan.alias) renames.set(orphan.alias, replacement); + const replacement = survivors.get(orphan.modelId) ?? null; + if (replacement !== orphan.alias) replacements.set(orphan.alias, replacement); } - nextContent = rewriteAliasReferences(nextContent, renames); + nextContent = rewriteAliasReferences(nextContent, replacements); } const output = applyEol(nextContent, eol); diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 300d5c9649..381ab94144 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -878,6 +878,26 @@ describe("Grok orphan adoption (#511)", () => { expect(modelTables(readFileSync(configPath, "utf8"))).toEqual([]); }); + test("clears references when an excluded model has no survivor (#2830)", () => { + writeOrphanedConfig([ + OWNERSHIP_MARKER, + "", + "[ui]", + 'fork_secondary_model = "ocx-gpt-5-6-sol"', + "", + ].join("\n")); + + expect(injectGrokConfig(10100, MODELS, { + grokHome, + excluded: new Set(["gpt-5.6-sol"]), + })).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + expect(modelTables(content)).toEqual([]); + expect(content).not.toContain('default = "ocx-gpt-5-6-sol"'); + expect(content).not.toContain('fork_secondary_model = "ocx-gpt-5-6-sol"'); + }); + // F7: the sweep must converge, or `changed` is meaningless to callers. test("is idempotent: the second sync reports no change", () => { writeOrphanedConfig(); From bb3321ca85250fdd9b562ac65b60953817971727 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 05:40:38 +0900 Subject: [PATCH 024/132] fix(agentrouter): support openai-chat identity and framing (#2843) Reimplements #2796 by @rrmlima. --- src/adapters/agentrouter.ts | 50 +++++++++++++++++++++++++++ src/adapters/anthropic.ts | 52 +---------------------------- src/adapters/openai-chat.ts | 10 ++++-- tests/openai-chat-hardening.test.ts | 47 ++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 src/adapters/agentrouter.ts diff --git a/src/adapters/agentrouter.ts b/src/adapters/agentrouter.ts new file mode 100644 index 0000000000..908428ef5d --- /dev/null +++ b/src/adapters/agentrouter.ts @@ -0,0 +1,50 @@ +export const AGENTROUTER_LANGUAGE_PREAMBLE = + "[Instruction: Process the user request below and respond in the appropriate language.]"; + +/** Match only AgentRouter itself or one of its subdomains, never a lookalike hostname. */ +export function isAgentRouterEndpoint(baseUrl: string): boolean { + try { + const { hostname } = new URL(baseUrl); + return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org"); + } catch { + return false; + } +} + +/** Supply AgentRouter's stable Codex admission identity unless the operator set one. */ +export function agentRouterDefaultHeaders( + baseUrl: string, + configuredHeaders?: Record, +): Record { + if (!isAgentRouterEndpoint(baseUrl)) return {}; + const hasOriginator = Object.keys(configuredHeaders ?? {}).some(name => name.toLowerCase() === "originator"); + return hasOriginator ? {} : { originator: "codex_cli_rs" }; +} + +/** Prepend the compatibility marker as a distinct block on the first user turn. */ +export function applyAgentRouterLanguageFraming(messages: unknown[]): void { + const firstUser = messages.find( + (message): message is { role: string; content: unknown } => + typeof message === "object" && message !== null && (message as { role?: unknown }).role === "user", + ); + if (!firstUser) return; + const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE }; + if (typeof firstUser.content === "string") { + firstUser.content = firstUser.content === "" + ? [preamble] + : [preamble, { type: "text", text: firstUser.content }]; + return; + } + if (!Array.isArray(firstUser.content)) return; + const [head] = firstUser.content as { type?: unknown; text?: unknown }[]; + if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return; + (firstUser.content as unknown[]).unshift(preamble); +} + +/** Frame only an owned copy so translated and passthrough callers remain unchanged. */ +export function frameAgentRouterMessages(baseUrl: string, messages: unknown): unknown { + if (!isAgentRouterEndpoint(baseUrl) || !Array.isArray(messages)) return messages; + const copy = structuredClone(messages) as unknown[]; + applyAgentRouterLanguageFraming(copy); + return copy; +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index b8312eb453..de4c7df150 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -28,6 +28,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; +import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; /** Map a user content part to an Anthropic content block (text or image source). */ function toAnthropicContentPart(p: OcxContentPart): unknown { @@ -647,57 +648,6 @@ function orphanToolResultText(msg: OcxToolResultMessage): string { * user content, so an Anthropic `system` string cannot reach it — the framing has to sit in the * first user turn. */ -const AGENTROUTER_LANGUAGE_PREAMBLE = - "[Instruction: Process the user request below and respond in the appropriate language.]"; - -/** - * Exact host match, not a substring. - * - * A `hostname.includes("agentrouter")` test also matches `notagentrouter.example` and - * `agentrouter.org.attacker.example`, which would let an unrelated destination silently - * receive an injected instruction block. A prompt mutation keyed on a provider's identity - * must be keyed on that identity exactly. - */ -function isAgentRouterEndpoint(baseUrl: string): boolean { - try { - const { hostname } = new URL(baseUrl); - return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org"); - } catch { - return false; - } -} - -/** - * Prepend the framing as its OWN text block instead of splicing it into the user's string. - * - * The distinction matters: rewriting `content` to `${marker}\n\n${original}` edits what the - * user wrote, and every downstream consumer — logs, retries, an upstream that echoes the turn — - * then sees a sentence the user never typed as if they had. A separate leading block carries the - * same signal to the filter while the original text survives byte-for-byte. - * - * Only the first user turn is framed, because only the first is what the gateway rejects. - */ -function applyAgentRouterLanguageFraming(messages: unknown[]): void { - const firstUser = messages.find( - (m): m is { role: string; content: unknown } => - typeof m === "object" && m !== null && (m as { role?: unknown }).role === "user", - ); - if (!firstUser) return; - const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE }; - if (typeof firstUser.content === "string") { - firstUser.content = firstUser.content === "" - ? [preamble] - : [preamble, { type: "text", text: firstUser.content }]; - return; - } - if (!Array.isArray(firstUser.content)) return; - // Idempotence is keyed on the LEADING block being exactly the marker. A substring test would - // let a user who quotes the marker later in their own prompt suppress the framing entirely. - const [head] = firstUser.content as { type?: unknown; text?: unknown }[]; - if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return; - (firstUser.content as unknown[]).unshift(preamble); -} - function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index f2a36e0490..1f4cb2aa74 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -26,6 +26,7 @@ import { } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; +import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; import { isXaiSchemaTarget, lookupLocalJsonPointer, @@ -87,7 +88,10 @@ function openAIChatTransport(provider: OcxProviderConfig): { if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); } - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { + "Content-Type": "application/json", + ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers), + }; if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; @@ -111,7 +115,7 @@ export function buildOpenAIChatPassthroughRequest( const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, - messages: rawBody.messages, + messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages), stream, }; for (const field of CHAT_PASSTHROUGH_FIELDS) { @@ -1379,7 +1383,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd buildRequest(parsed: OcxParsedRequest) { const { url, headers, hasCredential } = openAIChatTransport(provider); - const messages = messagesToChatFormat(parsed, provider); + const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); const tools = toolsToChatFormatForProvider(parsed, provider); const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index e3ead6e28d..52983f8377 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -19,6 +19,53 @@ afterEach(() => { else process.env.OCX_DEBUG = previousDebug; }); +describe("AgentRouter openai-chat compatibility", () => { + const preamble = "[Instruction: Process the user request below and respond in the appropriate language.]"; + + test("adds a stable Codex originator while preserving operator header precedence", () => { + const automatic = createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(parsed()); + expect(automatic.headers.originator).toBe("codex_cli_rs"); + + const overridden = createOpenAIChatAdapter(provider({ + baseUrl: "https://agentrouter.org/v1", + headers: { Originator: "operator-client" }, + })).buildRequest(parsed()); + expect(overridden.headers.Originator).toBe("operator-client"); + expect(overridden.headers.originator).toBeUndefined(); + }); + + test.each([ + "https://notagentrouter.example/v1", + "https://agentrouter.org.attacker.example/v1", + ])("does not add compatibility behavior to a lookalike host: %s", baseUrl => { + const request = createOpenAIChatAdapter(provider({ baseUrl })).buildRequest(parsed()); + expect(request.headers.originator).toBeUndefined(); + expect(request.body).not.toContain(preamble); + }); + + test("frames translated chat without changing the original parsed request", () => { + const source = parsed(); + source.context.messages[0]!.content = "responda somente: OK"; + const request = createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(source); + const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] }; + expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]); + expect(source.context.messages[0]?.content).toBe("responda somente: OK"); + }); + + test("frames passthrough chat without mutating the caller body", () => { + const rawBody = { messages: [{ role: "user", content: "responda somente: OK" }] }; + const request = buildOpenAIChatPassthroughRequest( + provider({ baseUrl: "https://agentrouter.org/v1" }), + rawBody, + "test-model", + false, + ); + const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] }; + expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]); + expect(rawBody.messages[0]?.content).toBe("responda somente: OK"); + }); +}); + function parsed(): OcxParsedRequest { return { modelId: "test-model", From 9abfabf180f7f8cd6591584ae60cba75402564f5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 05:51:35 +0900 Subject: [PATCH 025/132] fix(grok): clean every removed model reference Derive removed aliases from the previous managed block as well as adopted orphans, then rewrite every Grok model-selector path through one declared inventory. Cover the normal managed exclusion path across all current fields. --- src/grok/inject.ts | 400 ++++++++++++++++++----------- tests/grok-orphan-adoption.test.ts | 54 +++- 2 files changed, 294 insertions(+), 160 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 4cb416efaf..f45c38fdde 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -168,73 +168,6 @@ function tomlContainerEnd(content: string, start: number): number | null { return null; } -/** Locate one top-level string member inside an inline table without scanning nested prose. */ -function tomlInlineStringValueSpan( - content: string, - start: number, - end: number, - targetKey: string, -): { start: number; end: number } | null { - const skipTrivia = (from: number): number => { - let cursor = from; - while (cursor < end) { - while (/[ \t\r\n]/.test(content[cursor] ?? "")) cursor += 1; - if (content[cursor] !== "#") break; - const newline = content.indexOf("\n", cursor); - cursor = newline === -1 || newline >= end ? end : newline + 1; - } - return cursor; - }; - const keyPattern = new RegExp(DOTTED_KEY, "y"); - let entryStart = start + 1; - while (entryStart < end - 1) { - entryStart = skipTrivia(entryStart); - if (entryStart >= end - 1 || content[entryStart] === "}") return null; - keyPattern.lastIndex = entryStart; - const key = keyPattern.exec(content); - if (key === null) return null; - let cursor = skipTrivia(keyPattern.lastIndex); - if (content[cursor] !== "=") return null; - const valueStart = skipTrivia(cursor + 1); - const segments = canonicalDottedKey(key[0]); - if (segments.length === 1 && segments[0] === targetKey) { - const value = tomlStringSpanAt(content, valueStart); - return value === null ? null : { start: valueStart, end: value.end }; - } - - const stack: string[] = []; - cursor = valueStart; - let foundNext = false; - while (cursor < end - 1) { - const char = content[cursor]!; - if (char === "#") { - const newline = content.indexOf("\n", cursor); - cursor = newline === -1 || newline >= end ? end : newline + 1; - continue; - } - if (char === '"' || char === "'") { - const value = tomlStringSpanAt(content, cursor); - if (value === null) return null; - cursor = value.end; - continue; - } - if (char === "{" || char === "[") stack.push(char); - else if (char === "}" || char === "]") { - if (stack.length === 0) return null; - const expected = char === "}" ? "{" : "["; - if (stack.pop() !== expected) return null; - } else if (char === "," && stack.length === 0) { - entryStart = cursor + 1; - foundNext = true; - break; - } - cursor += 1; - } - if (!foundNext) return null; - } - return null; -} - /** * A same-length lexical projection for structural scans. Triple-quoted string bytes become * spaces while line endings and every byte outside those values keep their original offsets. @@ -568,18 +501,40 @@ function removeOrphanTables(content: string, orphans: OrphanTable[]): string { return removeTableRanges(content, orphanRanges(orphans)); } +/** Model aliases and routed ids owned by one complete managed region. */ +function managedModelAliases(content: string, region: ManagedRegion | null): Map { + const models = new Map(); + if (!region) return models; + const structure = analyzeTomlStructure(content); + for (const [position, header] of structure.headers.entries()) { + if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; + if (header.index < region.start || header.index >= region.end) continue; + const bodyEnd = Math.min(structure.headers[position + 1]?.index ?? content.length, region.end); + const modelId = tableBodyKeys(content.slice(header.index + header.length, bodyEnd)).get("model"); + if (modelId !== undefined) models.set(header.segments[1]!, modelId); + } + return models; +} + /** Read one exact path from an already parsed TOML document. */ -function tomlPathString(document: unknown, path: readonly string[]): string | null { +type TomlPathSegment = string | number; + +function tomlPathString(document: unknown, path: readonly TomlPathSegment[]): string | null { let value = document; for (const segment of path) { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - value = (value as Record)[segment]; + if (typeof segment === "number") { + if (!Array.isArray(value)) return null; + value = value[segment]; + } else { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + value = (value as Record)[segment]; + } } return typeof value === "string" ? value : null; } /** Parse a probe document and read one exact semantic path. */ -function parsedTomlPathString(content: string, path: readonly string[]): string | null { +function parsedTomlPathString(content: string, path: readonly TomlPathSegment[]): string | null { try { return tomlPathString(Bun.TOML.parse(content), path); } catch { @@ -587,7 +542,139 @@ function parsedTomlPathString(content: string, path: readonly string[]): string } } -/** Rename or remove the two semantic model references without touching user prose. */ +type ModelReferencePatternSegment = string | "*"; + +interface ModelReferencePath { + path: readonly ModelReferencePatternSegment[]; + /** A structured reference assignment that can be removed whole without losing sibling config. */ + removableContainerPath?: readonly string[]; +} + +/** Grok config values whose strings resolve through the `[model.]` catalog. */ +const MODEL_REFERENCE_PATHS: readonly ModelReferencePath[] = [ + { path: ["models", "default"] }, + { path: ["models", "web_search"] }, + { path: ["models", "session_summary"] }, + { path: ["models", "image_description"] }, + { path: ["models", "prompt_suggestion"] }, + { path: ["ui", "fork_secondary_model"] }, + { path: ["subagents", "models", "*"] }, + { path: ["auto_mode", "classifier_model"] }, + { + path: ["goal", "planner_model", "model"], + removableContainerPath: ["goal", "planner_model"], + }, + { + path: ["goal", "strategist_model", "model"], + removableContainerPath: ["goal", "strategist_model"], + }, + { + path: ["goal", "skeptic_models", "*", "model"], + removableContainerPath: ["goal", "skeptic_models"], + }, +]; + +interface AliasReference { + path: TomlPathSegment[]; + alias: string; + removableContainerPath?: readonly string[]; +} + +function collectAliasReferences(document: unknown): AliasReference[] { + const references: AliasReference[] = []; + const visit = ( + value: unknown, + pattern: readonly ModelReferencePatternSegment[], + patternIndex: number, + path: TomlPathSegment[], + removableContainerPath: readonly string[] | undefined, + ): void => { + if (patternIndex === pattern.length) { + if (typeof value === "string") { + references.push({ + path, + alias: value, + ...(removableContainerPath ? { removableContainerPath } : {}), + }); + } + return; + } + const segment = pattern[patternIndex]!; + if (segment === "*") { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + visit(item, pattern, patternIndex + 1, [...path, index], removableContainerPath); + } + } else if (typeof value === "object" && value !== null) { + for (const [key, item] of Object.entries(value)) { + visit(item, pattern, patternIndex + 1, [...path, key], removableContainerPath); + } + } + return; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) return; + visit( + (value as Record)[segment], + pattern, + patternIndex + 1, + [...path, segment], + removableContainerPath, + ); + }; + + for (const reference of MODEL_REFERENCE_PATHS) { + visit(document, reference.path, 0, [], reference.removableContainerPath); + } + return references; +} + +function sourcePath(path: readonly TomlPathSegment[]): string[] { + return path.filter((segment): segment is string => typeof segment === "string"); +} + +function pathsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((segment, index) => segment === right[index]); +} + +function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean { + return path.length >= prefix.length + && prefix.every((segment, index) => segment === path[index]); +} + +function tomlContainerStringSpans( + content: string, + start: number, + end: number, +): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + for (let index = start + 1; index < end - 1;) { + const char = content[index]!; + if (char === "#") { + const newline = content.indexOf("\n", index); + index = newline === -1 || newline >= end ? end : newline + 1; + continue; + } + if (char === '"' || char === "'") { + const span = tomlStringSpanAt(content, index); + if (span === null || span.end > end) return []; + spans.push({ start: index, end: span.end }); + index = span.end; + continue; + } + index += 1; + } + return spans; +} + +interface AliasReferenceCandidate { + valueStart: number; + valueEnd: number; + assignmentPath: string[]; + directLine: { start: number; end: number } | null; + containerLine: { start: number; end: number } | null; +} + +/** Rename or remove every declared semantic model reference without touching user prose. */ function transformAliasReferences( content: string, replacements: ReadonlyMap, @@ -602,14 +689,12 @@ function transformAliasReferences( "Grok config rewrite refused: Bun could not parse the TOML document safely.", ); } + const references = collectAliasReferences(document); + const targets = references.filter(reference => replacements.has(reference.alias)); + if (targets.length === 0) return content; const structure = analyzeTomlStructure(content); const edits: Array<{ start: number; end: number; replacement: string }> = []; - const candidates: Array<{ - pathKey: "models.default" | "ui.fork_secondary_model"; - valueStart: number; - valueEnd: number; - line: { start: number; end: number } | null; - }> = []; + const candidates: AliasReferenceCandidate[] = []; const assignment = new RegExp(String.raw`^([ \t]*(${DOTTED_KEY})[ \t]*=)`, "gm"); let headerPosition = -1; for (const match of structure.view.matchAll(assignment)) { @@ -618,64 +703,56 @@ function transformAliasReferences( while ((structure.headers[headerPosition + 1]?.index ?? Number.POSITIVE_INFINITY) < assignmentStart) headerPosition += 1; const currentHeader = headerPosition >= 0 ? structure.headers[headerPosition]! : null; - if (currentHeader?.array) continue; if (!allowRootDotted && currentHeader === null) continue; const segments = canonicalDottedKey(match[2]!); - const semanticPath = [...(currentHeader?.segments ?? []), ...segments]; + const assignmentPath = [...(currentHeader?.segments ?? []), ...segments]; let valueStart = assignmentStart + match[1]!.length; while (content[valueStart] === " " || content[valueStart] === "\t") valueStart += 1; - const pathKey = semanticPath.length === 2 && semanticPath[0] === "models" - && semanticPath[1] === "default" - ? "models.default" - : semanticPath.length === 2 && semanticPath[0] === "ui" - && semanticPath[1] === "fork_secondary_model" - ? "ui.fork_secondary_model" - : null; - if (pathKey !== null) { + const directTargets = targets.filter(target => pathsEqual(sourcePath(target.path), assignmentPath)); + if (directTargets.length > 0) { const value = tomlStringSpanAt(content, valueStart); - if (value === null) continue; - const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(value.end)); - if (suffix === null) continue; + if (value !== null) { + const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(value.end)); + if (suffix !== null) { + candidates.push({ + valueStart, + valueEnd: value.end, + assignmentPath, + directLine: { start: assignmentStart, end: value.end + suffix[0].length }, + containerLine: null, + }); + continue; + } + } + } + + const containerTargets = targets.filter(target => + pathStartsWith(sourcePath(target.path), assignmentPath)); + if (containerTargets.length === 0 || (content[valueStart] !== "{" && content[valueStart] !== "[")) continue; + const containerEnd = tomlContainerEnd(content, valueStart); + if (containerEnd === null) continue; + const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(containerEnd)); + if (suffix === null) continue; + const containerLine = { start: assignmentStart, end: containerEnd + suffix[0].length }; + for (const value of tomlContainerStringSpans(content, valueStart, containerEnd)) { candidates.push({ - pathKey, - valueStart, + valueStart: value.start, valueEnd: value.end, - line: { start: assignmentStart, end: value.end + suffix[0].length }, + assignmentPath, + directLine: null, + containerLine, }); - continue; } - - // The only non-line form we support is a root inline table (`models = { ... }` / `ui =`). - const inlineTarget = currentHeader === null && semanticPath.length === 1 - && semanticPath[0] === "models" - ? { pathKey: "models.default" as const, key: "default" } - : currentHeader === null && semanticPath.length === 1 && semanticPath[0] === "ui" - ? { pathKey: "ui.fork_secondary_model" as const, key: "fork_secondary_model" } - : null; - if (inlineTarget === null || content[valueStart] !== "{") continue; - const inlineEnd = tomlContainerEnd(content, valueStart); - if (inlineEnd === null) continue; - const value = tomlInlineStringValueSpan(content, valueStart, inlineEnd, inlineTarget.key); - if (value === null) continue; - candidates.push({ - pathKey: inlineTarget.pathKey, - valueStart: value.start, - valueEnd: value.end, - line: null, - }); } - const targets = [ - { path: ["models", "default"] as const, pathKey: "models.default" as const }, - { path: ["ui", "fork_secondary_model"] as const, pathKey: "ui.fork_secondary_model" as const }, - ]; for (const [targetIndex, target] of targets.entries()) { - const currentAlias = tomlPathString(document, target.path); - if (currentAlias === null || !replacements.has(currentAlias)) continue; - const replacement = replacements.get(currentAlias)!; - const probeCandidates = candidates.filter(candidate => candidate.pathKey === target.pathKey); + const replacement = replacements.get(target.alias)!; + const targetSourcePath = sourcePath(target.path); + const probeCandidates = candidates.filter(candidate => + pathsEqual(candidate.assignmentPath, targetSourcePath) + || pathStartsWith(targetSourcePath, candidate.assignmentPath)); if (probeCandidates.length === 0 && !allowRootDotted) continue; - if (probeCandidates.length === 0 || probeCandidates.length > 32) { + if (probeCandidates.length === 0 || probeCandidates.length > 128) { throw new Error( "Grok config rewrite refused: the model-reference source could not be bounded safely.", ); @@ -683,20 +760,37 @@ function transformAliasReferences( let located = false; for (const candidate of probeCandidates) { let sentinel = `__opencodex_reference_probe_${targetIndex}_${candidate.valueStart}__`; - while (sentinel === currentAlias) sentinel += "_"; + while (sentinel === target.alias) sentinel += "_"; const probe = content.slice(0, candidate.valueStart) + tomlString(sentinel) + content.slice(candidate.valueEnd); if (parsedTomlPathString(probe, target.path) !== sentinel) continue; - if (replacement === null && candidate.line === null) { - throw new Error( - "Grok teardown refused: a model reference uses an inline TOML shape that cannot " - + "be removed without rewriting user-owned bytes.", - ); + if (replacement === null) { + let removal = candidate.directLine; + if (removal === null && candidate.containerLine !== null + && target.removableContainerPath + && pathsEqual(candidate.assignmentPath, target.removableContainerPath)) { + const containerReferences = references.filter(reference => + pathStartsWith(sourcePath(reference.path), candidate.assignmentPath)); + if (containerReferences.length > 0 + && containerReferences.every(reference => replacements.get(reference.alias) === null)) { + removal = candidate.containerLine; + } + } + if (removal === null) { + throw new Error( + "Grok teardown refused: a model reference uses an inline TOML shape that cannot " + + "be removed without rewriting user-owned bytes.", + ); + } + edits.push({ start: removal.start, end: removal.end, replacement: "" }); + } else { + edits.push({ + start: candidate.valueStart, + end: candidate.valueEnd, + replacement: tomlString(replacement), + }); } - edits.push(replacement === null - ? { start: candidate.line!.start, end: candidate.line!.end, replacement: "" } - : { start: candidate.valueStart, end: candidate.valueEnd, replacement: tomlString(replacement) }); located = true; break; } @@ -707,7 +801,8 @@ function transformAliasReferences( } } let next = content; - for (const edit of edits.sort((a, b) => b.start - a.start)) { + const uniqueEdits = new Map(edits.map(edit => [`${edit.start}:${edit.end}:${edit.replacement}`, edit])); + for (const edit of [...uniqueEdits.values()].sort((a, b) => b.start - a.start)) { next = next.slice(0, edit.start) + edit.replacement + next.slice(edit.end); } return next; @@ -874,6 +969,7 @@ export function injectGrokConfig( // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the // entire file. if (originalRegion?.orphaned) return orphanedMarkerResult("injection"); + const previousManagedModels = managedModelAliases(originalContent, originalRegion); // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale // duplicate is replaced instead of routed around forever. Runs inside the normalized @@ -916,28 +1012,24 @@ export function injectGrokConfig( nextContent = `${content}\n${block}\n`; } - // Repoint `default` / `fork_secondary_model` at whichever alias survived. If an - // excluded or removed model has no replacement, clear its references with the same - // TOML-aware transform used by teardown so the new config cannot point at a deleted table. - if (orphans.length > 0) { - const survivors = new Map(); - const structure = analyzeTomlStructure(nextContent); - const managedRegion = findManagedRegion(nextContent); - for (const [position, header] of structure.headers.entries()) { - if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; - if (!managedRegion || header.index < managedRegion.start || header.index >= managedRegion.end) continue; - const alias = header.segments[1]!; - const bodyEnd = structure.headers[position + 1]?.index ?? nextContent.length; - const modelId = tableBodyKeys(nextContent.slice(header.index + header.length, bodyEnd)).get("model"); - if (modelId !== undefined && !survivors.has(modelId)) survivors.set(modelId, alias); - } - const replacements = new Map(); - for (const orphan of orphans) { - const replacement = survivors.get(orphan.modelId) ?? null; - if (replacement !== orphan.alias) replacements.set(orphan.alias, replacement); - } - nextContent = rewriteAliasReferences(nextContent, replacements); + // Repoint every model selector at whichever managed alias survived. Compare both swept + // out-of-fence tables and the PREVIOUS managed block: ordinary exclusion removes only the + // latter, so tying cleanup to `orphans` made the #2830 path dead code. + const nextManagedModels = managedModelAliases(nextContent, findManagedRegion(nextContent)); + const survivors = new Map(); + for (const [alias, modelId] of nextManagedModels) { + if (!survivors.has(modelId)) survivors.set(modelId, alias); + } + const replacements = new Map(); + for (const removed of [ + ...orphans.map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })), + ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })), + ]) { + if (nextManagedModels.get(removed.alias) === removed.modelId) continue; + const replacement = survivors.get(removed.modelId) ?? null; + if (replacement !== removed.alias) replacements.set(removed.alias, replacement); } + nextContent = rewriteAliasReferences(nextContent, replacements); const output = applyEol(nextContent, eol); if (output === rawContent) { diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 381ab94144..fee9bba318 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -58,6 +58,20 @@ describe("Grok orphan adoption (#511)", () => { return [...content.matchAll(/^\[model\.([^\]]+)\]$/gm)].map(match => match[1]!); } + function countStringValue(value: unknown, target: string): number { + if (value === target) return 1; + if (Array.isArray(value)) { + return value.reduce((count, item) => count + countStringValue(item, target), 0); + } + if (typeof value === "object" && value !== null) { + return Object.values(value).reduce( + (count, item) => count + countStringValue(item, target), + 0, + ); + } + return 0; + } + test("adopts the stale entry so exactly one table per model survives", () => { writeOrphanedConfig(); const result = injectGrokConfig(10100, MODELS, { grokHome }); @@ -878,15 +892,44 @@ describe("Grok orphan adoption (#511)", () => { expect(modelTables(readFileSync(configPath, "utf8"))).toEqual([]); }); - test("clears references when an excluded model has no survivor (#2830)", () => { - writeOrphanedConfig([ - OWNERSHIP_MARKER, + test("managed exclusion leaves zero references to a removed model (#2830)", () => { + const alias = "ocx-gpt-5-6-sol"; + writeFileSync(configPath, [ + "[models]", + `default = "${alias}"`, + `web_search = "${alias}"`, + `session_summary = "${alias}"`, + `image_description = "${alias}"`, + `prompt_suggestion = "${alias}"`, "", "[ui]", - 'fork_secondary_model = "ocx-gpt-5-6-sol"', + `fork_secondary_model = "${alias}"`, + "", + "[subagents.models]", + `explore = "${alias}"`, + "", + "[auto_mode]", + `classifier_model = "${alias}"`, + "", + "[goal]", + `planner_model = { model = "${alias}", agent_type = "grok-build-plan" }`, + "", + "[goal.strategist_model]", + `model = "${alias}"`, + 'agent_type = "cursor"', + "", + "[[goal.skeptic_models]]", + `model = "${alias}"`, + 'agent_type = "grok-build-plan"', "", ].join("\n")); + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + const activeContent = readFileSync(configPath, "utf8"); + expect(modelTables(activeContent)).toEqual([alias]); + expect(countStringValue(Bun.TOML.parse(activeContent), alias)).toBe(11); + expect(injectGrokConfig(10100, MODELS, { grokHome, excluded: new Set(["gpt-5.6-sol"]), @@ -894,8 +937,7 @@ describe("Grok orphan adoption (#511)", () => { const content = readFileSync(configPath, "utf8"); expect(modelTables(content)).toEqual([]); - expect(content).not.toContain('default = "ocx-gpt-5-6-sol"'); - expect(content).not.toContain('fork_secondary_model = "ocx-gpt-5-6-sol"'); + expect(countStringValue(Bun.TOML.parse(content), alias)).toBe(0); }); // F7: the sweep must converge, or `changed` is meaningless to callers. From c986d1d208169629f5db2736b7d4723e85719acf Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 05:58:25 +0900 Subject: [PATCH 026/132] fix(security): reach the fake-IP opt-in for explicit-zero mapped answers (#2842) Issue #2810: a Clash/Surge/Mihomo fake-IP resolver can answer with ::ffff:0:c612:1b -- the explicit-zero spelling of 198.18.0.27. That form assesses as "non-global address", never "benchmark address", so the allowBenchmarkAddresses opt-in could not reach it and those users were refused outright. The fix is deliberately NOT an equivalence in classifyIpv6. Under RFC 4291 the IPv4-mapped prefix is ::ffff:0:0/96, so ::ffff:0:: is a reserved address whose tail merely looks like an IPv4. Declaring the two equal would admit ::ffff:0:5db8:d822 (tail 93.184.216.34) as a public destination -- the blocker raised on #2812. Instead the benchmark gate itself recognises the explicit-zero encoding and checks the decoded tail against the 198.18.0.0/15 benchmark range only. classifyIpv6 is unchanged, so a public-looking tail stays blocked and a user-configured literal URL is still refused with or without the opt-in. Reimplements #2812 by @gaoran1209 with the maintainer's blocker addressed. Closes #2810. --- src/lib/destination-policy.ts | 47 +++++++++++-- tests/destination-policy-resolved.test.ts | 86 +++++++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index d552b78065..10dabbf8bf 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -107,6 +107,44 @@ function ipv6Hextets(hostname: string): number[] | null { /** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */ const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const; +/** + * `0:0:0:0:ffff:0::/96` — the explicit-zero spelling of a mapped IPv4 that some DNS resolvers + * return, e.g. `::ffff:0:c612:1b` for `198.18.0.27`. + * + * This is deliberately NOT taught to `classifyIpv6`. Under RFC 4291 the mapped prefix is + * `::ffff:0:0/96`, so `::ffff:0:c612:1b` is a reserved address whose tail merely LOOKS like an + * IPv4 — it is not equivalent to `198.18.0.27`. Treating the two as equal in the general + * classifier would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a public destination, + * which is the merge blocker a maintainer raised on #2812. + * + * The reported symptom is narrower than that equivalence: on a fake-IP resolver the answer + * assesses as `non-global address`, so the `allowBenchmarkAddresses` exception — which exists + * precisely for Clash/Surge/Mihomo fake-IP — could never be reached for this spelling. The fix + * therefore lives inside that opt-in, and only for a tail that is itself in `198.18.0.0/15`. + */ +const EXPLICIT_ZERO_MAPPED_PREFIX = [0, 0, 0, 0, 0xffff, 0] as const; + +/** + * True when this DNS answer may pass the `allowBenchmarkAddresses` opt-in. + * + * Ordinary benchmark answers (IPv4 `198.18/19`, canonical `::ffff:198.18.0.27`, and the NAT64 + * form) already carry `detail: "benchmark address"` and pass through the first branch. The + * second branch adds ONLY the explicit-zero spelling, and only when its embedded quad is itself + * a benchmark address — so a public, loopback, private, or metadata-looking tail is refused. + */ +function isBenchmarkDnsAnswer(address: string, assessment: DestinationAssessment | null): boolean { + if (assessment?.kind === "private" && assessment.detail === "benchmark address") return true; + if (isIP(address) !== 6) return false; + if (assessment?.kind !== "private" || assessment.detail !== "non-global address") return false; + const hextets = ipv6Hextets(normalizeHostname(address)); + if (!hextets) return false; + if (!EXPLICIT_ZERO_MAPPED_PREFIX.every((group, index) => hextets[index] === group)) return false; + const hi = hextets[6]!; + const lo = hextets[7]!; + const embedded = classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`); + return embedded.kind === "private" && embedded.detail === "benchmark address"; +} + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -303,11 +341,7 @@ export async function providerDestinationResolvedError( const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind === "public") continue; // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject. - if ( - options?.allowBenchmarkAddresses - && assessment.kind === "private" - && assessment.detail === "benchmark address" - ) { + if (options?.allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) { continue; } if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`; @@ -406,7 +440,7 @@ export async function resolvePublicAddresses( // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and // do not mark the destination private, so the caller's HTTP(S)_PROXY path // still applies (credit #1748). - if (benchmarkAllowed && assessment?.kind === "private" && assessment.detail === "benchmark address") { + if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) { validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); continue; } @@ -430,4 +464,3 @@ export async function resolvePublicAddresses( export async function assertUrlResolvesPublic(url: string): Promise { await resolvePublicAddresses(url); } - diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 207f73ec8c..0269dfc457 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -296,3 +296,89 @@ describe("providerDestinationConfigError — NAT64 well-known prefix (RFC 6052)" expect(providerDestinationConfigError("p", provider("https://[2001:db8::1]/v1"))).toContain("documentation"); }); }); + +/** + * Issue #2810: a fake-IP resolver answers `::ffff:0:c612:1b` — the explicit-zero spelling of + * `198.18.0.27`. That assesses as `non-global address`, never `benchmark address`, so the + * `allowBenchmarkAddresses` opt-in could not reach it and Clash/Surge users behind fake-IP were + * refused. + * + * The fix is deliberately NOT an equivalence in `classifyIpv6`. Under RFC 4291 the mapped prefix + * is `::ffff:0:0/96`, so `::ffff:0::` is a RESERVED address whose tail merely looks like + * an IPv4. Declaring them equal would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a + * public destination — the blocker a maintainer raised on #2812. Both directions are pinned here. + */ +describe("#2810 explicit-zero mapped benchmark answers under the fake-IP opt-in", () => { + const OPT_IN = { context: "p", allowBenchmarkAddresses: true } as const; + + test("the reported answer is accepted and stays non-private", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.addresses).toEqual([{ address: "::ffff:0:c612:1b", family: 6 }]); + expect(resolved.privateNetwork).toBe(false); + }); + + test("both benchmark range boundaries are accepted", async () => { + // 198.18.0.0 and 198.19.255.255 + for (const address of ["::ffff:0:c612:0", "::ffff:0:c613:ffff"]) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.addresses).toEqual([{ address, family: 6 }]); + } + }); + + test("THE BLOCKER: a public-looking tail is still refused", async () => { + // ::ffff:0:5db8:d822 has the tail 93.184.216.34. If the classifier treated the explicit-zero + // form as a mapped IPv4, this reserved address would be admitted as a public destination. + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:5db8:d822", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + }); + + test("loopback-, metadata-, and out-of-range tails are refused", async () => { + const refused = [ + "::ffff:0:7f00:1", // 127.0.0.1 + "::ffff:0:a9fe:a9fe", // 169.254.169.254 + "::ffff:0:c611:ffff", // 198.17.255.255, just below the range + "::ffff:0:c614:0", // 198.20.0.0, just above the range + "::ffff:0:a00:5", // 10.0.0.5 + ]; + for (const address of refused) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + } + }); + + test("without the opt-in the reported answer is refused", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", "p")).rejects.toThrow("non-global"); + }); + + test("a literal URL is still refused, opt-in or not", () => { + // The opt-in is a DNS-answer exception. A user-configured literal never reaches it. + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:c612:1b]/v1"))) + .toContain("non-global"); + expect(providerDestinationConfigError("p", provider("https://[::ffff:0:5db8:d822]/v1"))) + .toContain("non-global"); + }); + + test("a prefix that is one hextet off is not decoded", async () => { + lookupMock.mockResolvedValueOnce([{ address: "::ffff:1:c612:1b", family: 6 }]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow("non-global"); + }); + + test("one accepted answer cannot smuggle a private companion answer", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "::ffff:0:c612:1b", family: 6 }, + { address: "10.0.0.5", family: 4 }, + ]); + await expect(resolvePublicAddresses("https://api.example.com/v1", OPT_IN)).rejects.toThrow(); + }); + + test("the canonical spelling and ordinary IPv4 benchmark answers still work", async () => { + for (const address of ["::ffff:198.18.0.27", "198.18.0.27"]) { + lookupMock.mockResolvedValueOnce([{ address, family: address.includes(":") ? 6 : 4 }]); + const resolved = await resolvePublicAddresses("https://api.example.com/v1", OPT_IN); + expect(resolved.privateNetwork).toBe(false); + } + }); +}); From 1a73b7a11312782f05824666515683271f735ca8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 06:06:39 +0900 Subject: [PATCH 027/132] fix(grok): clean inline subagent model references Extend managed-model cleanup to role and persona model selectors, and cover the complete inline selector matrix with a zero-dangling-reference regression. --- src/grok/inject.ts | 2 ++ tests/grok-orphan-adoption.test.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index f45c38fdde..5412dcfe5a 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -559,6 +559,8 @@ const MODEL_REFERENCE_PATHS: readonly ModelReferencePath[] = [ { path: ["models", "prompt_suggestion"] }, { path: ["ui", "fork_secondary_model"] }, { path: ["subagents", "models", "*"] }, + { path: ["subagents", "roles", "*", "model"] }, + { path: ["subagents", "personas", "*", "model"] }, { path: ["auto_mode", "classifier_model"] }, { path: ["goal", "planner_model", "model"], diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index fee9bba318..a35c532012 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -908,6 +908,14 @@ describe("Grok orphan adoption (#511)", () => { "[subagents.models]", `explore = "${alias}"`, "", + "[subagents.roles.reviewer]", + `model = "${alias}"`, + 'description = "Review code"', + "", + "[subagents.personas.concise]", + `model = "${alias}"`, + 'instructions = "Be concise"', + "", "[auto_mode]", `classifier_model = "${alias}"`, "", @@ -928,7 +936,7 @@ describe("Grok orphan adoption (#511)", () => { .toMatchObject({ ok: true, changed: true }); const activeContent = readFileSync(configPath, "utf8"); expect(modelTables(activeContent)).toEqual([alias]); - expect(countStringValue(Bun.TOML.parse(activeContent), alias)).toBe(11); + expect(countStringValue(Bun.TOML.parse(activeContent), alias)).toBe(13); expect(injectGrokConfig(10100, MODELS, { grokHome, @@ -938,6 +946,8 @@ describe("Grok orphan adoption (#511)", () => { const content = readFileSync(configPath, "utf8"); expect(modelTables(content)).toEqual([]); expect(countStringValue(Bun.TOML.parse(content), alias)).toBe(0); + expect(content).toContain('[subagents.roles.reviewer]\ndescription = "Review code"'); + expect(content).toContain('[subagents.personas.concise]\ninstructions = "Be concise"'); }); // F7: the sweep must converge, or `changed` is meaningless to callers. From d2e992072340cd96e812f46d0e73328766c63407 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 29 Aug 2026 06:08:27 +0900 Subject: [PATCH 028/132] fix(shadow-call): reject self-intercept targets (#2849) * fix(shadow-call): reject self-intercept targets * docs(shadow-call): document preserved reasoning effort --- .../docs/fr/reference/configuration/server.md | 4 +- .../docs/ja/reference/configuration/server.md | 4 +- .../docs/ko/reference/configuration/server.md | 4 +- .../docs/reference/configuration/server.md | 5 +- .../docs/ru/reference/configuration/server.md | 4 +- .../docs/tr/reference/configuration/server.md | 7 +- .../zh-cn/reference/configuration/server.md | 4 +- .../zh-tw/reference/configuration/server.md | 4 +- docs/shadow-call-intercept.md | 5 +- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/pages/Models.tsx | 8 +- gui/src/pages/dashboard-overview-sections.tsx | 2 +- gui/src/pages/dashboard-shared.ts | 21 +++- gui/tests/shadow-call-model-options.test.ts | 23 +++- src/lib/shadow-call.ts | 24 +++- src/server/management/config-routes.ts | 27 ++++- src/server/responses/core.ts | 67 +++++----- src/types/config.ts | 5 +- tests/responses-shadow-intercept.test.ts | 114 ++++++++++++++++-- 26 files changed, 263 insertions(+), 85 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 071c8d86d5..ae0b6f3a58 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -25,7 +25,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | `codexAutoStart?` | `boolean` | `true` | Autorise le lanceur intermédiaire Codex à exécuter `ocx ensure` avant de démarrer Codex. Avec la valeur false, cette vérification ne fait rien. | | `codexShimAutoRestore?` | `boolean` | `true` | Restaure le lanceur intermédiaire installé après son remplacement par une mise à jour externe de Codex terminée. Désactivation par variable d'environnement : `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Compatibilité historique Codex App réversible. Les métadonnées originales sont sauvegardées et restaurées par `ocx stop` / `ocx restore`. | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | désactivé | Redirigez les appels Codex helper/shadow reconnus vers un modèle choisi avec peu d'effort. Le préfixe source par défaut est `gpt-5.6-luna` ; les clients plus anciens via 0.144.x utilisaient `gpt-5.4-mini`, que `sourceModels` peut restaurer. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | désactivé | Redirigez les appels Codex helper/shadow reconnus vers un modèle choisi tout en conservant l'effort de raisonnement configuré pour la requête. Le préfixe source par défaut est `gpt-5.6-luna` ; les clients plus anciens via 0.144.x utilisaient `gpt-5.4-mini`, que `sourceModels` peut restaurer. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | activé lorsqu'il est utilisable | Options du service auxiliaire de recherche Web. | | `visionSidecar?` | `OcxVisionSidecarConfig` | activé lorsqu'il est utilisable | Options du service auxiliaire de description d'images. | | `images?` | `OcxImagesConfig` | sélection automatique OpenAI | Options de relais d'images autonomes pour Codex `image_gen`. | @@ -177,7 +177,7 @@ l'abonnement avec un avertissement lorsque la détection n'est pas concluante. V Codex utilise de petits modèles auxiliaires pour des tâches telles que les titres et les messages de commit. Activez `shadowCallIntercept` pour rediriger les préfixes de modèle source reconnus vers un autre modèle configuré. Le -modèle de remplacement s'exécute avec un faible effort. Définissez `sourceModels` uniquement lorsqu'un client utilise d'autres identifiants de modèles auxiliaires. +modèle de remplacement conserve l'effort de raisonnement configuré pour la requête. Définissez `sourceModels` uniquement lorsqu'un client utilise d'autres identifiants de modèles auxiliaires. Codex 0.145.0+ indique l'objet de la requête dans `x-codex-turn-metadata` : les requêtes normales portant `request_kind: "turn"` conservent le modèle sélectionné, tandis que les requêtes de maintenance reconnues peuvent être redirigées. Les clients qui ne fournissent pas ces métadonnées conservent le comportement historique fondé sur le préfixe. diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index d31c8db6fb..85e6063b8f 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -24,7 +24,7 @@ description: リスナー、リモート アクセス、アドミッション | `codexAutoStart?` | `boolean` | `true` | Codex を起動する前に、Codex シムで `ocx ensure` を実行させます。 False を指定すると、操作が行われないことが保証されます。 | | `codexShimAutoRestore?` | `boolean` | `true` |完了した外部 Codex アップデートによってインストールされたシムが置き換えられた後、インストールされているシムを復元します。環境オプトアウト: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | Codex App 履歴の互換性を元に戻すことができます。元のメタデータは `ocx stop` / `ocx restore` によってバックアップおよび復元されます。 | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` |オフ |認識された Codex ヘルパー/シャドウ呼び出しを、少ない労力で選択したモデルにリダイレクトします。デフォルトのソースプレフィックスは `gpt-5.6-luna` です。0.144.x 以前のクライアントでは `gpt-5.4-mini` が使われており、`sourceModels` で復元できます。 | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` |オフ |認識された Codex ヘルパー/シャドウ呼び出しを、リクエストに設定された推論エフォートを維持したまま選択したモデルにリダイレクトします。デフォルトのソースプレフィックスは `gpt-5.6-luna` です。0.144.x 以前のクライアントでは `gpt-5.4-mini` が使われており、`sourceModels` で復元できます。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` |使用可能な場合はオン | Web 検索サイドカー オプション。 | | `visionSidecar?` | `OcxVisionSidecarConfig` |使用可能な場合はオン |画像説明サイドカー オプション。 | | `images?` | `OcxImagesConfig` | OpenAI の自動選択 | Codex `image_gen` のスタンドアロン イメージ リレー オプション。 | @@ -106,7 +106,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## シャドウコール -Codex は、タイトルやコミット メッセージなどのタスクに小さなヘルパー モデルを使用します。 `shadowCallIntercept` を有効にして、認識されたソース モデル プレフィックスを別の構成済みモデルにリダイレクトします。交換作業は少ない労力で実行されます。クライアントが異なるヘルパー ID を使用する場合にのみ、`sourceModels` を設定します。 +Codex は、タイトルやコミット メッセージなどのタスクに小さなヘルパー モデルを使用します。 `shadowCallIntercept` を有効にして、認識されたソース モデル プレフィックスを別の構成済みモデルにリダイレクトします。置換後も、リクエストに設定された推論エフォートは維持されます。クライアントが異なるヘルパー ID を使用する場合にのみ、`sourceModels` を設定します。 ```json { diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 79caa1fe87..8e1fc14842 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -24,7 +24,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex를 실행하기 전에 `ocx ensure`를 돌리도록 허용합니다. `false`이면 ensure는 아무 작업도 하지 않습니다. | | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 설치된 shim을 교체한 뒤 복원합니다. 환경 변수로 끌 수 있습니다: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App history 호환성입니다. 원래 메타데이터는 `ocx stop` / `ocx restore`가 백업하고 복원합니다. | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 인식된 Codex 보조/섀도 호출을 선택한 모델로 낮은 노력 수준에서 다시 보냅니다. 기본 source prefix는 `gpt-5.6-luna`입니다. 0.144.x 이하의 이전 클라이언트는 `gpt-5.4-mini`를 사용했으며 `sourceModels`로 복원할 수 있습니다. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 인식된 Codex 보조/섀도 호출을 요청에 설정된 reasoning effort를 유지한 채 선택한 모델로 다시 보냅니다. 기본 source prefix는 `gpt-5.6-luna`입니다. 0.144.x 이하의 이전 클라이언트는 `gpt-5.4-mini`를 사용했으며 `sourceModels`로 복원할 수 있습니다. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | 웹 검색 사이드카 옵션입니다. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | 이미지 설명 사이드카 옵션입니다. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Codex `image_gen`용 독립형 Images 릴레이 옵션입니다. | @@ -106,7 +106,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Shadow calls -Codex는 제목과 커밋 메시지 같은 작업에 작은 보조 모델을 사용합니다. 인식된 source-model prefix를 다른 구성된 모델로 돌리려면 `shadowCallIntercept`를 활성화합니다. 대체 호출은 낮은 노력 수준으로 실행됩니다. 클라이언트가 다른 helper id를 사용할 때만 `sourceModels`를 설정합니다. +Codex는 제목과 커밋 메시지 같은 작업에 작은 보조 모델을 사용합니다. 인식된 source-model prefix를 다른 구성된 모델로 돌리려면 `shadowCallIntercept`를 활성화합니다. 대체 호출은 요청에 설정된 reasoning effort를 유지합니다. 클라이언트가 다른 helper id를 사용할 때만 `sourceModels`를 설정합니다. ```json { diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 790ea2dc30..ab318f92b9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -27,7 +27,7 @@ runs helper features around provider requests. | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model at low effort. The default source prefix is `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefix is `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | @@ -187,7 +187,8 @@ subscription with a warning when detection is inconclusive. See Codex uses small helper models for tasks such as titles and commit messages. Enable `shadowCallIntercept` to redirect recognized source-model prefixes to another configured model. The -replacement runs at low effort. Set `sourceModels` only when a client uses different helper ids. +replacement keeps the request's configured reasoning effort. Set `sourceModels` only when a client +uses different helper ids. Codex 0.145.0+ marks request purpose in `x-codex-turn-metadata`: normal `request_kind: "turn"` requests keep the selected model, while recognized maintenance requests can be redirected. Clients without that metadata retain the legacy prefix behavior. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 3e650d0b7f..103153bf4f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -25,7 +25,7 @@ description: Listener, удалённый доступ, admission key, тайм | `codexAutoStart?` | `boolean` | `true` | Разрешает shim'у Codex запускать `ocx ensure` перед стартом Codex. При false `ensure` становится no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает установленный shim после завершённого внешнего обновления Codex, которое заменило его. Для отключения через окружение: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. Исходные metadata резервируются и восстанавливаются через `ocx stop` / `ocx restore`. | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Перенаправляет распознанные helper/shadow-call'ы Codex на выбранную модель с low effort. Source-prefix по умолчанию: `gpt-5.6-luna`; клиенты до 0.144.x включительно использовали `gpt-5.4-mini`, который можно восстановить через `sourceModels`. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Перенаправляет распознанные helper/shadow-call'ы Codex на выбранную модель с сохранением настроенного для запроса reasoning effort. Source-prefix по умолчанию: `gpt-5.6-luna`; клиенты до 0.144.x включительно использовали `gpt-5.4-mini`, который можно восстановить через `sourceModels`. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Настройки sidecar'а web-search. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Настройки sidecar'а описания изображений. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Настройки standalone Images relay для Codex `image_gen`. | @@ -132,7 +132,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote Codex использует маленькие helper-model'и для задач вроде заголовков и commit message. Включите `shadowCallIntercept`, чтобы перенаправлять распознанные `sourceModels` на другую настроенную -модель. Замещающая модель работает с low effort. `sourceModels` задавайте только если клиент +модель. Замещающая модель сохраняет настроенный для запроса reasoning effort. `sourceModels` задавайте только если клиент использует другие helper-id. ```json diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 9462983969..fef020194f 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -26,7 +26,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | `codexAutoStart?` | `boolean` | `true` | Codex dolgusunun Codex'i başlatmadan önce `ocx ensure` çalıştırmasına izin verin. False, ensure'ı bir işlem yapmayan (no-op) hale getirir. | | `codexShimAutoRestore?` | `boolean` | `true` | Tamamlanan harici bir Codex güncellemesi değiştirdikten sonra kurulu bir dolguyu geri yükleyin. Ortam vazgeçmesi: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Tersine çevrilebilir Codex App geçmişi uyumluluğu. Orijinal meta veriler yedeklenir ve `ocx stop` / `ocx restore` tarafından geri yüklenir. | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | kapalı | Tanınan Codex yardımcı/gölge çağrılarını düşük çabayla seçilen bir modele yeniden yönlendirin. Varsayılan kaynak öneki `gpt-5.6-luna`'dır; 0.144.x'e kadar olan eski istemciler `sourceModels`'ın geri yükleyebileceği `gpt-5.4-mini` kullanmıştır. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | kapalı | Tanınan Codex yardımcı/gölge çağrılarını, istek için yapılandırılan akıl yürütme çabasını koruyarak seçilen bir modele yeniden yönlendirin. Varsayılan kaynak öneki `gpt-5.6-luna`'dır; 0.144.x'e kadar olan eski istemciler `sourceModels`'ın geri yükleyebileceği `gpt-5.4-mini` kullanmıştır. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | kullanılabilir olduğunda açık | Web arama sidecar seçenekleri. | | `visionSidecar?` | `OcxVisionSidecarConfig` | kullanılabilir olduğunda açık | Görsel açıklama sidecar seçenekleri. | | `images?` | `OcxImagesConfig` | otomatik OpenAI seçimi | Codex `image_gen` için bağımsız Görseller aktarma seçenekleri. | @@ -196,8 +196,9 @@ modu](/tr/guides/claude-code/#auth-mode). ## Gölge çağrılar Codex, başlıklar ve commit mesajları gibi görevler için küçük yardımcı modeller -kullanır. Tanınan kaynak model öneklerini düşük çabayla yapılandırılmış başka -bir modele yeniden yönlendirmek için `shadowCallIntercept`'i etkinleştirin. +kullanır. Tanınan kaynak model öneklerini yapılandırılmış başka bir modele yeniden +yönlendirmek için `shadowCallIntercept`'i etkinleştirin. Değiştirilen istek, yapılandırılmış +akıl yürütme çabasını korur. `sourceModels`'ı yalnızca bir istemci farklı yardımcı kimlikleri kullandığında ayarlayın. Codex 0.145.0+, istek amacını `x-codex-turn-metadata` içinde işaretler: normal `request_kind: "turn"` istekleri seçilen modeli tutarken diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index c9753f58bb..2f20d35a9f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -25,7 +25,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 之前运行 `ocx ensure`。设为 false 会让 ensure 变成无操作。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成外部 Codex 更新并覆盖安装的 shim 之后恢复该 shim。环境退出开关:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容性。原始元数据会被备份,并由 `ocx stop` / `ocx restore` 恢复。 | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 将识别出的 Codex 辅助/影子调用以低努力级别重定向到选定模型。默认源前缀为 `gpt-5.6-luna`;0.144.x 及更早客户端使用 `gpt-5.4-mini`,可通过 `sourceModels` 恢复。 | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 将识别出的 Codex 辅助/影子调用重定向到选定模型,并保留为请求配置的推理强度。默认源前缀为 `gpt-5.6-luna`;0.144.x 及更早客户端使用 `gpt-5.4-mini`,可通过 `sourceModels` 恢复。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 在可用时启用 | Web 搜索侧车选项。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 在可用时启用 | 图像描述侧车选项。 | | `images?` | `OcxImagesConfig` | 自动选择 OpenAI | 用于 Codex `image_gen` 的独立 Images 转发选项。 | @@ -118,7 +118,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## 影子调用 Codex 会为标题、提交信息等任务使用较小的辅助模型。启用 -`shadowCallIntercept` 后,可将识别出的源模型前缀重定向到另一个已配置模型。替换会以低努力级别运行。只有当客户端使用不同的辅助 ID 时,才设置 `sourceModels`。 +`shadowCallIntercept` 后,可将识别出的源模型前缀重定向到另一个已配置模型。替换后仍会保留为请求配置的推理强度。只有当客户端使用不同的辅助 ID 时,才设置 `sourceModels`。 ```json { diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index f8e7689c2c..90e3256283 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -24,7 +24,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `codexAutoStart?` | `boolean` | `true` | 讓 Codex shim 在啟動 Codex 前執行 `ocx ensure`。False 使 ensure 為 no-op。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成的外部 Codex 更新取代已安裝的 shim 後還原它。環境退出:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 歷史相容性。原始中繼資料由 `ocx stop` / `ocx restore` 備份並還原。 | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 將識別的 Codex helper/shadow call 重定向到所選模型,以低 effort 執行。預設來源前綴為 `gpt-5.4-mini` 與 `gpt-5.6-luna`。 | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 將識別的 Codex helper/shadow call 重定向到所選模型,並保留為請求設定的 reasoning effort。預設來源前綴為 `gpt-5.4-mini` 與 `gpt-5.6-luna`。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 可用時開啟 | 網頁搜尋 sidecar 選項。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 可用時開啟 | 圖片描述 sidecar 選項。 | | `images?` | `OcxImagesConfig` | 自動 OpenAI 選擇 | Codex `image_gen` 的獨立 Images 中繼選項。 | @@ -140,7 +140,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Shadow call -Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟用 `shadowCallIntercept` 以將識別的來源模型前綴重定向到另一個已設定的模型。替換以低 effort 執行。僅在客戶端使用不同的 helper id 時設定 `sourceModels`。 +Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟用 `shadowCallIntercept` 以將識別的來源模型前綴重定向到另一個已設定的模型。替換後仍會保留為請求設定的 reasoning effort。僅在客戶端使用不同的 helper id 時設定 `sourceModels`。 ```json { diff --git a/docs/shadow-call-intercept.md b/docs/shadow-call-intercept.md index ff527c87df..7e73c35d06 100644 --- a/docs/shadow-call-intercept.md +++ b/docs/shadow-call-intercept.md @@ -8,7 +8,8 @@ Codex Desktop App makes background API calls with a hard-coded helper model for - **Commit message generation** — generates git commit messages - **Skill orchestration** — internal orchestration turns -These calls happen independently of your selected main model and use `reasoningEffort: low`. +These calls happen independently of your selected main model. When intercepted, the request keeps +its configured reasoning effort. The helper model is not stable across client versions. Codex used `gpt-5.4-mini` up to 0.144.x and moved to `gpt-5.6-luna` in 0.145.0, which silently disabled a single-literal intercept @@ -70,7 +71,7 @@ the defaults rather than extending them: - Headerless legacy clients retain the original prefix behavior: matching bare model ids are rewritten - Missing, malformed, or unrecognized turn metadata retains the legacy prefix behavior -- Reasoning effort is forced to `low` (matching the original behavior) +- The request's configured reasoning effort is preserved - The original model ID is logged as `shadowCallRewrittenFrom` in request logs - When disabled (default), no interception occurs diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f849c9be0a..0913f2ad47 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -291,7 +291,7 @@ export const de: Record = { "dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.", "dash.visionOff": "Aus", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", - "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um. Effort wird auf low fixiert.", + "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.", "dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Ersatzmodell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0e809578bd..8f1f1333d3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -309,7 +309,7 @@ export const en = { "dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.", "dash.visionAdvancedPopover": "Advanced vision settings", "dash.shadowCallIntercept": "Shadow Call Intercept", - "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model. Effort is fixed to low.", + "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.", "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Replacement model", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b552df536e..fedc4698be 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -299,7 +299,7 @@ export const fr: Record = { "dash.visionTimeoutInvalid": "Saisissez un entier compris entre {min} et {max} millisecondes.", "dash.visionAdvancedPopover": "Paramètres de vision avancés", "dash.shadowCallIntercept": "Interception des appels fantômes", - "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi. L’effort est fixé à faible.", + "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.", "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Modèle de remplacement", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 82e0281f8b..646faf0560 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -300,7 +300,7 @@ export const ja: Record = { "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", "dash.visionOff": "オフ", "dash.shadowCallIntercept": "シャドウコール傍受", - "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。負荷は low に固定されます。", + "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", "dash.shadowCallOriginal": "元のモデル", "dash.shadowCallModel": "差し替えモデル", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 0a0c58fdd0..00edec94d7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -295,7 +295,7 @@ export const ko: Record = { "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", "dash.visionOff": "끔", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", - "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다. effort는 low로 고정됩니다.", + "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.", "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", "dash.shadowCallOriginal": "원본", "dash.shadowCallModel": "대체 모델", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 94e2dbc333..d4a6e3e142 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -300,7 +300,7 @@ export const ru: Record = { "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", "dash.visionOff": "Выкл", "dash.shadowCallIntercept": "Перехват теневых вызовов", - "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель. Уровень рассуждений жёстко задан как low.", + "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.", "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", "dash.shadowCallOriginal": "Оригинал", "dash.shadowCallModel": "Модель-замена", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index d0f337d05e..6effb92e0f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -194,7 +194,7 @@ export const zhTW: Record = { "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", "dash.visionOff": "關閉", "dash.shadowCallIntercept": "影子呼叫攔截", - "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。effort 固定為 low。", + "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", "dash.shadowCallOriginal": "原始", "dash.shadowCallModel": "替代模型", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aaa31df4e2..b2cb24f862 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -295,7 +295,7 @@ export const zh: Record = { "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", "dash.visionOff": "关闭", "dash.shadowCallIntercept": "影子调用拦截", - "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。effort 固定为 low。", + "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。", "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", "dash.shadowCallOriginal": "原始", "dash.shadowCallModel": "替代模型", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index b29c06e808..627894e67f 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -358,8 +358,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); const shadowCallOptions = useMemo(() => { const activeNamespaced = new Set(shadowModelOptions.map(option => option.value)); - return shadowCallModelOptions(models.filter(model => activeNamespaced.has(model.namespaced)), shadowCall?.model); - }, [models, shadowCall?.model, shadowModelOptions]); + return shadowCallModelOptions( + models.filter(model => activeNamespaced.has(model.namespaced)), + shadowCall?.model, + shadowCall?.sourceModels, + ); + }, [models, shadowCall?.model, shadowCall?.sourceModels, shadowModelOptions]); const loadShadowCall = useCallback(async () => { const bounded = createBoundedFetch(15_000); diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 6b944ed5eb..971cc3a64f 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -649,7 +649,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { - {strategy === "round-robin" && ( + {(strategy === "round-robin" || strategy === "random") && ( updateDraft((d) => ({ ...d, strategy }))} />

- {draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")} + {draft.strategy === "failover" + ? t("cws.strategy.failoverHint") + : draft.strategy === "round-robin" + ? t("cws.strategy.roundRobinHint") + : null}

@@ -363,7 +367,11 @@ export function DetailPanel({ onChange={(targets) => updateDraft((d) => ({ ...d, targets }))} />

- {draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")} + {draft.strategy === "failover" + ? t("cws.targets.failoverHint") + : draft.strategy === "round-robin" + ? t("cws.targets.roundRobinHint") + : null}

save must not rewrite a combo's strategy. + * + * The runtime and management API accept five strategies. The GUI parser used to + * collapse random/least-used/reset-window to failover, so saving an untouched + * combo silently rewrote its strategy (and stripped weights for random). + */ +import { expect, test } from "bun:test"; +import { parseComboList, toPutBody } from "../src/combo-workspace-data"; + +const strategies = ["failover", "round-robin", "random", "least-used", "reset-window"] as const; + +function payloadWith(strategy: unknown, weight?: number) { + return { + combos: [ + { + id: "demo", + model: "combo/demo", + strategy, + stickyLimit: 3, + targets: [ + weight !== undefined + ? { provider: "openai", model: "gpt-5", weight } + : { provider: "openai", model: "gpt-5" }, + ], + }, + ], + }; +} + +test("parse preserves every runtime strategy", () => { + for (const strategy of strategies) { + const [item] = parseComboList(payloadWith(strategy)); + expect(item?.strategy).toBe(strategy); + } +}); + +test("unknown or missing strategies still normalize to failover", () => { + for (const raw of [undefined, "sticky", 42]) { + const [item] = parseComboList(payloadWith(raw)); + expect(item?.strategy).toBe("failover"); + } +}); + +test("saving an untouched combo round-trips merged strategies and random weights", () => { + const [randomCombo] = parseComboList(payloadWith("random", 7)); + expect(randomCombo).toBeDefined(); + const randomBody = toPutBody(randomCombo!); + expect(randomBody.combo.strategy).toBe("random"); + expect(randomBody.combo.targets[0]).toEqual({ provider: "openai", model: "gpt-5", weight: 7 }); + expect(randomBody.combo.stickyLimit).toBeUndefined(); + + const [leastUsed] = parseComboList(payloadWith("least-used")); + expect(toPutBody(leastUsed!).combo.strategy).toBe("least-used"); + + const [resetWindow] = parseComboList(payloadWith("reset-window")); + expect(toPutBody(resetWindow!).combo.strategy).toBe("reset-window"); +}); + +test("round-robin still sends weights and stickyLimit", () => { + const [roundRobin] = parseComboList(payloadWith("round-robin", 2)); + const body = toPutBody(roundRobin!); + expect(body.combo.strategy).toBe("round-robin"); + expect(body.combo.targets[0]).toEqual({ provider: "openai", model: "gpt-5", weight: 2 }); + expect(body.combo.stickyLimit).toBe(3); +}); From 2cb50938ebe1f8810e897ee44bfed362697861ad Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 01:35:14 +0900 Subject: [PATCH 089/132] docs(devlog): record the lane N units and their outcomes (#2930) --- .../000_units.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 devlog/_plan/260829_lane_n_effort_ladder_and_locale_probe/000_units.md diff --git a/devlog/_plan/260829_lane_n_effort_ladder_and_locale_probe/000_units.md b/devlog/_plan/260829_lane_n_effort_ladder_and_locale_probe/000_units.md new file mode 100644 index 0000000000..4f79b9dee4 --- /dev/null +++ b/devlog/_plan/260829_lane_n_effort_ladder_and_locale_probe/000_units.md @@ -0,0 +1,116 @@ +# Lane N — CommandCode effort ladders and the localized Windows ownership probe + +Unit for the work-phases that ran after the bug-PR queue reached zero: two +reported defects that were still open as issues, plus two concurrency gaps in the +merged pool-401 path. + +## wp20 — #2883, GLM-5.3-Flash advertises no efforts + +`z-ai/glm-5.3-flash` reached the Codex catalog with `supported_reasoning_levels` +empty, so the App's picker rendered nothing and requests carried +`requestedEffort: "none"`. + +The table in `src/providers/command-code-efforts.ts` held `zai-org/GLM-5.3`. The +live route shares neither its vendor prefix nor its model, and `modelRecordValue` +matches exact, colon-family, or case-folded ids only, so `configuredReasoningEfforts` +fell through to the provider-level `reasoningEfforts: []` and +`applyProviderConfigHints` wrote that onto the model. + +Decision: add one explicit row, keyed to the exact upstream id. Do NOT relax +`modelRecordValue` — a stem or substring match would merge two different upstream +models across two vendor namespaces, for every provider at once. The row also +widens `knownModelIdsForProvider`, so the Codex-facing slug decodes back to the +native id. + +Ladder provenance: measured, not reported. The profile page renders client-side, +but the delivered HTML ships a serialized payload whose string table decodes as +`224=low, 225=medium, 226=high, 227=xhigh, 569=max`; this model's array is +`[224,226,569]`. The index map reproduces all six rows the same page carries that +were already committed here. No authenticated upstream probe was performed. + +Landed: a05dd252d (#2917). Issue closed manually — PRs target `dev`, so GitHub +does not auto-close on merge. + +## wp21 — #2914, zh-CN `ocx sync` cannot prove ownership + +Two failures compounding. The targeted `schtasks` query answers in CP936 and +`legacyEncodingForLocale` had no `zh` mapping, so the bytes arrived as mojibake and +no message match was possible. That leaves the locale-neutral full listing as the +only evidence, and it was bounded by the 2 s targeted-query budget while taking +12.3 s on a host with 401 tasks. + +Decision: name the CJK ANSI pages (`zh`→gbk, `zh-Hant`/TW/HK/MO→big5, `ja`→shift_jis) +and give the listing its own 20 s budget through a per-call override, leaving the +targeted queries at 2 s. + +Rejected during implementation: deriving the host's own not-found wording from a +control query against an unregistrable task name. It looks like the general fix and +is unsafe — `schtasks` exits 1 for both "not found" and "access denied", so a +locked-down host answers control and real queries identically and the comparison +yields a false `absent`. The existing access-denied test went red immediately. The +reasoning is recorded in the source comment so it is not rebuilt later. + +## wp22 — #2892 gaps 1-2, shared refresh-flight cancellation + +Refresh flights are shared per grant, but the flight's abort signal folded in the +initiating caller's signal, so one cancelled request aborted the fetch every joiner +depended on — and a joiner cannot distinguish that from an upstream failure, so a +closed tab could mark a healthy account for reauthentication. + +Decision: the flight owns its lifetime (stale eviction + 30 s ceiling); callers wait +through `awaitOwnCancellation`, which honors only their own signal. Both wait sites +use it — changing only the joiner's would have stripped the owner's cancellation +instead of scoping it. + +Gap 1 (a superseding credential must also be fresh) ships as one comparison but +without a red-proven test: three interleavings each landed on a different branch. +The source comment says so explicitly rather than leaving a green assertion that +proves nothing. + +Gaps 3-5 remain open, and gap 5 is the separate recovery-budget PR the issue asks +for. + +## wp23 — #2923, the 20 s listing paid twice per startup + +A regression from wp21's own fix. `startServer` inspects ownership twice on +purpose, so the widened listing budget was paid at both sites: ~25 s measured, +40 s at the ceiling, before `Bun.serve`. + +Two designs were written in parallel. Mine (#2927) scoped a caller-owned cache to +"one startup"; @Ingwannu's (#2928) keys reuse on the targeted query's exact bytes +and status. Theirs is stronger — it binds the absence proof to the evidence that +produced it rather than to a time window — so #2927 was closed in its favor. + +Review found one real gap in it: the first version cached a *stalled* listing, so +one transient 20 s timeout left ownership unprovable for the whole startup, +refusing the write #2914 exists to allow. Reproduced, then fixed on the +contributor's branch (only a successful listing is retained) with a test whose +targeted stderr is byte-identical across both passes, so nothing but the +stall-handling can force the retry. Also pinned `statePaths` in the `startServer` +case, which was reading the developer's real installation and reporting `foreign` +locally while passing on CI. + +Landed 09a50299e (#2928). + +## wp24 — #2925 and #2929, contributor overlap + +#2925 re-fixed #2914 independently and reached the same conclusion on the code +pages. It conflicted with the already-merged fix, so it was closed — but its +identity-budget half was the piece deliberately deferred in wp21, and its +argument (the CI gate existed *only* to widen that constant, so the desktop/CI +split is vestigial) was better than "raise the number". Landed separately as +1d9b389c1 (#2926) with credit. + +The localized-substring fast path from #2925 was not taken: each added substring +covers one more language someone thought of, and each is a chance to read a +different refusal as absence. + +#2929 fixed dashboard combo strategies collapsing `random`/`least-used`/ +`reset-window` to `failover` — saving an untouched combo silently rewrote it. +Verified by mutation independently (`Expected: "random", Received: "failover"`) +before merging as 112db9e12. + +## Outcome + +Zero open bug-labeled pull requests. Issues #2883, #2893, #2914 and #2923 closed; +#2892 remains open for gaps 3-4, and #2885, #2813, #1527, #1419 are unchanged. From ae356a3cfa73a93ce6d5e02dcb280d13bc409b20 Mon Sep 17 00:00:00 2001 From: Amr Obaid <98298256+x3M3x@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:36:11 +0400 Subject: [PATCH 090/132] fix(gui): group and translate the newer combo strategies in the workspace (#2931) --- gui/src/combo-workspace-data.ts | 34 +++++++++++++++++-- gui/src/components/ComboWorkspace.tsx | 1 + .../components/combo-workspace-add-modal.tsx | 13 ++----- .../components/combo-workspace-controls.tsx | 4 +-- .../combo-workspace-detail-panel.tsx | 13 ++----- .../combo-workspace-overview-panel.tsx | 1 + gui/src/i18n/de.ts | 15 ++++++-- gui/src/i18n/en.ts | 15 ++++++-- gui/src/i18n/fr.ts | 15 ++++++-- gui/src/i18n/ja.ts | 15 ++++++-- gui/src/i18n/ko.ts | 15 ++++++-- gui/src/i18n/ru.ts | 15 ++++++-- gui/src/i18n/tr.ts | 15 ++++++-- gui/src/i18n/zh-TW.ts | 15 ++++++-- gui/src/i18n/zh.ts | 15 ++++++-- gui/tests/combo-strategy-roundtrip.test.ts | 14 +++++++- 16 files changed, 171 insertions(+), 44 deletions(-) diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 07943dc7f0..d6d9ea204f 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -4,6 +4,7 @@ */ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; +import type { TKey } from "./i18n/shared"; export { SUPPORTED_NATIVE_OPENAI_SLUGS }; @@ -20,6 +21,30 @@ export const COMBO_STRATEGIES: readonly ComboStrategy[] = [ "reset-window", ] as const; +export const COMBO_STRATEGY_LABEL_KEYS: Record = { + failover: "cws.strategy.failover", + "round-robin": "cws.strategy.roundRobin", + random: "cws.strategy.random", + "least-used": "cws.strategy.leastUsed", + "reset-window": "cws.strategy.resetWindow", +}; + +export const COMBO_STRATEGY_HINT_KEYS: Record = { + failover: "cws.strategy.failoverHint", + "round-robin": "cws.strategy.roundRobinHint", + random: "cws.strategy.randomHint", + "least-used": "cws.strategy.leastUsedHint", + "reset-window": "cws.strategy.resetWindowHint", +}; + +export const COMBO_TARGETS_HINT_KEYS: Record = { + failover: "cws.targets.failoverHint", + "round-robin": "cws.targets.roundRobinHint", + random: "cws.targets.randomHint", + "least-used": "cws.targets.leastUsedHint", + "reset-window": "cws.targets.resetWindowHint", +}; + const COMBO_STRATEGY_SET = new Set(COMBO_STRATEGIES); /** @@ -101,6 +126,7 @@ export interface ComboItem { export interface ComboSections { failover: ComboItem[]; roundRobin: ComboItem[]; + other: ComboItem[]; } export interface ComboAttentionItem { @@ -211,11 +237,13 @@ export function parseComboList(payload: unknown): ComboItem[] { export function groupCombos(items: ComboItem[]): ComboSections { const failover: ComboItem[] = []; const roundRobin: ComboItem[] = []; + const other: ComboItem[] = []; for (const item of items) { - if (item.strategy === "round-robin") roundRobin.push(item); - else failover.push(item); + if (item.strategy === "failover") failover.push(item); + else if (item.strategy === "round-robin") roundRobin.push(item); + else other.push(item); } - return { failover, roundRobin }; + return { failover, roundRobin, other }; } export function filterCombos(items: ComboItem[], query: string): ComboItem[] { diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 28b31d67cd..d66ca8a242 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -129,6 +129,7 @@ export default function ComboWorkspace({ {([ ["failover", sections.failover, "cws.group.failover"], ["round-robin", sections.roundRobin, "cws.group.roundRobin"], + ["other", sections.other, "cws.group.other"], ] as const).map(([key, items, labelKey]) => ( items.length > 0 ? (
diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index ed18aaacfe..57f6253509 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -13,6 +13,7 @@ import { useT } from "../i18n/shared"; import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; +import { COMBO_STRATEGY_HINT_KEYS, COMBO_TARGETS_HINT_KEYS } from "../combo-workspace-data"; import { clampedNumberInput } from "./combo-workspace-utils"; export function AddComboModal({ @@ -163,11 +164,7 @@ export function AddComboModal({ onChange={(strategy) => setDraft((d) => ({ ...d, strategy }))} />

- {draft.strategy === "failover" - ? t("cws.strategy.failoverHint") - : draft.strategy === "round-robin" - ? t("cws.strategy.roundRobinHint") - : null} + {t(COMBO_STRATEGY_HINT_KEYS[draft.strategy])}

@@ -216,11 +213,7 @@ export function AddComboModal({ onChange={(targets) => setDraft((d) => ({ ...d, targets }))} />

- {draft.strategy === "failover" - ? t("cws.targets.failoverHint") - : draft.strategy === "round-robin" - ? t("cws.targets.roundRobinHint") - : null} + {t(COMBO_TARGETS_HINT_KEYS[draft.strategy])}

- {value} + {t(COMBO_STRATEGY_LABEL_KEYS[value])} ) : null} diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index 409da0b265..32a6625fde 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -15,6 +15,7 @@ import { useT } from "../i18n/shared"; import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; +import { COMBO_STRATEGY_HINT_KEYS, COMBO_TARGETS_HINT_KEYS } from "../combo-workspace-data"; import { clampedNumberInput } from "./combo-workspace-utils"; type DetailTab = "config" | "about"; @@ -317,11 +318,7 @@ export function DetailPanel({ onChange={(strategy) => updateDraft((d) => ({ ...d, strategy }))} />

- {draft.strategy === "failover" - ? t("cws.strategy.failoverHint") - : draft.strategy === "round-robin" - ? t("cws.strategy.roundRobinHint") - : null} + {t(COMBO_STRATEGY_HINT_KEYS[draft.strategy])}

@@ -367,11 +364,7 @@ export function DetailPanel({ onChange={(targets) => updateDraft((d) => ({ ...d, targets }))} />

- {draft.strategy === "failover" - ? t("cws.targets.failoverHint") - : draft.strategy === "round-robin" - ? t("cws.targets.roundRobinHint") - : null} + {t(COMBO_TARGETS_HINT_KEYS[draft.strategy])}

{combos.length}{t("cws.count.total")}
{sections.failover.length}{t("cws.count.failover")}
{sections.roundRobin.length}{t("cws.count.roundRobin")}
+
{sections.other.length}{t("cws.count.other")}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0913f2ad47..d2f1830a1e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -480,7 +480,7 @@ export const de: Record = { "models.tab.compatibility": "Kompatibilität", "models.tab.routing": "Routing (beta)", "models.tabsLabel": "Modell-Oberflächen", - "models.subtitle.combos": "Geordnete Modellgruppen, die unter einer id antworten. Failover probiert Ziele der Reihe nach, Round-Robin verteilt die Last.", + "models.subtitle.combos": "Geordnete Modellgruppen, die unter einer id antworten. Ziele mit Failover verketten oder die Last mit einer Balancing-Strategie verteilen.", "models.subtitle.compatibility": "Schreibgeschützte Kompatibilitätsmatrix aus der Lab-Projektion.", "models.subtitle.routing": "Policy-Profile, Dry-Run-Auswertung und quellenbasierte Routing-Analysen.", "models.subtitle": "Steuere, welche Modelle Codex sieht — natives GPT-Passthrough und geroutete Anbieter, nach Anbieter gruppiert (Kopfzeile zum Einklappen anklicken). Ausgeblendete Modelle fehlen in Katalog und Auswahl, bleiben aber per genauer ID aufrufbar. Änderungen gelten bei der nächsten Codex-Runde — opencodex invalidiert Codex 5-Minuten-Modell-Cache, kein Neustart nötig.", @@ -2018,13 +2018,15 @@ export const de: Record = { "cws.noSearchResults": "Keine Combos passen zur Suche.", "cws.group.failover": "Failover", "cws.group.roundRobin": "Round-Robin", + "cws.group.other": "Weitere Strategien", "cws.targetCount": "{count} Ziele", "cws.targetCountOne": "1 Ziel", "cws.overviewTitle": "Combos", - "cws.overviewBlurb": "Virtuelle Modelle mit Failover über Anbieter/Modell-Ziele oder deterministischem Smooth Weighted Round-Robin.", + "cws.overviewBlurb": "Virtuelle Modelle, die über Anbieter/Modell-Ziele mit Failover, Round-Robin, gewichtetem Zufall, seltenst genutztem Ziel oder frühestem Quota-Reset weiterleiten.", "cws.count.total": "Gesamt", "cws.count.failover": "Failover", "cws.count.roundRobin": "Round-Robin", + "cws.count.other": "Weitere", "cws.howTitle": "So funktioniert es", "cws.howBody": "Fordern Sie in Codex den öffentlichen Modellnamen der Combo an. Ohne eigenen Namen gilt combo/. OpenCodex wählt ein Ziel und springt nur bei wiederholbaren Upstream-Fehlern. Ist kein Ziel verfügbar, schlägt die Anfrage geschlossen fehl, statt den globalen Standardanbieter zu verwenden.", "cws.attentionTitle": "Aufmerksamkeit nötig", @@ -2044,8 +2046,14 @@ export const de: Record = { "cws.strategy": "Strategie", "cws.strategy.failover": "Failover", "cws.strategy.roundRobin": "Round-Robin", + "cws.strategy.random": "Zufall", + "cws.strategy.leastUsed": "Seltenst genutzt", + "cws.strategy.resetWindow": "Reset-Fenster", "cws.strategy.failoverHint": "Ziele der Reihe nach versuchen. Bei einem wiederholbaren Fehler (Limit, Ausfall, Abo-Sperre) zum nächsten springen.", "cws.strategy.roundRobinHint": "Datenverkehr deterministisch nach Gewicht verteilen. Das gewählte Ziel für einen Block erfolgreicher Anfragen behalten und dann weiterschalten.", + "cws.strategy.randomHint": "Pro Anfrage ein geeignetes Ziel ziehen, mit Wahrscheinlichkeiten proportional zum Gewicht. Keine Bindung zwischen Anfragen.", + "cws.strategy.leastUsedHint": "Jede Anfrage an das geeignete Ziel mit den wenigsten erfassten Erfolgen weiterleiten. Zählungen starten mit dem Proxy neu.", + "cws.strategy.resetWindowHint": "Bevorzugt das geeignete Ziel, dessen Quota-Fenster am frühesten zurückgesetzt wird. Ohne Quota-Daten gilt die Konfigurationsreihenfolge.", "cws.field.id": "Combo-ID", "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Clients fordern {model} an.", "cws.field.alias": "Öffentlicher Modellname", @@ -2071,6 +2079,9 @@ export const de: Record = { "cws.targets": "Ziele", "cws.targets.failoverHint": "Reihenfolge zählt — das erste ist primär.", "cws.targets.roundRobinHint": "Gewichte steuern die deterministische relative Auswahl; die Reihenfolge löst Gleichstände im Rotationsring.", + "cws.targets.randomHint": "Gewichte steuern die Wahrscheinlichkeit jeder Ziehung; die Reihenfolge spielt keine Rolle.", + "cws.targets.leastUsedHint": "Die Reihenfolge löst nur Gleichstände zwischen gleich oft genutzten Zielen.", + "cws.targets.resetWindowHint": "Die Reihenfolge gilt, wenn Quota-Daten fehlen oder gleich ausfallen.", "cws.target.provider": "Anbieter", "cws.target.model": "Modell", "cws.target.weight": "Gewicht", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8f1f1333d3..2d4c55b139 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -505,7 +505,7 @@ export const en = { "models.tab.compatibility": "Compatibility", "models.tab.routing": "Routing (beta)", "models.tabsLabel": "Model surfaces", - "models.subtitle.combos": "Ordered groups of models that answer as one id. Failover tries targets in order; round-robin spreads the load.", + "models.subtitle.combos": "Ordered groups of models that answer as one id. Chain targets with failover or spread the load with a balancing strategy.", "models.subtitle.compatibility": "Read-only compatibility verdict matrix from lab projection evidence.", "models.subtitle.routing": "Policy profiles, dry-run evaluation, and source-backed routing analytics.", "models.subtitle": "Toggle which models Codex sees — native GPT passthrough and routed providers, grouped by provider (click a header to collapse). Hidden models stay off the catalog + model picker but remain directly callable by exact id. Changes apply on the next Codex turn — opencodex invalidates Codex's 5-min model cache so no restart is needed.", @@ -2058,13 +2058,15 @@ export const en = { "cws.noSearchResults": "No combos match your search.", "cws.group.failover": "Failover", "cws.group.roundRobin": "Round-robin", + "cws.group.other": "Other strategies", "cws.targetCount": "{count} targets", "cws.targetCountOne": "1 target", "cws.overviewTitle": "Combos", - "cws.overviewBlurb": "Virtual models that fail over across provider/model targets or use deterministic smooth weighted round-robin.", + "cws.overviewBlurb": "Virtual models that route across provider/model targets with failover, round-robin, weighted random, least-used, or soonest quota reset.", "cws.count.total": "Total", "cws.count.failover": "Failover", "cws.count.roundRobin": "Round-robin", + "cws.count.other": "Other", "cws.howTitle": "How it works", "cws.howBody": "Ask Codex for the combo's public model name. Without one, the default is combo/. OpenCodex selects a target and hops only on retryable upstream failures. If no target remains available, the request fails closed instead of using the global default provider.", "cws.attentionTitle": "Needs attention", @@ -2084,8 +2086,14 @@ export const en = { "cws.strategy": "Strategy", "cws.strategy.failover": "Failover", "cws.strategy.roundRobin": "Round-robin", + "cws.strategy.random": "Random", + "cws.strategy.leastUsed": "Least-used", + "cws.strategy.resetWindow": "Reset-window", "cws.strategy.failoverHint": "Try targets in order. If the first fails with a retryable error (rate limit, outage, subscription gate), hop to the next.", "cws.strategy.roundRobinHint": "Deterministically balance traffic by weight. Keep each selected target for a batch of successful requests, then advance.", + "cws.strategy.randomHint": "Draw one eligible target per request, with odds proportional to weight. No stickiness between requests.", + "cws.strategy.leastUsedHint": "Route each request to the eligible target with the fewest recorded successes. Counts restart with the proxy.", + "cws.strategy.resetWindowHint": "Prefer the eligible target whose quota window resets soonest. Falls back to configuration order when quota data is missing.", "cws.field.id": "Combo id", "cws.field.idHint": "Clients will request {model}", "cws.field.idInternalHint": "Internal combo id. You can change it after creation.", @@ -2111,6 +2119,9 @@ export const en = { "cws.targets": "Targets", "cws.targets.failoverHint": "Order matters — first is primary.", "cws.targets.roundRobinHint": "Weights control deterministic relative selection; order breaks ties in the rotation ring.", + "cws.targets.randomHint": "Weights control each draw's odds; order does not matter.", + "cws.targets.leastUsedHint": "Order only breaks ties between equally used targets.", + "cws.targets.resetWindowHint": "Order applies when quota data is missing or tied.", "cws.target.provider": "Provider", "cws.target.model": "Model", "cws.target.weight": "Weight", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index fedc4698be..99d64ee4e3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -490,7 +490,7 @@ export const fr: Record = { "models.tab.compatibility": "Compatibilité", "models.tab.routing": "Routage (bêta)", "models.tabsLabel": "Espaces des modèles", - "models.subtitle.combos": "Groupes ordonnés de modèles qui répondent sous un même identifiant. Le repli essaie les cibles dans l’ordre ; la rotation distribue la charge.", + "models.subtitle.combos": "Groupes ordonnés de modèles qui répondent sous un même identifiant. Enchaînez les cibles avec le repli ou répartissez la charge avec une stratégie d’équilibrage.", "models.subtitle.compatibility": "Matrice en lecture seule des verdicts de compatibilité issus des preuves de projection du laboratoire.", "models.subtitle.routing": "Profils de stratégie, évaluation à blanc et analyses de routage fondées sur les sources.", "models.subtitle": "Choisissez les modèles visibles par Codex — accès direct aux GPT natifs et fournisseurs routés, regroupés par fournisseur (cliquez sur un en-tête pour le réduire). Les modèles masqués sont retirés du catalogue et du sélecteur, mais restent directement accessibles par leur identifiant exact. Les modifications s’appliquent au prochain tour Codex — opencodex invalide le cache de modèles de Codex de 5 min, sans nécessiter de redémarrage.", @@ -2019,13 +2019,15 @@ export const fr: Record = { "cws.noSearchResults": "Aucune combinaison ne correspond à votre recherche.", "cws.group.failover": "Basculement", "cws.group.roundRobin": "Rotation", + "cws.group.other": "Autres stratégies", "cws.targetCount": "{count} cibles", "cws.targetCountOne": "1 cible", "cws.overviewTitle": "Combinaisons", - "cws.overviewBlurb": "Modèles virtuels qui basculent entre des cibles fournisseur/modèle ou utilisent une rotation pondérée fluide et déterministe.", + "cws.overviewBlurb": "Modèles virtuels qui routent entre des cibles fournisseur/modèle par repli, rotation, aléatoire pondéré, moins utilisé ou réinitialisation de quota la plus proche.", "cws.count.total": "Total", "cws.count.failover": "Basculement", "cws.count.roundRobin": "Rotation", + "cws.count.other": "Autres", "cws.howTitle": "Fonctionnement", "cws.howBody": "Demandez à Codex le nom de modèle public de la combinaison. Sans nom, combo/ est utilisé par défaut. OpenCodex sélectionne une cible et ne bascule qu’en cas d’échec réessayable en amont. Si aucune cible ne reste disponible, la requête est bloquée au lieu d’utiliser le fournisseur global par défaut.", "cws.attentionTitle": "Intervention requise", @@ -2049,8 +2051,14 @@ export const fr: Record = { "cws.strategy": "Stratégie", "cws.strategy.failover": "Basculement", "cws.strategy.roundRobin": "Rotation", + "cws.strategy.random": "Aléatoire", + "cws.strategy.leastUsed": "Moins utilisé", + "cws.strategy.resetWindow": "Fenêtre de réinitialisation", "cws.strategy.failoverHint": "Essaie les cibles dans l’ordre. Si la première échoue avec une erreur réessayable (limite de débit, panne, restriction d’abonnement), passe à la suivante.", "cws.strategy.roundRobinHint": "Répartit le trafic de manière déterministe selon les pondérations. Conserve chaque cible sélectionnée pendant un lot de requêtes réussies, puis passe à la suivante.", + "cws.strategy.randomHint": "Tire une cible éligible par requête, avec des probabilités proportionnelles au poids. Aucune adhérence entre requêtes.", + "cws.strategy.leastUsedHint": "Dirige chaque requête vers la cible éligible ayant le moins de succès enregistrés. Les compteurs redémarrent avec le proxy.", + "cws.strategy.resetWindowHint": "Préfère la cible éligible dont la fenêtre de quota se réinitialise le plus tôt. Sans données de quota, l’ordre de configuration s’applique.", "cws.field.id": "Identifiant de la combinaison", "cws.field.idHint": "Les clients demanderont {model}", "cws.field.idInternalHint": "Identifiant interne de la combinaison. Vous pouvez le modifier après la création.", @@ -2072,6 +2080,9 @@ export const fr: Record = { "cws.targets": "Cibles", "cws.targets.failoverHint": "L’ordre est important — la première est la cible principale.", "cws.targets.roundRobinHint": "Les pondérations contrôlent la sélection relative déterministe; l’ordre départage les égalités dans l’anneau de rotation.", + "cws.targets.randomHint": "Les pondérations contrôlent les chances de chaque tirage ; l’ordre n’a pas d’importance.", + "cws.targets.leastUsedHint": "L’ordre ne départage que les cibles également utilisées.", + "cws.targets.resetWindowHint": "L’ordre s’applique quand les données de quota manquent ou sont égales.", "cws.target.provider": "Fournisseur", "cws.target.model": "Modèle", "cws.target.weight": "Pondération", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 646faf0560..4788f0ef80 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -488,7 +488,7 @@ export const ja: Record = { "models.tab.compatibility": "互換性", "models.tab.routing": "ルーティング (beta)", "models.tabsLabel": "モデルサーフェス", - "models.subtitle.combos": "複数のモデルを 1 つの id にまとめ、順に応答させます。failover は順番に試し、round-robin は負荷を分散します。", + "models.subtitle.combos": "複数のモデルを 1 つの id にまとめ、順に応答させます。failover でターゲットを連鎖させるか、分散戦略で負荷を分散します。", "models.subtitle.compatibility": "ラボ投影証拠の読み取り専用互換性判定マトリクス。", "models.subtitle.routing": "ポリシープロファイル、dry-run 評価、そして根拠の残るルーティング分析です。", "models.subtitle": "Codex に表示するモデルを切り替えます — ネイティブ GPT パススルーとルーティングプロバイダー、プロバイダー別(ヘッダーをクリックで折りたたみ)。非表示モデルはカタログとピッカーから外れますが、正確な id での直接呼び出しは可能です。変更は次回の Codex ターンで適用 — opencodex は Codex の 5 分間モデルキャッシュを無効化するので再起動は不要です。", @@ -2076,13 +2076,15 @@ export const ja: Record = { "cws.noSearchResults": "検索に一致するコンボがありません。", "cws.group.failover": "フェイルオーバー", "cws.group.roundRobin": "ラウンドロビン", + "cws.group.other": "その他の戦略", "cws.targetCount": "{count} ターゲット", "cws.targetCountOne": "1 ターゲット", "cws.overviewTitle": "コンボ", - "cws.overviewBlurb": "プロバイダー/モデルターゲット間でフェイルオーバーする、または決定論的で滑らかな重み付きラウンドロビンを使う仮想モデル。", + "cws.overviewBlurb": "プロバイダー/モデルターゲット間を、フェイルオーバー、ラウンドロビン、重み付きランダム、最少使用、最短クォータリセットで振り分ける仮想モデル。", "cws.count.total": "合計", "cws.count.failover": "フェイルオーバー", "cws.count.roundRobin": "ラウンドロビン", + "cws.count.other": "その他", "cws.howTitle": "仕組み", "cws.howBody": "Codex に combo/ を要求します。OpenCodex はターゲットを選び、再試行可能な上流の失敗時のみホップします。利用可能なターゲットが残っていない場合、グローバルなデフォルトプロバイダーを使わずにフェイルクローズします。", "cws.attentionTitle": "要対応", @@ -2103,8 +2105,14 @@ export const ja: Record = { "cws.strategy": "ストラテジー", "cws.strategy.failover": "フェイルオーバー", "cws.strategy.roundRobin": "ラウンドロビン", + "cws.strategy.random": "ランダム", + "cws.strategy.leastUsed": "最少使用", + "cws.strategy.resetWindow": "リセットウィンドウ", "cws.strategy.failoverHint": "ターゲットを順に試します。最初が再試行可能なエラー(レート制限、障害、サブスクリプションゲート)で失敗した場合、次へホップします。", "cws.strategy.roundRobinHint": "重みで決定論的にトラフィックを分散します。選んだターゲットを成功リクエストのバッチ分保持し、次へ進みます。", + "cws.strategy.randomHint": "リクエストごとに適格なターゲットを 1 つ抽選します。確率は重みに比例し、リクエスト間でスティッキネスはありません。", + "cws.strategy.leastUsedHint": "各リクエストを、成功回数が最も少ない適格なターゲットへ振ります。カウントはプロキシの再起動でリセットされます。", + "cws.strategy.resetWindowHint": "クォータのウィンドウが最も早くリセットされる適格なターゲットを優先します。クォータデータがない場合は設定順に従います。", "cws.field.id": "コンボ ID", "cws.field.idHint": "クライアントは {model} をリクエストします", "cws.field.idInternalHint": "コンボの内部 ID。作成後も変更できます。", @@ -2130,6 +2138,9 @@ export const ja: Record = { "cws.targets": "ターゲット", "cws.targets.failoverHint": "順序が重要 — 最初がプライマリです。", "cws.targets.roundRobinHint": "重みが決定論的な相対選択を制御し、順序がローテーションリングの同点を解消します。", + "cws.targets.randomHint": "重みが各抽選の確率を制御します。順序は影響しません。", + "cws.targets.leastUsedHint": "順序は同じ使用回数のターゲット間の同点のみを解消します。", + "cws.targets.resetWindowHint": "順序はクォータデータが欠落または同点のときに適用されます。", "cws.target.provider": "プロバイダー", "cws.target.model": "モデル", "cws.target.weight": "重み", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 00edec94d7..33c4452c14 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -491,7 +491,7 @@ export const ko: Record = { "models.tab.compatibility": "호환성", "models.tab.routing": "라우팅 (beta)", "models.tabsLabel": "모델 표면", - "models.subtitle.combos": "여러 모델을 하나의 id로 묶어 순서대로 응답하게 합니다. failover는 순서대로 시도하고, round-robin은 부하를 나눕니다.", + "models.subtitle.combos": "여러 모델을 하나의 id로 묶어 순서대로 응답하게 합니다. failover로 대상을 연결하거나 분산 전략으로 부하를 나눕니다.", "models.subtitle.compatibility": "랩 프로젝션 증거의 읽기 전용 호환성 판정 행렬.", "models.subtitle.routing": "정책 프로필, dry-run 평가, 그리고 근거가 남는 라우팅 분석입니다.", "models.subtitle": "Codex가 보는 모델을 켜고 끕니다 — 네이티브 GPT passthrough와 라우팅된 모델을 프로바이더별로 묶어 보여줍니다(헤더를 클릭하면 접힘). 숨긴 모델은 카탈로그와 선택기에서 빠지지만 정확한 id로 직접 호출할 수 있습니다. 변경 사항은 다음 Codex 턴에 적용됩니다 — opencodex가 Codex의 5분 모델 캐시를 무효화하므로 재시작이 필요 없습니다.", @@ -2045,13 +2045,15 @@ export const ko: Record = { "cws.noSearchResults": "검색과 일치하는 콤보가 없습니다.", "cws.group.failover": "장애 조치", "cws.group.roundRobin": "라운드로빈", + "cws.group.other": "기타 전략", "cws.targetCount": "대상 {count}개", "cws.targetCountOne": "대상 1개", "cws.overviewTitle": "콤보", - "cws.overviewBlurb": "프로바이더/모델 대상 사이에서 장애 조치하거나 결정적 Smooth Weighted 라운드로빈을 사용하는 가상 모델입니다.", + "cws.overviewBlurb": "프로바이더/모델 대상 사이를 장애 조치, 라운드로빈, 가중 랜덤, 최소 사용, 최단 쿼터 리셋으로 라우팅하는 가상 모델입니다.", "cws.count.total": "전체", "cws.count.failover": "장애 조치", "cws.count.roundRobin": "라운드로빈", + "cws.count.other": "기타", "cws.howTitle": "동작 방식", "cws.howBody": "Codex에서 콤보의 공개 모델 이름을 요청하세요. 설정하지 않으면 combo/가 기본값입니다. OpenCodex는 재시도 가능한 업스트림 오류에서만 다음 대상으로 넘깁니다. 사용 가능한 대상이 없으면 전역 기본 프로바이더로 우회하지 않고 요청을 실패 처리합니다.", "cws.attentionTitle": "확인 필요", @@ -2071,8 +2073,14 @@ export const ko: Record = { "cws.strategy": "전략", "cws.strategy.failover": "장애 조치", "cws.strategy.roundRobin": "라운드로빈", + "cws.strategy.random": "랜덤", + "cws.strategy.leastUsed": "최소 사용", + "cws.strategy.resetWindow": "리셋 윈도우", "cws.strategy.failoverHint": "대상을 순서대로 시도합니다. 재시도 가능한 오류(한도, 장애, 구독 게이트)면 다음으로 넘어갑니다.", "cws.strategy.roundRobinHint": "가중치에 따라 트래픽을 결정적으로 분배합니다. 선택된 대상을 성공 요청 묶음 동안 유지한 뒤 다음 대상으로 진행합니다.", + "cws.strategy.randomHint": "요청마다 가중치에 비례한 확률로 적합한 대상을 하나 뽑습니다. 요청 간 고정이 없습니다.", + "cws.strategy.leastUsedHint": "각 요청을 성공 횟수가 가장 적은 적합한 대상으로 보냅니다. 횟수는 프록시 재시작 시 초기화됩니다.", + "cws.strategy.resetWindowHint": "쿼터 윈도우가 가장 빨리 리셋되는 적합한 대상을 우선합니다. 쿼터 데이터가 없으면 설정 순서를 따릅니다.", "cws.field.id": "콤보 ID", "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 클라이언트는 {model}을(를) 요청합니다.", "cws.field.alias": "공개 모델 이름", @@ -2098,6 +2106,9 @@ export const ko: Record = { "cws.targets": "대상", "cws.targets.failoverHint": "순서가 중요합니다 — 첫 번째가 기본입니다.", "cws.targets.roundRobinHint": "가중치는 결정적 상대 선택을 제어하고, 순서는 회전 고리의 동률을 결정합니다.", + "cws.targets.randomHint": "가중치가 각 추첨의 확률을 제어하며, 순서는 무관합니다.", + "cws.targets.leastUsedHint": "순서는 사용량이 같은 대상 간의 동률만 결정합니다.", + "cws.targets.resetWindowHint": "쿼터 데이터가 없거나 동률일 때 순서가 적용됩니다.", "cws.target.provider": "프로바이더", "cws.target.model": "모델", "cws.target.weight": "가중치", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d4a6e3e142..08bf01b65b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -493,7 +493,7 @@ export const ru: Record = { "models.tab.compatibility": "Совместимость", "models.tab.routing": "Маршрутизация (beta)", "models.tabsLabel": "Поверхности моделей", - "models.subtitle.combos": "Упорядоченные группы моделей, отвечающие под одним id. Failover пробует цели по порядку, round-robin распределяет нагрузку.", + "models.subtitle.combos": "Упорядоченные группы моделей, отвечающие под одним id. Связывайте цели через failover или распределяйте нагрузку стратегией балансировки.", "models.subtitle.compatibility": "Матрица совместимости только для чтения из проекции лаборатории.", "models.subtitle.routing": "Профили политик, оценка в режиме dry-run и аналитика маршрутизации с подтверждением источников.", "models.subtitle": "Управляйте тем, какие модели видит Codex — нативные GPT (сквозной проброс) и модели маршрутизируемых провайдеров, сгруппированные по провайдеру (нажмите на заголовок, чтобы свернуть группу). Скрытые модели исчезают из каталога и селектора, но остаются вызываемыми по точному id. Изменения применяются на следующем ходе Codex — opencodex сбрасывает 5-минутный кэш моделей Codex, поэтому перезапуск не требуется.", @@ -2128,13 +2128,15 @@ export const ru: Record = { "cws.noSearchResults": "Нет комбо, соответствующих запросу.", "cws.group.failover": "Failover", "cws.group.roundRobin": "Round-robin", + "cws.group.other": "Другие стратегии", "cws.targetCount": "{count} целей", "cws.targetCountOne": "1 цель", "cws.overviewTitle": "Комбо", - "cws.overviewBlurb": "Виртуальные модели, которые при сбоях переключаются между целями провайдер/модель (отказоустойчивое переключение, failover) или используют детерминированный плавный взвешенный циклический перебор (round-robin).", + "cws.overviewBlurb": "Виртуальные модели, маршрутизирующие между целями провайдер/модель через failover, round-robin, взвешенный случайный выбор, наименее используемую цель или ближайший сброс квоты.", "cws.count.total": "Всего", "cws.count.failover": "Failover", "cws.count.roundRobin": "Round-robin", + "cws.count.other": "Другие", "cws.howTitle": "Как это работает", "cws.howBody": "Запросите у Codex публичное имя модели комбо. Если оно не задано, используется combo/. OpenCodex выбирает цель и переключается на следующую только при сбоях вышестоящего провайдера, допускающих повтор. Если доступных целей не осталось, запрос завершается ошибкой, а не переходит на глобальный провайдер по умолчанию.", "cws.attentionTitle": "Требует внимания", @@ -2154,8 +2156,14 @@ export const ru: Record = { "cws.strategy": "Стратегия", "cws.strategy.failover": "Failover", "cws.strategy.roundRobin": "Round-robin", + "cws.strategy.random": "Случайный", + "cws.strategy.leastUsed": "Наименее используемый", + "cws.strategy.resetWindow": "Окно сброса", "cws.strategy.failoverHint": "Цели перебираются по порядку. Если первая завершается ошибкой, допускающей повтор (лимит запросов, сбой, ограничение подписки), происходит переключение на следующую.", "cws.strategy.roundRobinHint": "Детерминированное распределение трафика по весам. Выбранная цель удерживается на серию успешных запросов, затем селектор переходит к следующей.", + "cws.strategy.randomHint": "Для каждого запроса выбирается одна подходящая цель с вероятностью, пропорциональной весу. Между запросами привязки нет.", + "cws.strategy.leastUsedHint": "Каждый запрос направляется к подходящей цели с наименьшим числом успешных запросов. Счётчики обнуляются при перезапуске прокси.", + "cws.strategy.resetWindowHint": "Предпочитается подходящая цель, чьё окно квот сбрасывается раньше всех. Без данных о квотах действует порядок из конфигурации.", "cws.field.id": "Id комбо", "cws.field.idHint": "Клиенты будут запрашивать {model}", "cws.field.idInternalHint": "Внутренний id комбо. Его можно изменить после создания.", @@ -2181,6 +2189,9 @@ export const ru: Record = { "cws.targets": "Цели", "cws.targets.failoverHint": "Порядок важен — первая цель основная.", "cws.targets.roundRobinHint": "Веса задают детерминированный относительный выбор; при равных весах порядок определяет очерёдность в кольце ротации.", + "cws.targets.randomHint": "Веса задают вероятности каждого выбора; порядок не важен.", + "cws.targets.leastUsedHint": "Порядок разрешает только равенство между одинаково используемыми целями.", + "cws.targets.resetWindowHint": "Порядок применяется, когда данных о квотах нет или они равны.", "cws.target.provider": "Провайдер", "cws.target.model": "Модель", "cws.target.weight": "Вес", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1391401055..a7e001271b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -496,7 +496,7 @@ export const tr: Record = { "models.tab.compatibility": "Uyumluluk", "models.tab.routing": "Yönlendirme (beta)", "models.tabsLabel": "Model yüzeyleri", - "models.subtitle.combos": "Tek bir kimlik olarak yanıt veren sıralı model grupları.", + "models.subtitle.combos": "Tek bir kimlik olarak yanıt veren sıralı model grupları. Hedefleri failover ile zincirleyin veya yükü dengeleme stratejisiyle dağıtın.", "models.subtitle.compatibility": "Lab projeksiyon kanıtından salt okunur uyumluluk matrisi.", "models.subtitle.routing": "Politika profilleri ve simülasyon değerlendirmesi.", "models.subtitle": "Codex'in göreceği modelleri açıp kapatın.", @@ -2048,13 +2048,15 @@ export const tr: Record = { "cws.noSearchResults": "Aramanızla eşleşen kombo yok.", "cws.group.failover": "Yedekli (Failover)", "cws.group.roundRobin": "Round-robin", + "cws.group.other": "Diğer stratejiler", "cws.targetCount": "{count} hedef", "cws.targetCountOne": "1 hedef", "cws.overviewTitle": "Kombolar", - "cws.overviewBlurb": "Sağlayıcı/model hedefleri arasında devreden sanal modeller.", + "cws.overviewBlurb": "Sağlayıcı/model hedefleri arasında failover, round-robin, ağırlıklı rastgele, en az kullanılan veya en yakın kota sıfırlamasıyla yönlendiren sanal modeller.", "cws.count.total": "Toplam", "cws.count.failover": "Failover", "cws.count.roundRobin": "Round-robin", + "cws.count.other": "Diğer", "cws.howTitle": "Nasıl çalışır", "cws.howBody": "Codex'ten kombonun kamuya açık model adını isteyin. Bir ad olmadan varsayılan combo/ şeklindedir. OpenCodex bir hedef seçer ve yalnızca yeniden denenebilir yukarı akış hatalarında atlar. Hiçbir hedef kullanılabilir kalmazsa, istek küresel varsayılan sağlayıcıyı kullanmak yerine kapalı olarak başarısız olur (fail closed).", "cws.attentionTitle": "Dikkat gerekiyor", @@ -2074,8 +2076,14 @@ export const tr: Record = { "cws.strategy": "Strateji", "cws.strategy.failover": "Yedekli (Failover)", "cws.strategy.roundRobin": "Round-robin", + "cws.strategy.random": "Rastgele", + "cws.strategy.leastUsed": "En az kullanılan", + "cws.strategy.resetWindow": "Sıfırlama penceresi", "cws.strategy.failoverHint": "Hedefleri sırayla deneyin. İlk hedef yeniden denenebilir bir hatayla (oran sınırı, kesinti, abonelik engeli) başarısız olursa sonraki hedefe atlayın.", "cws.strategy.roundRobinHint": "Trafiği ağırlığa göre kararlı bir şekilde dengeleyin. Seçilen her hedefi bir dizi başarılı istek boyunca tutun, ardından ilerleyin.", + "cws.strategy.randomHint": "Her istek için ağırlığa orantılı olasılıkla bir uygun hedef çekilir. İstekler arasında yapışkanlık yoktur.", + "cws.strategy.leastUsedHint": "Her isteği, kayıtlı başarısı en az olan uygun hedefe yönlendirir. Sayaçlar proxy ile yeniden başlar.", + "cws.strategy.resetWindowHint": "Kota penceresi en yakında sıfırlanacak uygun hedefi tercih eder. Kota verisi yoksa yapılandırma sırasına döner.", "cws.field.id": "Kombo ID", "cws.field.idHint": "İstemciler {model} isteyecek", "cws.field.idInternalHint": "Dahili kombo ID. Oluşturduktan sonra değiştirebilirsiniz.", @@ -2101,6 +2109,9 @@ export const tr: Record = { "cws.targets": "Hedefler", "cws.targets.failoverHint": "Sıralama önemlidir — birincil olan ilktir.", "cws.targets.roundRobinHint": "Ağırlıklar kararlı bağıntılı seçimi kontrol eder; sıralama rotasyon halkasındaki eşitlikleri bozar.", + "cws.targets.randomHint": "Ağırlıklar her çekilişin olasılığını kontrol eder; sıralamanın önemi yoktur.", + "cws.targets.leastUsedHint": "Sıralama yalnızca eşit kullanımlı hedefler arasındaki eşitliği bozar.", + "cws.targets.resetWindowHint": "Kota verisi eksik veya eşitse sıralama uygulanır.", "cws.target.provider": "Sağlayıcı", "cws.target.model": "Model", "cws.target.weight": "Ağırlık", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6effb92e0f..c2eb37f8d2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1577,13 +1577,15 @@ export const zhTW: Record = { "cws.noSearchResults": "沒有符合的組合。", "cws.group.failover": "容錯移轉", "cws.group.roundRobin": "輪詢", + "cws.group.other": "其他策略", "cws.targetCount": "{count} 個目標", "cws.targetCountOne": "1 個目標", "cws.overviewTitle": "組合", - "cws.overviewBlurb": "可在供應商/模型目標間容錯移轉,或採用確定性平滑加權輪詢的虛擬模型。", + "cws.overviewBlurb": "在供應商/模型目標之間依容錯移轉、輪詢、加權隨機、最少使用或最早配額重置路由的虛擬模型。", "cws.count.total": "總計", "cws.count.failover": "容錯移轉", "cws.count.roundRobin": "輪詢", + "cws.count.other": "其他", "cws.howTitle": "工作原理", "cws.howBody": "在 Codex 中請求組合的公開模型名稱;未設定時預設使用 combo/。OpenCodex 僅在可重試的上游錯誤時切換目標。若沒有可用目標,請求會直接失敗,不會回退到全域性預設供應商。", "cws.attentionTitle": "需要關注", @@ -1602,8 +1604,14 @@ export const zhTW: Record = { "cws.strategy": "策略", "cws.strategy.failover": "容錯移轉", "cws.strategy.roundRobin": "輪詢", + "cws.strategy.random": "隨機", + "cws.strategy.leastUsed": "最少使用", + "cws.strategy.resetWindow": "重置視窗", "cws.strategy.failoverHint": "按順序嘗試目標。若出現可重試錯誤(限流、故障、訂閱門控),則跳到下一個。", "cws.strategy.roundRobinHint": "按權重確定性地分配流量。將所選目標保留一批成功請求後,再推進到下一個目標。", + "cws.strategy.randomHint": "每個請求按權重比例隨機抽取一個可用目標,請求之間不保持黏性。", + "cws.strategy.leastUsedHint": "將每個請求路由到成功次數最少的可用目標。計數隨代理重啟歸零。", + "cws.strategy.resetWindowHint": "優先選擇配額視窗最早重置的可用目標。缺少配額資料時回退到設定順序。", "cws.field.id": "組合 ID", "cws.field.idHint": "客戶端將請求 {model}", "cws.field.idInternalHint": "組合的內部 ID,建立後仍可修改。", @@ -1625,6 +1633,9 @@ export const zhTW: Record = { "cws.targets": "目標", "cws.targets.failoverHint": "順序很重要 — 第一個為主。", "cws.targets.roundRobinHint": "權重控制確定性的相對選擇;順序用於打破輪換環中的平局。", + "cws.targets.randomHint": "權重控制每次抽取的機率,順序無關緊要。", + "cws.targets.leastUsedHint": "順序僅在使用量相同的目標之間打破平局。", + "cws.targets.resetWindowHint": "配額資料缺失或相同時依順序處理。", "cws.target.provider": "供應商", "cws.target.model": "模型", "cws.target.weight": "權重", @@ -1960,7 +1971,7 @@ export const zhTW: Record = { "models.tab.compatibility": "相容性", "models.tab.routing": "路由 (beta)", "models.tabsLabel": "模型介面", - "models.subtitle.combos": "將多個模型合成一個 id 來回答。容錯移轉會依序嘗試目標;輪詢則分攤負載。", + "models.subtitle.combos": "將多個模型合成一個 id 來回答。用容錯移轉串接目標,或用均衡策略分攤負載。", "models.subtitle.compatibility": "來自實驗室投影證據的唯讀相容性判定矩陣。", "models.subtitle.routing": "原則設定檔、dry-run 評估,以及有來源依據的路由分析。", "models.contextSettings": "自訂視窗", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b2cb24f862..bc36d0f752 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -488,7 +488,7 @@ export const zh: Record = { "models.tab.compatibility": "兼容性", "models.tab.routing": "路由 (beta)", "models.tabsLabel": "模型界面", - "models.subtitle.combos": "把多个模型合成一个 id 依次应答。failover 按顺序尝试,round-robin 分摊负载。", + "models.subtitle.combos": "把多个模型合成一个 id 依次应答。用 failover 串联目标,或用均衡策略分摊负载。", "models.subtitle.compatibility": "来自实验室投影证据的只读兼容性判定矩阵。", "models.subtitle.routing": "策略配置、dry-run 评估,以及有据可查的路由分析。", "models.subtitle": "开关 Codex 可见的模型 — 原生 GPT passthrough 与已路由模型按提供方分组(点击标题可折叠)。隐藏的模型不会出现在目录和模型选择器中,但仍可按精确 id 直接调用。更改在下一个 Codex 回合生效 — opencodex 会使 Codex 的 5 分钟模型缓存失效,因此无需重启。", @@ -2038,13 +2038,15 @@ export const zh: Record = { "cws.noSearchResults": "没有匹配的组合。", "cws.group.failover": "故障转移", "cws.group.roundRobin": "轮询", + "cws.group.other": "其他策略", "cws.targetCount": "{count} 个目标", "cws.targetCountOne": "1 个目标", "cws.overviewTitle": "组合", - "cws.overviewBlurb": "可在提供方/模型目标间故障转移,或采用确定性平滑加权轮询的虚拟模型。", + "cws.overviewBlurb": "在提供方/模型目标之间按故障转移、轮询、加权随机、最少使用或最早配额重置路由的虚拟模型。", "cws.count.total": "总计", "cws.count.failover": "故障转移", "cws.count.roundRobin": "轮询", + "cws.count.other": "其他", "cws.howTitle": "工作原理", "cws.howBody": "在 Codex 中请求组合的公开模型名称;未设置时默认使用 combo/。OpenCodex 仅在可重试的上游错误时切换目标。若没有可用目标,请求会直接失败,不会回退到全局默认提供方。", "cws.attentionTitle": "需要关注", @@ -2064,8 +2066,14 @@ export const zh: Record = { "cws.strategy": "策略", "cws.strategy.failover": "故障转移", "cws.strategy.roundRobin": "轮询", + "cws.strategy.random": "随机", + "cws.strategy.leastUsed": "最少使用", + "cws.strategy.resetWindow": "重置窗口", "cws.strategy.failoverHint": "按顺序尝试目标。若出现可重试错误(限流、故障、订阅门控),则跳到下一个。", "cws.strategy.roundRobinHint": "按权重确定性地分配流量。将所选目标保留一批成功请求后,再推进到下一个目标。", + "cws.strategy.randomHint": "每个请求按权重比例随机抽取一个可用目标,请求之间不保持粘性。", + "cws.strategy.leastUsedHint": "把每个请求路由到成功次数最少的可用目标。计数随代理重启归零。", + "cws.strategy.resetWindowHint": "优先选择配额窗口最早重置的可用目标。缺少配额数据时回退到配置顺序。", "cws.field.id": "组合 ID", "cws.field.idHint": "客户端将请求 {model}", "cws.field.idInternalHint": "组合的内部 ID,创建后仍可修改。", @@ -2091,6 +2099,9 @@ export const zh: Record = { "cws.targets": "目标", "cws.targets.failoverHint": "顺序很重要 — 第一个为主。", "cws.targets.roundRobinHint": "权重控制确定性的相对选择;顺序用于打破轮换环中的平局。", + "cws.targets.randomHint": "权重控制每次抽取的概率,顺序无关紧要。", + "cws.targets.leastUsedHint": "顺序仅在使用量相同的目标之间打破平局。", + "cws.targets.resetWindowHint": "配额数据缺失或相同时按顺序处理。", "cws.target.provider": "提供方", "cws.target.model": "模型", "cws.target.weight": "权重", diff --git a/gui/tests/combo-strategy-roundtrip.test.ts b/gui/tests/combo-strategy-roundtrip.test.ts index 9a28e45830..fcf3d81a53 100644 --- a/gui/tests/combo-strategy-roundtrip.test.ts +++ b/gui/tests/combo-strategy-roundtrip.test.ts @@ -6,7 +6,7 @@ * combo silently rewrote its strategy (and stripped weights for random). */ import { expect, test } from "bun:test"; -import { parseComboList, toPutBody } from "../src/combo-workspace-data"; +import { groupCombos, parseComboList, toPutBody } from "../src/combo-workspace-data"; const strategies = ["failover", "round-robin", "random", "least-used", "reset-window"] as const; @@ -64,3 +64,15 @@ test("round-robin still sends weights and stickyLimit", () => { expect(body.combo.targets[0]).toEqual({ provider: "openai", model: "gpt-5", weight: 2 }); expect(body.combo.stickyLimit).toBe(3); }); + +test("groupCombos keeps the three newer strategies in their own bucket", () => { + const combos = strategies.map((strategy) => parseComboList(payloadWith(strategy))[0]!); + const sections = groupCombos(combos); + expect(sections.failover.map((c) => c.strategy)).toEqual(["failover"]); + expect(sections.roundRobin.map((c) => c.strategy)).toEqual(["round-robin"]); + expect(sections.other.map((c) => c.strategy)).toEqual([ + "random", + "least-used", + "reset-window", + ]); +}); From 6a907d2a3c6496935ec87d86240a6a12b0ffa00b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 02:19:39 +0900 Subject: [PATCH 091/132] fix(upstream): apply fresh-connection recovery on the sidecar and loop retry legs (#2932) `applyUpstreamRecoveryInit` exists because Bun has ignored a bare hop-by-hop `Connection: close` (oven-sh/bun#20492), so leaving a half-closed pooled socket needs the transport-level `keepalive: false` it adds. The main lanes call it (chat-native, compact, six sites in responses/core). Nine legs did not. The web-search and images loops received the `retryRecovery` argument `fetchWithResetRetry` hands them and spent it on `onAttemptSend` telemetry, then built a plain init. The seven sidecar and vision executors passed zero-argument thunks: they retried, but could not ask for fresh-connection recovery. Every replay on those legs therefore stayed eligible for the same dead socket the reset came from, so recovery was luck, and the retry reported as exhausted rather than as the pool problem it was. All nine now route their init through the helper. On the one leg that also pins a provider HTTP version, recovery nests inside `withUpstreamHttpVersion` so `protocol` and the recovery fields survive together; the reverse order needs a `?? init` fallback to type-check and would drop one of them. Two regressions in tests/web-search.test.ts observe each attempt's init. Both were driven red four ways: reverting the sidecar leg, reverting the loop leg, dropping only `keepalive` while keeping the header, and mis-nesting the composition so the protocol pin is lost. The last mutation also showed the pre-existing #2885 test covers only the routed leg, which is why the new sidecar assertion checks the pin. Normalizing headers changed one observable: three credential canaries in the gemini and exa suites read `init.headers` as a plain record and broke. Those reads now go through `new Headers(...)`, which is representation-independent; each was re-proven to still fail on a substituted bearer, so the invariant is unchanged. Deliberately does not close #2885: this is retry-path parity, not an explanation of the Bun 1.4.0 versus 1.3.14 difference. No wire capture was taken, so whether Bun normalizes the prohibited `Connection` header away under an HTTP/2 pin is unverified; `keepalive: false` is the field that does the work either way. --- .../000_units.md | 87 ++++++++++++ src/images/loop.ts | 9 +- src/vision/anthropic-describe.ts | 6 +- src/vision/describe.ts | 8 +- src/web-search/anthropic-executor.ts | 11 +- src/web-search/exa-executor.ts | 6 +- src/web-search/executor.ts | 10 +- src/web-search/gemini-executor.ts | 6 +- src/web-search/loop.ts | 11 +- src/web-search/xai-executor.ts | 6 +- tests/exa-web-search.test.ts | 3 +- tests/gemini-web-search.test.ts | 11 +- tests/web-search.test.ts | 134 ++++++++++++++++++ 13 files changed, 277 insertions(+), 31 deletions(-) create mode 100644 devlog/_plan/260830_lane_o_reset_recovery_parity/000_units.md diff --git a/devlog/_plan/260830_lane_o_reset_recovery_parity/000_units.md b/devlog/_plan/260830_lane_o_reset_recovery_parity/000_units.md new file mode 100644 index 0000000000..4a7c6b81d5 --- /dev/null +++ b/devlog/_plan/260830_lane_o_reset_recovery_parity/000_units.md @@ -0,0 +1,87 @@ +# Lane O — connection-reset recovery parity on the sidecar and loop legs + +Unit for the work-phase that followed Lane N. Its trigger was a hook restating +issue #2885 fallout as "the web-search sidecar ignores `upstreamHttpVersion` and +skips the fresh-connection retry treatment". Half of that was already shipped; +the other half turned out to be wider than the sidecar. + +## What was already true + +PR #2908 (`22f2df614`) landed the transport-pin half. `src/web-search/loop.ts:326` +resolves `deps.incomingMeta.providerFetch`, both send legs use it, and +`src/server/responses/core.ts:5006` rebuilds it at send time so a 429 rotation +cannot pin a stale credential. `src/web-search/executor.ts:77` wraps the sidecar +leg in `withUpstreamHttpVersion(forwardProvider)`. Nothing in that description is +outstanding, and no part of this unit re-does it. + +## The gap that was real + +`applyUpstreamRecoveryInit` (`src/lib/upstream-retry.ts:295`) exists for one +reason: Bun has ignored the hop-by-hop `Connection: close` header +(oven-sh/bun#20492), so leaving a half-closed pooled socket needs the +transport-level `keepalive: false` extension as well. Setting the header alone +lets the retry land back on the same dead socket. + +The main lanes call it — `src/server/chat-native.ts:207`, +`src/server/responses/compact.ts:715`, and six sites in +`src/server/responses/core.ts` (3831, 3902, 4103, 4163, 5521, 6038). The +web-search loop and the images loop take the `retryRecovery` argument +`fetchWithResetRetry` hands them, spend it on `deps.onAttemptSend` telemetry, and +then build a plain init. Every sidecar executor passes a zero-argument thunk: it +still retries, but it cannot ask for fresh-connection recovery. + +Be precise about the consequence. `fetchWithResetRetry` retries a reset up to +three times, and on these legs each replay stays *eligible* to reuse the pooled +socket the reset came from — not guaranteed to, since the pool may hand out +another. That is enough to make recovery a matter of luck, and the retry then +reports as exhausted rather than as the pool problem it is. It matches the +failure shape #2885 reported without explaining it, and this unit does not claim +to close that issue. + +`src/adapters/kiro-retry.ts` already hand-rolls the same two fields (header at +168, `keepalive` at 173) and is out of scope; an independent audit confirmed it +correct. + +## Diff + +Thread the recovery init through the legs that already receive the recovery kind, +and give the sidecar thunks the argument they were missing: + +- `src/web-search/loop.ts` and `src/images/loop.ts` — pass the existing + `retryRecovery` through `applyUpstreamRecoveryInit`, preserving the + `accept-encoding: identity` handling and the provider-scoped executor. +- the sidecar executors — accept the recovery argument and route their init the + same way, composed so a protocol pin and the recovery fields cannot displace + each other. + +## Composition constraint + +`withUpstreamHttpVersion` spreads `{...(init ?? {}), protocol}` and is typed to +return `RequestInit | undefined`; `applyUpstreamRecoveryInit` spreads +`{...init, headers}` and adds `keepalive`. The order is not free. Recovery goes +**inside**: + +```ts +withUpstreamHttpVersion(url, applyUpstreamRecoveryInit(baseInit, recovery), provider) +``` + +so the recovery helper always receives a defined init and the version helper +spreads the result, keeping headers, `keepalive`, body, signal, and redirect +alongside `protocol`. The reverse nesting needs a `?? baseInit` fallback to type-check +at all and would otherwise dereference the `undefined` branch. An independent +audit probed the composed object under Bun and observed `protocol`, +`keepalive: false`, `connection: close`, the body, and `redirect: "manual"` +surviving together. The regression asserts a pinned provider still sees its +`protocol` on the replay. + +What is not verified: no wire capture was taken. Under an HTTP/2 pin, +`Connection` is a prohibited hop-by-hop header and Bun may normalize it away +while still honoring `keepalive: false`. The same canonical helper already runs +on provider paths that support an HTTP/2 pin, so this is a documented unknown +rather than a reason to exclude a site. + +## Evidence standard + +A green suite proves nothing here. Each assertion is driven red by reverting its +own site to the plain init, and any assertion that stays green under that +mutation is deleted rather than kept. diff --git a/src/images/loop.ts b/src/images/loop.ts index 74f759b2b5..0191215960 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -21,7 +21,7 @@ import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { isTranslatorBudgetExceededError, @@ -521,12 +521,15 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise fetch(`${base}/v1/messages`, { + recovery => fetch(`${base}/v1/messages`, applyUpstreamRecoveryInit({ method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal, - }), + }, recovery)), { abortSignal: linkedSignal.signal, label: "vision-sidecar-anthropic" }, ); if (!res.ok) { diff --git a/src/vision/describe.ts b/src/vision/describe.ts index d580a607e8..b919fb738a 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -4,7 +4,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; -import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { parseSidecarSSE } from "../web-search/parse"; import type { SidecarOutcomeRecorder } from "../web-search/executor"; @@ -90,7 +90,9 @@ export async function describeImage( const t0 = Date.now(); try { const res = await fetchWithResetRetry( - () => fetch(`${forwardProvider.baseUrl}/responses`, { + // The replay needs `keepalive: false` to abandon the half-closed pooled socket; Bun has + // ignored a bare `Connection: close` (oven-sh/bun#20492). + recovery => fetch(`${forwardProvider.baseUrl}/responses`, applyUpstreamRecoveryInit({ method: "POST", headers, body: JSON.stringify(body), @@ -99,7 +101,7 @@ export async function describeImage( // across origins but forwards nonstandard headers such as `chatgpt-account-id`, // `session_id`, and `x-codex-turn-metadata` to the redirect target. redirect: "manual", - }), + }, recovery)), { abortSignal: linkedSignal.signal, label: "vision-sidecar" }, ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts index aeba03a829..1eb206afa8 100644 --- a/src/web-search/anthropic-executor.ts +++ b/src/web-search/anthropic-executor.ts @@ -4,7 +4,7 @@ import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/a import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fingerprint"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; -import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import type { WebSearchSource } from "./parse"; import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; @@ -162,7 +162,14 @@ export async function runAnthropicWebSearch( const t0 = Date.now(); try { const res = await fetchWithResetRetry( - () => fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal }), + // The replay needs `keepalive: false` to leave the half-closed pooled socket; Bun has + // ignored a bare `Connection: close` (oven-sh/bun#20492). + recovery => fetch(url, applyUpstreamRecoveryInit({ + method: "POST", + headers, + body: JSON.stringify(body), + signal: linkedSignal.signal, + }, recovery)), { abortSignal: linkedSignal.signal, label: "web-search-sidecar-anthropic" }, ); // Guard before any branch reads the body: the failure branch's `res.text()` ran ahead of diff --git a/src/web-search/exa-executor.ts b/src/web-search/exa-executor.ts index 12e8e7ef02..2170eec140 100644 --- a/src/web-search/exa-executor.ts +++ b/src/web-search/exa-executor.ts @@ -8,7 +8,7 @@ * redirect: "manual" because Bun forwards custom headers across redirects. * Never throws; every error string passes redactSecretString. */ -import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -39,13 +39,13 @@ export async function runExaWebSearch( const t0 = Date.now(); try { const res = await fetchWithResetRetry( - () => fetch(EXA_SEARCH_URL, { + recovery => fetch(EXA_SEARCH_URL, applyUpstreamRecoveryInit({ method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey }, body: JSON.stringify({ query, numResults: EXA_NUM_RESULTS, contents: { text: { maxCharacters: EXA_SNIPPET_CHARS } } }), signal: linkedSignal.signal, redirect: "manual", - }), + }, recovery)), { abortSignal: linkedSignal.signal, label: "exa-web-search-sidecar" }, ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 84daf31b9a..840f062fbc 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -3,7 +3,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; -import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { withUpstreamHttpVersion } from "../lib/upstream-http-version"; import { parseSidecarSSE, type WebSearchResult } from "./parse"; import type { CodexUpstreamOutcome } from "../codex/routing"; @@ -74,7 +74,11 @@ export async function runWebSearch( const t0 = Date.now(); try { const res = await fetchWithResetRetry( - () => fetch(url, withUpstreamHttpVersion(url, { + // Recovery nests INSIDE the version helper: applyUpstreamRecoveryInit then always receives a + // defined init, and withUpstreamHttpVersion spreads the result, so `protocol` and the + // recovery fields (`connection: close` + Bun's transport-level `keepalive: false`) survive + // together. The reverse order needs a `?? init` fallback to type-check at all. + recovery => fetch(url, withUpstreamHttpVersion(url, applyUpstreamRecoveryInit({ method: "POST", headers, body: JSON.stringify(body), @@ -83,7 +87,7 @@ export async function runWebSearch( // across origins but forwards nonstandard headers such as `chatgpt-account-id`, // `session_id`, and `x-codex-turn-metadata` to the redirect target. redirect: "manual", - }, forwardProvider)), + }, recovery), forwardProvider)), { abortSignal: linkedSignal.signal, label: "web-search-sidecar" }, ); // Attach the body guard before ANY branch reads it. The success path guarded itself below, diff --git a/src/web-search/gemini-executor.ts b/src/web-search/gemini-executor.ts index f575526ef4..72c74169cd 100644 --- a/src/web-search/gemini-executor.ts +++ b/src/web-search/gemini-executor.ts @@ -10,7 +10,7 @@ */ import type { OcxProviderConfig } from "../types"; import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth"; -import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -69,7 +69,7 @@ export async function runGeminiWebSearch( const t0 = Date.now(); try { const res = await fetchWithResetRetry( - () => fetch(`${base}/v1internal:generateContent`, { + recovery => fetch(`${base}/v1internal:generateContent`, applyUpstreamRecoveryInit({ method: "POST", headers: { "Content-Type": "application/json", @@ -79,7 +79,7 @@ export async function runGeminiWebSearch( body: JSON.stringify(envelope), signal: linkedSignal.signal, redirect: "manual", - }), + }, recovery)), { abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" }, ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 4b3fde2a82..682e482eea 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -13,7 +13,7 @@ import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { isTranslatorBudgetExceededError, @@ -460,12 +460,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise fetch(url, { + recovery => fetch(url, applyUpstreamRecoveryInit({ method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify(body), signal: linkedSignal.signal, // Credential-bearing: never follow a redirect off the pinned origin. redirect: "manual", - }), + }, recovery)), { abortSignal: linkedSignal.signal, label: "xai-web-search-sidecar" }, ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); diff --git a/tests/exa-web-search.test.ts b/tests/exa-web-search.test.ts index 0ad77abab9..f9202f768b 100644 --- a/tests/exa-web-search.test.ts +++ b/tests/exa-web-search.test.ts @@ -242,7 +242,8 @@ describe("runExaWebSearch key hygiene (canary)", () => { expect(captured).toHaveLength(1); expect(captured[0]!.url).toBe("https://api.exa.ai/search"); expect(captured[0]!.init.redirect).toBe("manual"); - expect((captured[0]!.init.headers as Record)["x-api-key"]).toBe("key-1"); + // Representation-independent: the init may carry a plain record or a Headers instance. + expect(new Headers(captured[0]!.init.headers).get("x-api-key")).toBe("key-1"); } finally { globalThis.fetch = realFetch; } diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 0cc71cca4c..0f0bd782a1 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -129,9 +129,12 @@ describe("runGeminiWebSearch request shape (review P1)", () => { expect(new URL(req.url).origin).toBe("https://daily-cloudcode-pa.googleapis.com"); expect(req.url).toContain("/v1internal:generateContent"); expect(req.init.redirect).toBe("manual"); - const headers = req.init.headers as Record; - expect(headers["Authorization"]).toBe("Bearer gem-token-abc"); - expect(headers["User-Agent"]).toContain("antigravity"); + // Read through Headers so the credential assertion holds whether the init carries a plain + // record or a Headers instance: the reset-recovery helper normalizes headers on the send + // path, and this canary is about WHICH bearer goes out, not how the init spells it. + const headers = new Headers(req.init.headers); + expect(headers.get("Authorization")).toBe("Bearer gem-token-abc"); + expect(headers.get("User-Agent")).toContain("antigravity"); const body = JSON.parse(String(req.init.body)); expect(body.project).toBe("proj-9"); expect(body.userAgent).toBe("antigravity"); @@ -176,7 +179,7 @@ describe("runGeminiWebSearch request shape (review P1)", () => { try { const out = await runGeminiWebSearch("q", "google-antigravity", cca, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); expect(out.text).toBe("ok"); - expect((request!.headers as Record)["Authorization"]).toBe("Bearer token-a"); + expect(new Headers(request!.headers).get("Authorization")).toBe("Bearer token-a"); expect(JSON.parse(String(request!.body)).project).toBe("project-a"); expect(accountSets["google-antigravity"]!.activeAccountId).toBe("account-b"); } finally { diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 035c88e7ae..a212b4f892 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -13,6 +13,20 @@ import type { AdapterFetchContext, ProviderAdapter } from "../src/adapters/base" import type { OcxMessage, OcxParsedRequest } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import { withUpstreamHttpVersion } from "../src/lib/upstream-http-version"; + +/** + * Wrap a fetch so it applies the provider's HTTP-version pin the way `providerFetch` does in + * production. The reset-recovery tests need a provider-scoped executor that pins a protocol, so the + * composition order in the loop leg is observable without standing up the whole server path. + */ +function withUpstreamHttpVersionExecutor( + inner: typeof globalThis.fetch, + provider: Pick, +): typeof globalThis.fetch { + return ((input: Parameters[0], init?: RequestInit) => + inner(input, withUpstreamHttpVersion(input, init, provider))) as typeof globalThis.fetch; +} /** Run the web-search loop with a default test translator budget. */ function runWithWebSearch( @@ -2518,3 +2532,123 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { expect(frames.some(f => f.event === "response.completed")).toBe(true); }); }); + +describe("connection-reset recovery parity on the web-search legs", () => { + const originalGlobalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalGlobalFetch; }); + + /** Bun's reset rejection shape, as matched by isConnectionResetError. */ + function bunResetError(): Error { + return new Error("The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()"); + } + + type Observed = { keepalive: unknown; connection: string | null; protocol: string | undefined; body: unknown; redirect: string | undefined }; + + function observe(init: RequestInit | undefined): Observed { + const withExtras = init as (RequestInit & { keepalive?: unknown; protocol?: string }) | undefined; + return { + keepalive: withExtras?.keepalive, + connection: new Headers(init?.headers).get("connection"), + protocol: withExtras?.protocol, + body: init?.body, + redirect: init?.redirect, + }; + } + + test("the sidecar leg replays a reset on a fresh connection while keeping the provider HTTP version pin", async () => { + const attempts: Observed[] = []; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + attempts.push(observe(init)); + if (attempts.length === 1) throw bunResetError(); + return new Response("data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + await runOpenAiWebSearch( + "current docs", + { type: "web_search" }, + { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + upstreamHttpVersion: "http1.1", + }, + new Headers({ authorization: "Bearer selected-token" }), + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1_000 }, + ); + + expect(attempts.length).toBe(2); + // The first attempt must NOT force a fresh connection: pooling is the normal, faster path. + expect(attempts[0]!.keepalive).toBeUndefined(); + expect(attempts[0]!.connection).toBeNull(); + // The replay must leave the half-closed pooled socket. `keepalive: false` is the field that + // actually does it — Bun has ignored a bare `Connection: close` (oven-sh/bun#20492) — so + // assert both rather than treating the header as sufficient. + expect(attempts[1]!.keepalive).toBe(false); + expect(attempts[1]!.connection).toBe("close"); + // Composition order guard: recovery must not displace the protocol pin, or a user who set + // http1.1 to work around a transport failure loses it on exactly the retry that needs it. + expect(attempts[0]!.protocol).toBe("http1.1"); + expect(attempts[1]!.protocol).toBe("http1.1"); + // Credential-boundary and replayability fields survive the composition. + expect(attempts[1]!.redirect).toBe("manual"); + expect(typeof attempts[1]!.body).toBe("string"); + }); + + test("the routed loop leg replays a reset on a fresh connection through the provider-scoped fetch", async () => { + const attempts: Observed[] = []; + const routedProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://routed.test/v1", + apiKey: "routed-key", + upstreamHttpVersion: "http1.1", + }; + const providerScopedFetch = (async (_input: string | URL | Request, init?: RequestInit) => { + attempts.push(observe(init)); + if (attempts.length === 1) throw bunResetError(); + return new Response( + 'data: {"choices":[{"delta":{"content":"answer"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n", + { headers: { "Content-Type": "text/event-stream" } }, + ); + }) as typeof fetch; + globalThis.fetch = (async () => { + throw new Error("the routed leg must use the provider-scoped fetch, not the global one"); + }) as typeof fetch; + + const parsed = parseRequest({ model: "routed/model", input: "search please", stream: true }); + const response = await runWithWebSearch({ + parsed, + adapter: createOpenAIChatAdapter(routedProvider), + hostedTool: { type: "web_search" }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1_000 }, + maxSearches: 1, + selectedForwardHeaders: new Headers(), + forwardProvider: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + incomingMeta: { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + providerFetch: withUpstreamHttpVersionExecutor(providerScopedFetch, routedProvider), + }, + }); + + expect(response.status).toBe(200); + await response.text(); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts[0]!.keepalive).toBeUndefined(); + expect(attempts[0]!.connection).toBeNull(); + expect(attempts[1]!.keepalive).toBe(false); + expect(attempts[1]!.connection).toBe("close"); + expect(attempts[1]!.protocol).toBe("http1.1"); + // The loop sets accept-encoding: identity so raw byte progress stays observable; the recovery + // helper clones headers into a Headers instance and must not drop it. + expect(typeof attempts[1]!.body).toBe("string"); + }); +}); + From d882caed5eb212bf5737d3cb0022dace2dab418e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 03:12:38 +0900 Subject: [PATCH 092/132] fix(cursor): require a replayed call to precede the result it names (#2936) * docs(devlog): correct the red-test arithmetic and name two enumeration gaps An independent final-gate review measured the record wrong on both numbers: reverting only the call-site threading fails 3 of 6 assertions, not 2 of 5. The sixth test was added after the table was written, and it fails against a missing threading too -- with knownCalls undefined the suffix-only index names echo SECOND for a result whose output is FIRST, the same wrong label by a different route. Verified at 1241a8d5c: 16 pass / 3 fail. Also records two pre-existing gaps the completeness table did not account for, neither induced by the checkpoint cut: a fourth emission site in the conversationTurns native branch that never consults knownCalls, and the two builders gating on different predicates (cursorNeedsExternalToolContinuation vs isCursorExternalWireModel), which disagree for composer-2.5 -- measured as ROOT invoked=true, TURN_STEP invoked=false. Docs only: the cosmetic indentation fix was dropped so this PR carries no src change, since the hygiene gate reads a whitespace-only edit as behaviour. * fix(cursor): require a replayed call to precede the result it names The invocation-line index had no ordering constraint, so it would name a call that runs LATER in history than the result being labelled. Measured on dev with no patch, for both grok-4.6-high and composer-2.5: a result whose own output is EARLY-OUT came out as invoked: exec_command with {"cmd":"echo LATER"}. That is the failure toolCallsByCallId's own comment calls worse than no label, because nothing downstream can detect it. The index implemented the ambiguity half of that comment and not the ordering half, and #2900 shipped it. toolCallsByCallId now records each first binding's message index in a WeakMap side table, and callBefore returns a call only when it precedes the result. Positions compare in full-history space: the root loop's i is already there, and knownCallsOffset re-bases the checkpoint suffix. In conversationTurns the position is knownCallsOffset + start + w -- all three terms, because start is historyMessageStart and dropping it re-creates the #2910 orphan on the checkpoint path. Reachability is narrow: it needs a result serialized before its own call, which requires no id reuse. No live codex-exec repro is claimed. Five assertions added. Two go red without the bound; the checkpoint-plus-pruning row goes red when start is dropped and had to assert the turn step specifically, since the root path has no start term and hid the mutation. --- .../050_phase6_native_turn_orphan.md | 243 +++++++++++++++ .../060_phase7_positional_bound.md | 278 ++++++++++++++++++ src/adapters/cursor/protobuf-request.ts | 75 ++++- tests/cursor-tool-result-invocation.test.ts | 107 +++++++ 4 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 devlog/_plan/260829_cursor_tool_continuation_pairing/050_phase6_native_turn_orphan.md create mode 100644 devlog/_plan/260829_cursor_tool_continuation_pairing/060_phase7_positional_bound.md diff --git a/devlog/_plan/260829_cursor_tool_continuation_pairing/050_phase6_native_turn_orphan.md b/devlog/_plan/260829_cursor_tool_continuation_pairing/050_phase6_native_turn_orphan.md new file mode 100644 index 0000000000..688f59e888 --- /dev/null +++ b/devlog/_plan/260829_cursor_tool_continuation_pairing/050_phase6_native_turn_orphan.md @@ -0,0 +1,243 @@ +# 050 — Phase 6: the native turn branch still emits an orphaned result + +Depends on: `11d33597f` (#2910), `cfb70c972` (#2913), `6906049c6` (#2919), all on `origin/dev`. + +## Why this exists + +The final-gate review of #2910 flagged a fourth emission site as a MINOR finding and I deferred it, +on the grounds that it was pre-existing and not induced by the checkpoint cut. Both of those are +true. What I did not check before deferring is whether it produces **the same defect this whole unit +is about** — an emitted result envelope that names no invocation. + +It does. Measured on `origin/dev`: + +```text +no-interleave [composer-2.5] steps=["toolCall"] +interleaved [composer-2.5] steps=["toolCall","BARE_TEXT_ENVELOPE(invoked=false)"] +no-interleave [composer-2.5-fast] steps=["toolCall"] +interleaved [composer-2.5-fast] steps=["toolCall","BARE_TEXT_ENVELOPE(invoked=false)"] +``` + +So this is not a cosmetic gap in a doc table. It is the orphaned-result condition, reachable today, +on the native path. + +## Root cause + +`conversationTurns` handles a native `toolResult` by looking for its call in `pendingToolCalls`, a map +populated **only while walking the current turn**: + +```ts +const priorCall = pendingToolCalls.get(message.toolCallId); +if (priorCall) { + current.steps.push(toolCallStep(priorCall, requestScope, message)); // paired: call + result together + pendingToolCalls.delete(message.toolCallId); +} else { + current.steps.push(/* … */ toolResultToText(message) /* … */); // bare: no invocation named +} +``` + +A user message closes the current turn (`flush()`), which clears `pendingToolCalls`. So when history +interleaves a user message between a call and its result — an ordinary shape, not a contrived one — +the lookup misses and the `else` fires. That branch calls `toolResultToText(message)` with **no second +argument**, even though the function has accepted an optional `call` since #2900: + +```ts +function toolResultToText( + message: OcxToolResultMessage, + call?: Extract, +): string +``` + +`turnCalls` — the full-history index this unit already threads in — is in scope at that line and holds +exactly the call the fallback could not find. + +## The change — after TWO audits corrected it + +> **Audit round r2 returned VERDICT: FAIL** on the version of this plan below the first correction, +> with three BLOCKERs. This section records what was wrong, because the same reasoning error keeps +> recurring in this unit and the record is the only thing that makes it visible. +> +> The rewritten design is in "Design v3" further down. Everything between here and there is history. + +**The first version of this plan was wrong, and the audit gate caught it before implementation.** It +proposed resolving the fallback from `turnCalls`. That cannot work, and the reason is worth recording +because it is the same class of mistake this unit keeps making — reasoning about the code instead of +measuring it. + +`turnCalls` is gated on the external predicate: + +```ts +const turnCalls = externalModel ? (knownCalls ?? toolCallsByCallId(messages)) : undefined; +``` + +And the `toolResult` handler returns early for external models, *before* the `pendingToolCalls` +lookup exists. So the two sets are disjoint by construction: + +| Model | `isCursorExternalWireModel` | `turnCalls` | Reaches the `else` branch? | +|-------|------------------------------|--------------|----------------------------| +| `grok-4.6-high` | true | populated | **no** — external branch handles it | +| `composer-2.5` | false | `undefined` | yes | +| `composer-2.5-fast` | false | `undefined` | yes | + +Measured: `grok-4.6-high` already emits `ENVELOPE(invoked=true)` on the interleaved history, through +the external branch. Every model that reaches the fallback has `turnCalls === undefined`, so +`turnCalls?.get(...)` is unconditionally `undefined` there. The proposed one-line change would have +been **inert**, shipped green, and looked like a fix. + +The actual change is a separate index that does not ride the external gate: + +```ts +// Native fallback: the call is real history, just not in THIS turn's pending map. +const nativeCalls = knownCalls ?? toolCallsByCallId(messages); +… +const fallbackCall = nativeCalls.get(decodeCursorCallId(message.toolCallId)); +… toolResultToText(message, fallbackCall) … +``` + +Deliberately narrow: + +- The `if (priorCall)` paired path is untouched. When call and result sit in one turn, Cursor gets a + real `toolCallStep` carrying both halves, which is strictly better than text and must not change. +- Only the `else` branch — already a text envelope today — gains a line inside it. +- `knownCalls` is reused when the checkpoint path supplied it, so the covered-history lookup from + `040` applies here too rather than being re-derived from a slice. +- Ambiguity handling is inherited: `toolCallsByCallId` drops any id two different invocations claim, so + a reused id still yields no invocation line rather than a confidently wrong one. + +### Cost + +This indexes history for native models, which previously skipped it. Measured in `040` at 0.27 ms per +encode on a 401-message thread, against blob serialization and SHA-256 hashing already in the same +encode. Verified again for the native path in this phase. + +## Audit r2: three BLOCKERs against the design above + +An independent auditor copied `src/` to a scratch tree, applied the exact patch this plan proposed, +and ran both trees through the real encoder. Findings, each reproduced: + +**B1 — the fix would name a FUTURE call for a stale result.** `toolCallsByCallId` carries no +positional information, but `pendingToolCalls` was inherently backward-looking: it only ever held +calls already walked in the current turn. Replacing it with a whole-history index removes that bound. +Measured with a result at index 1 and its id's call at index 3 (`echo LATER`): + +| tree | output | +|------|--------| +| base | `TEXT invoked=false` | +| patched | `TEXT invoked=true — invoked: exec_command with {"cmd":"echo LATER"}` | + +The ambiguity guard does not catch this, because one call for an id is not ambiguous. I re-derived it +independently: `resultIndex=1`, `callIndex=3`, `callIndex > resultIndex` is true. This is precisely the +failure the index's own doc comment calls unacceptable — "an early result could be labelled with a +later command… a wrong invocation is worse than none". The root path escapes it only because it skips +results at or after `activeUserIndex`; the turn path has no such bound. + +**B2 — the added line can make a request fail to encode.** The turn path stores one blob per step with +no truncation guard. The root path has `truncateToolResultBlob`; `toolCallStep` degrades by dropping +images; this `else` branch has neither, and `storeCursorBlob` throws `CursorBlobAdmissionError` +unconditionally on rejection. With the entry ceiling lowered to reach the boundary cheaply, a +large-but-legal argument plus a result that fits in base threw `entry_too_large` in the patched tree. +The plan's cost section discussed only the 2 KB argument cap, never the step-blob total. + +**B3 — the "no-index guard" test row was false, and it was the row that would have caught B1.** +`nativeCalls` was unconditional, so no model stays un-indexed. Measured `invoked=false → true` for +`composer-2.5-fast`, `auto`, and `auto-intelligence`. A test asserting "unchanged" would have failed +immediately and been quietly rewritten to match observed output — the exact mechanism that produced +three partial fixes in this unit already. + +Plus: the affected set is wider than this plan listed. `isCursorNativeWireModel` returns true for +`auto` and `default` as well as `composer-*`, so `auto` and `auto-intelligence` reach the branch too. + +## Design v3 + +Three constraints, one per BLOCKER. + +**Positional bound (B1).** The fallback accepts a call only when it appears *before* the result in +history. That needs an index carrying position, so `toolCallsByCallId` gains a variant that records the +message index of each first binding, and the fallback compares against the result's own index. A call +at a later index yields no invocation line — the honest degradation the existing code already prefers. + +**The bound must compare within ONE coordinate system, and this is the trap.** `040` threads a +**full-history** index into a **sliced** replay: `buildPreparedCursorRunRequest` builds +`toolCallsByCallId(request.rawMessages)` and hands it to `conversationTurns`, which then iterates +`rawMessages.slice(suffixStart)` using slice-local positions. Comparing a full-history `callIndex` +against a slice-local `resultIndex` compares two different origins. Worked example with +`suffixStart = 4`, the call at full index 1 and the result at full index 4: + +| comparison | result | +|------------|--------| +| full vs full (correct) | `1 < 4` → accept | +| full vs slice-local (naive) | `1 < 0` → **reject** | + +A naive bound therefore drops the invocation line for a legitimately earlier call — silently +re-creating, on the checkpoint path, the exact orphan #2910 was merged to fix. So the fallback must +either receive the suffix offset and compare `callIndex < suffixStart + localIndex`, or the index must +be built over the same message array the loop walks. Whichever is chosen, a test must pin the +checkpoint case specifically, because a unit test on full replay alone cannot see this. + +**Byte budget (B2).** The rendered step is measured against `cursorBlobMaxEntryBytes()` before it is +stored. If naming the invocation would not fit, the envelope is emitted **without** the invocation +line rather than throwing: the result output is the payload, the invocation line is a convenience, and +that ordering is already established by the root path's `PROBE a huge argument must not evict the +result output` test. + +**Honest scope (B3).** The change affects every model that reaches this branch — `composer-2.5`, +`composer-2.5-fast`, `auto`, `auto-intelligence` — and the tests must assert that, not the opposite. +No test claims a model is unchanged when it is not. + +What stays untouched: the `if (priorCall)` paired path. The auditor confirmed it is byte-identical +across all five models in the patched tree, and it produces a real `mcpToolCall` protobuf step +carrying both halves, which is strictly better than any text envelope. + +### The 363-B question, answered rather than inherited + +The previous draft asserted safety by inheritance. The specific guard forbids a `[Tool Call]` marker, +and `toolInvocationLine` emits none — confirmed, no `[Tool Call]` string appears in any patched turn +step. But the auditor named a shape that does not exist on the external path: the text envelope now +sits directly beside a genuine `mcpToolCall` step describing the same call, so the same invocation is +described twice in one turn. Given that `040` already records a live `composer-2.5` run fabricating a +`[Tool Result]` envelope as chat, that duplication is not obviously harmless. + +This is why the phase does **not** widen the gate and does not proceed on inference. The narrow +question — a result whose call is genuinely absent from the current turn gets its invocation named, +bounded by position and by bytes — is decidable from the wire. Whether a native model should see the +same call described twice is a live-behaviour question, and it is deferred with that reason stated. + +## The predicate question, deliberately not answered here + +`turnCalls` is gated on `isCursorExternalWireModel`, while the root builder gates on the wider +`cursorNeedsExternalToolContinuation`. They disagree for `composer-2.5` (true vs **false**), so this +change alone will not name the invocation for that model's turn steps. + +Widening the turn gate to match would change what a *native* model receives on its resume path, and +this unit has already shipped three partial fixes by reasoning about the Cursor wire instead of +measuring it. The gate stays as it is; the asymmetry stays recorded in `040`. What this phase fixes is +the case where the index already exists and was simply not consulted. + +## Tests + +In `tests/cursor-tool-result-invocation.test.ts`, driven red before the fix: + +Two rows of the previous table were factually wrong and audit r2 rejected them: one named an +"external" model when every model reaching this branch is native by `isCursorExternalWireModel`, and +one asserted native turn steps were "unchanged" when the patch changes them for four models. A test +that asserts the opposite of what the code does gets quietly rewritten to match observed output, which +is how this unit shipped three partial fixes. + +| Test | Without the fix | +|------|-----------------| +| a native result separated from its call by a user message names its invocation in the turn step | **red** | +| a result whose id's call appears LATER in history gets no invocation line | **red** — B1 bound | +| naming the invocation is dropped, not thrown, when the step would exceed the entry ceiling | **red** — B2 budget | +| a call and result inside one turn still pair into an mcpToolCall step, not text | green — paired-path guard | +| every model reaching the branch is named explicitly (`composer-2.5`, `composer-2.5-fast`, `auto`, `auto-intelligence`) | green — scope is asserted, not assumed | +| `grok-4.6-high` is unaffected, because the external branch handles it before this code | green — disjointness guard | +| on the CHECKPOINT path, a call before `suffixStart` is still accepted by the positional bound | **red** — coordinate-system guard | + +The second and third rows are the ones that did not exist before the audit, and they are the two that +encode its BLOCKERs as executable checks rather than prose. + +## Verification + +- Focused `bun test` on the cursor files. +- `bun x tsc --noEmit`. +- Full suite on `ssh lidge`; no local full-suite run as a gate. diff --git a/devlog/_plan/260829_cursor_tool_continuation_pairing/060_phase7_positional_bound.md b/devlog/_plan/260829_cursor_tool_continuation_pairing/060_phase7_positional_bound.md new file mode 100644 index 0000000000..a05efb763a --- /dev/null +++ b/devlog/_plan/260829_cursor_tool_continuation_pairing/060_phase7_positional_bound.md @@ -0,0 +1,278 @@ +# 060 — Phase 7: bound the invocation lookup by position + +Depends on: `11d33597f` (#2910), `cfb70c972` (#2913), `6906049c6` (#2919) on `origin/dev`. +Supersedes the implementation intent of `050`; that unit's own defect is now the smaller half of this +one. + +## Why this replaces 050 + +`050` set out to name the invocation on a native turn-branch fallback. Two independent audit rounds +failed it (r2 and r3), and the second one found something that outranks the thing `050` was trying to +fix: **the mislabel is already on the wire, in code merged today.** + +Measured on the tracked tree with **no patch applied** — a result whose own output is `EARLY-OUT`, +labelled as having been produced by a command that runs later in history: + +```text +grok-4.6-high => invoked: exec_command with {"cmd":"echo LATER"} | output=EARLY-OUT +composer-2.5 => invoked: exec_command with {"cmd":"echo LATER"} | output=EARLY-OUT +``` + +This is the failure mode `toolCallsByCallId`'s own doc comment calls unacceptable: "an early result +could be labelled with a later command — a wrong invocation is worse than none, since it is the kind +of mislabel the model cannot detect." The index implements the *ambiguity* half of that comment and +not the *ordering* half. + +So the priority inverts. A missing invocation line on a native turn step is a cosmetic gap; a **wrong** +invocation line on the shipped external root path is the defect this unit exists to prevent, and I +introduced it in #2900. + +## Root cause + +`toolCallsByCallId` carries no position. It keeps the first call for an id and drops ids claimed by +two different invocations, but nothing constrains *where* the winning call sits relative to the result +being labelled. The comment's parenthetical — "results follow their call, so the first binding is the +one an earlier result belongs to" — is an assumption about history order, not something the code +checks. + +`050`'s draft claimed the root path escaped this via `activeUserIndex`. That is false, and audit r3 +disproved it: `activeUserIndex` is `-1` whenever the last raw message is a `toolResult`, and otherwise +it bounds the loop end, never the call index. I re-measured it above on the shipped tree. + +## Reachability, stated honestly + +This needs a history where a result's id is first claimed by a *later* call. It is not the common +shape: Codex normally replays a full thread in which the call precedes its result. + +My first draft framed the precondition as id reuse. Audit r4 corrected that — **the actual +precondition is only "a result precedes its call in serialized order"**, and it is reachable without +any id reuse at all: a result serialized before the assistant message declaring its call was measured +being labelled from that later message, with two distinct ids. Routes: + +- a result emitted before its own call in the serialized order — no reuse required; +- an id reused by a retry after the original call has left the replayed window. + +It also does not need a contrived trailing shape. On the ordinary trailing-`toolResult` continuation — +the single most common shape this proxy sees — `activeUserIndex` is `-1` and the loop walks the entire +history, so nothing bounds the lookup at all. + +I have **not** produced this from a live `codex exec` run, and I am not claiming a live repro. What is +demonstrated is that the encoder produces a confidently wrong label when given the shape, on the path +that ships today. Given that a mislabel is undetectable downstream by design, that is worth closing on +its own terms rather than waiting for a user to hit it. + +## The change + +Give the index position, and require the call to precede the result. + +`toolCallsByCallId` gains a companion that records the message index of each first binding. Both +emission sites already know the result's index — the root loop has `i` (it already passes +`messageIndex: i` into `pushDeduped`), and the turn loop can carry it. A call at an index **not less +than** the result's index yields no invocation line: the same honest degradation the ambiguity path +already takes. + +### The coordinate-system trap + +Both audits converged on this and it is the reason a naive bound is worse than none. `040` threads a +**full-history** index into a **sliced** replay: the checkpoint path builds +`toolCallsByCallId(request.rawMessages)` and hands it to builders that iterate +`rawMessages.slice(suffixStart)`. A full-history call index compared against a slice-local result +index compares two different origins: + +| `suffixStart = 4`, call at full index 1, result at full index 4 | comparison | outcome | +|---|---|---| +| correct | `1 < 4` | accept | +| naive (full vs slice-local) | `1 < 0` | **reject** | + +Audit r3 measured both directions of this on a patched tree: it rejects valid pairings *and* can +accept for the wrong reason. Rejecting silently re-creates, on the checkpoint path, the exact orphan +#2910 was merged to fix. + +The bound therefore compares in one coordinate system. Two options looked available: + +1. build the index over the **same array the loop walks**; +2. keep the **full-history** index and have the loop convert its local index to full space before + comparing. + +**Option 1 is self-contradictory and this plan initially chose it.** The checkpoint site threads a +full-history index *precisely because the call can sit outside the slice* — that is what #2910 fixed. +Rebuilding the index over the slice removes that call from the index altogether, so the invocation is +lost for exactly the shape phase 5 closed. Worked through with `suffixStart = 2`, the call at full +index 1 and the result at full index 3: + +| option | call in index? | comparison | outcome | +|--------|----------------|------------|---------| +| 1 — rebuild over slice | **no** | n/a | invocation LOST, re-breaks #2910 | +| 2 — full index + offset | yes | `1 < 2 + 1 = 3` | named, and ordering enforced | + +So the design is **option 2**: the index stays full-history, and each emission site converts the +position it walks into full-history space before comparing. The root path already has the full-history +`i` when it is not slicing; the checkpoint path must add its `suffixStart`, which means that offset has +to be passed to the builders alongside `knownCalls` rather than inferred. + +That is one more parameter than option 1 would have needed, and it is the price of not re-breaking the +previous phase. Recording the wrong first choice because "no offset to thread" is exactly the kind of +simplicity argument that produced the last three partial fixes. + +### The three origins ADD — write the expression, not the parts + +There are three offsets in play, and the comparison position is their **sum**: + +```text +resultFullIndex = knownCallsOffset + start + w + + knownCallsOffset : checkpointSuffixStart, or 0 on full replay + start : historyMessageStart in conversationTurns, or 0 + w : the loop's own position within the array it walks +``` + +Audit r5 implemented this plan and then mutation-tested the two readings the earlier prose permitted. +Both typecheck cleanly and passed all 274 cursor tests **plus the five rows the table below had at the +time**. Row 6 exists because of this measurement, so it is the one row they do not pass — see the note +under the table. + +| variant | 274 cursor tests | live behaviour | +|---------|------------------|----------------| +| `start + w` (drops `knownCallsOffset`) | 273 pass, 1 fail | caught | +| `knownCallsOffset + w` (drops `start`) | **274 pass** | **live orphan** | +| `knownCallsOffset > 0 ? offset + w : start + w` | **274 pass** | **live orphan** | + +The shape that exposes the two survivors is checkpoint **and** root pruning together: +`suffixStart = 1` with a large turn inside the suffix forcing `historyMessageStart = 3`. Correct +arithmetic names the call; both survivors emit no invocation line — re-creating on the checkpoint path +the exact orphan #2910 fixed, which is the failure this document spends its longest section warning +about. + +So the expression is normative. An implementer who derives only one term ships something that looks +green from every angle this plan would otherwise check. + +**Storage mechanism, so review does not relitigate it:** `toolCallsByCallId` returns a bare `Map`, so +positions go in a side table keyed by the returned map — a `WeakMap>` — rather +than changing the return type and every caller. There are four `toolCallsByCallId(` invocations +(`rg -c` on the file) across two builders and the checkpoint site. Audit r6 built the side table exactly +as specified and confirmed the return type and all existing call sites stay unchanged, with positions +recorded on first binding and deleted alongside the ambiguity drop. + +**Loop rewrite caution:** converting `conversationTurns`' `for…of` to an indexed loop should keep an +`if (!message) continue;` guard, matching the existing root loop. Audit r6 corrected my stated reason: +`noUncheckedIndexedAccess` is **not** enabled in this repo, so `walked[w]` types as `OcxMessage` and no +narrowing is lost — removing the guard still typechecks. Confirmed: `grep -c noUncheckedIndexedAccess +tsconfig.json` returns 0. So the guard is a runtime-consistency choice, not a strictness requirement, +and an implementer who tests the original justification would find it did not hold. + +## Tests + +**Exactly one row is red without the fix.** Saying "every row must fail first" would be the same +overclaim `040` was audited for twice: the accept-side rows exist to stop the bound from becoming a +blanket refusal, and a guard that is green before *and* after is doing its job. What matters is that no +row is **vacuous** — every row must be red under at least one wrong implementation. + +| Test | Unpatched | Red under | +|------|-----------|-----------| +| a result whose id's call appears LATER in history gets NO invocation line | **red** — names `echo LATER` today | the defect itself | +| the same history with the call EARLIER still names it | green | a bound that refuses everything | +| on the checkpoint ROOT path, a call before `suffixStart` is still named | green | `same-array`, `naive` | +| on the checkpoint TURN path, the same call is still named | green | `same-array`, `naive` | +| an id ambiguous in FULL history but not in the suffix yields no line | green | `same-array` | +| on the checkpoint TURN path with root pruning too (`suffixStart` > 0 **and** `historyMessageStart` > 0) the call is still named | green | `knownCallsOffset + w`, `start + w`, ternary | + +Row 5 is the one audit r4 said was missing, and it is the most important guard in the table. The +plain "an ambiguous id yields no line" row I originally listed does **not** catch suffix-narrowing — +measured green under `same-array` — because the ambiguity is visible in the slice too. The guard has to +construct ambiguity that full history sees and the suffix does not. That test already exists in the +tree as `an ambiguous id resolved from full history is not re-resolved from the suffix`, added in +#2919, so this phase must keep it green rather than write a new one. + +Rows 3 and 4 are split because audit r4 showed the single row as worded was satisfiable by the root +path alone, which would let a turn-path regression through. + +Row 6 is the one audit r5 proved was missing, and it must assert against the **turn** path. Audit r6 +built it both ways on identical history and only the turn form discriminates: + +| row 6 asserts against | correct | `knownCallsOffset + w` | ternary | `start + w` | +|---|---|---|---|---| +| turn path | pass | **fail** | **fail** | **fail** | +| root path | pass | pass | pass | fail | + +The reason is structural, not fixture luck. `historyMessageStart` is an *output* of +`rootPromptMessages`, assigned only after its loop finishes, while that loop walks full-history `i` from +zero — so the root path's expression reduces to `knownCallsOffset + 0 + i` and `knownCallsOffset + w` is +*identical* to the correct one there. No root-path test can ever separate them. Only +`conversationTurns` carries `start = historyMessageStart` into its slice. + +This is the same defect rows 3 and 4 were split to avoid, in the one row that must not have it: a +table that cannot distinguish a correct derivation from a plausible wrong one is the shape of every +earlier failure in this unit. Row 6 is also the only row whose preconditions must be checked rather +than assumed — r6 instrumented it and confirmed `offset=1 start=1 w=2`, both offsets genuinely +non-zero, so the row exercises the composition instead of being incidentally satisfied. + +### Measured across implementations + +Audit r4 implemented every coordinate option behind one knob and ran identical tests: + +Test counts below differ by **file scope**, not because the suite grew — r4 measured the three files +this unit touches (124 tests: `cursor-tool-result-invocation` 19, `cursor-tool-continuation` 12, +`cursor-blob` 93), r5 and r6 widened to seven and nine cursor files respectively. The three-file figure +is the one this phase gates on, and it is reproducible with +`bun test tests/cursor-tool-result-invocation.test.ts tests/cursor-tool-continuation.test.ts tests/cursor-blob.test.ts`. + +| implementation | new rows | three-file cursor suite | +|----------------|----------|-------------------------| +| shipped (no bound) | row 1 red | 124 pass | +| `same-array` (this plan's first choice) | row 3/4 red | **121 pass, 3 fail** | +| `naive` (condemned by r2/r3) | row 3/4 red | 122 pass, 2 fail | +| **`offset`** (the design above) | **all green** | **124 pass** | + +`same-array` is worse than the option two earlier audits already rejected: besides losing the +out-of-slice call, it narrows the ambiguity evidence and emits `invoked: … echo SECOND` for a result +whose output is `FIRST` — a fresh instance of the wrong-label defect, on the checkpoint path. + +### A third coordinate origin + +`conversationTurns` iterates `messages.slice(start, historyEnd)` with a `for…of` over **values**, so it +has no index at all today, and `start` is `historyMessageStart` — non-zero on the full-replay path after +root pruning. The loop-local position is therefore `start + w`, not `w`. Audit r4 confirmed this third +origin produces no mislabel on its own, so it is an implementation trap rather than a live defect, but +an implementer who reads only the `suffixStart` discussion above will walk straight into it. + +## Scope + +The bound lives in the shared lookup, so it covers every consumer at once — the external root path +(where the mislabel is live), the external turn path, and the checkpoint variants of both. + +The native turn-branch fallback from `050` is **not** included. Audit r3 showed the affected id set is +48 wire ids rather than the four `050` listed, that the paired `mcpToolCall` step already describes the +same call so the envelope is not as orphaned as `050` claimed, and that a raw-vs-decoded id keying +asymmetry between `pendingToolCalls` and the index is unaccounted for. That is a separate phase with +its own measurements, not a rider on a correctness fix. + +## Verification + +### Implementation notes: row 6 took five fixtures to make discriminate + +The plan predicted row 6 would catch a dropped `start` term. Getting a fixture that actually does took +five attempts, and the failures are worth recording because each one looked correct: + +| attempt | why it did not discriminate | +|---------|------------------------------| +| `suffixStart = 1`, 400 KiB filler | cut left the call INSIDE the slice, so no covered call was exercised | +| `suffixStart = 2`, 400 KiB filler | 400 KiB is under the 512 KiB root budget, so nothing pruned and `start` stayed 0 | +| `suffixStart = 2`, 600 KiB filler | call was in the COVERED region, where its position is below the offset and the under-count cannot cross it | +| call adjacent to result, 600 KiB | correct shape, but the assertion pooled roots **and** turn steps | +| same, asserting the TURN step only | **discriminates** | + +The fourth is the instructive one. Pooling both sources hid the mutation exactly as the plan's own +analysis said it would: the root path has no `start` term to drop, so it keeps naming the call and an +either-source assertion stays green. Instrumenting the loop gave `offset=1 start=1 w=2`, so under the +mutation the result's computed position was 3 while its call sits at 3 — `3 >= 3` rejects, the turn step +loses its invocation line, and the root step still has one. + +The condition was derived rather than guessed after the third failure: dropping `start` under-counts a +walked message by exactly `start`, so it flips the decision only when the call is inside the slice and +`w_result - w_call <= start`. + +- Focused `bun test` on the cursor files; row 1 driven red first, and each guard row driven red against + the wrong implementation it exists to catch. +- `bun x tsc --noEmit`. +- `bun run privacy:scan` — the declared CI gate in `AGENTS.md`, omitted from the first draft of this list. +- Full suite on `ssh lidge`; no local full-suite run as a gate. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 0ea1c91fc4..14f7f1c4e6 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -214,6 +214,12 @@ function rootPromptMessages( * which is where the defect this line prevents actually reappeared in live use. */ knownCalls?: Map>, + /** + * Full-history index of `rawMessages[0]` for this call. Non-zero only on the checkpoint path, where + * only a suffix is replayed but `knownCalls` still spans full history; the positional bound needs + * both sides in the same space (devlog 260829 060). + */ + knownCallsOffset = 0, ): { ids: Uint8Array[]; byteLength: number; @@ -320,7 +326,9 @@ function rootPromptMessages( // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; - const text = `${prefix}\n${toolResultToText(message, replayedCalls?.get(decodeCursorCallId(message.toolCallId)))}`; + // The bound compares in full-history space: this loop's `i` is already full-history on the + // full-replay path, and `knownCallsOffset` re-bases it when only a suffix is replayed. + const text = `${prefix}\n${toolResultToText(message, callBefore(replayedCalls, decodeCursorCallId(message.toolCallId), knownCallsOffset + i))}`; pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text); } } @@ -748,6 +756,42 @@ function toolInvocationLine(call: Extract>, + Map +>(); + +/** + * The indexed call for `callId`, but only when it appears BEFORE `resultIndex` in history. + * + * `toolCallsByCallId` has no ordering constraint, so it would happily name a call that runs LATER than + * the result being labelled — a result whose own output is `EARLY-OUT` was measured on the shipped tree + * as `invoked: exec_command with {"cmd":"echo LATER"}`. That is the mislabel the index's own comment + * calls worse than no label, because nothing downstream can detect it (devlog 260829 060). + * + * `resultIndex` MUST be in full-history space. The checkpoint path replays a suffix and the turn + * builder starts at `historyMessageStart`, so a caller composes `knownCallsOffset + start + local` + * before calling; comparing a full-history call index against a slice-local result index silently + * drops legitimate pairings and re-creates the orphan #2910 fixed. + */ +function callBefore( + calls: Map> | undefined, + callId: string, + resultIndex: number, +): Extract | undefined { + const call = calls?.get(callId); + if (!call || !calls) return undefined; + const position = callPositions.get(calls)?.get(callId); + if (position === undefined || position >= resultIndex) return undefined; + return call; +} + /** * Index assistant tool calls by decoded call id so a replayed result can name its invocation. * @@ -762,8 +806,10 @@ function toolInvocationLine(call: Extract> { const calls = new Map>(); const ambiguous = new Set(); - for (const message of messages) { - if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + const positions = new Map(); + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + if (!message || message.role !== "assistant" || !Array.isArray(message.content)) continue; for (const part of message.content) { if (part.type !== "toolCall") continue; const callId = decodeCursorCallId(part.id); @@ -771,6 +817,7 @@ function toolCallsByCallId(messages: readonly OcxMessage[]): Map>, + /** Full-history index of `rawMessages[0]`; see {@link rootPromptMessages}. */ + knownCallsOffset = 0, ): Uint8Array[] { const messages = request.rawMessages; if (!messages?.length) return []; @@ -967,7 +1018,15 @@ function conversationTurns( pendingToolCalls.clear(); }; - for (const message of messages.slice(start, historyEnd)) { + const walked = messages.slice(start, historyEnd); + for (let w = 0; w < walked.length; w++) { + const message = walked[w]; + // `for…of` gave this for free; keep it explicit so the indexed loop behaves identically. + if (!message) continue; + // Full-history position of this message: the slice offset the caller passed, plus where this + // loop starts inside `rawMessages`, plus the local step. All three terms are needed — dropping + // `start` still passes every test except the checkpoint-plus-pruned-root case (devlog 060). + const fullIndex = knownCallsOffset + start + w; if (message.role === "assistant") { if (!current) continue; for (const part of message.content) { @@ -1004,7 +1063,7 @@ function conversationTurns( const prefix = normalized.isError ? "[Tool Error]" : "[Tool Result]"; // Name the invocation here as well, for the same reason the root replay does: a result with // no visible originating call reads as an interrupted attempt (devlog 260829 000_rca). -const call = turnCalls?.get(decodeCursorCallId(message.toolCallId)); + const call = callBefore(turnCalls, decodeCursorCallId(message.toolCallId), fullIndex); const invocation = call ? `${toolInvocationLine(call)}\n` : ""; current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { @@ -1170,8 +1229,10 @@ function buildPreparedCursorRunRequest( // Index calls from the FULL history, not the suffix: the cut can fall between a call and // its result, and a result replayed without its invocation is the orphaned-result defect. const fullHistoryCalls = toolCallsByCallId(request.rawMessages); - const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls); - const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls); + // `suffixStart` re-bases the replayed slice into full-history space, which is the space + // `fullHistoryCalls` positions live in. Without it the positional bound compares two origins. + const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart); + const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); const suffixSystemCount = systemPromptBlobs(suffixRequest).length; const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount); diff --git a/tests/cursor-tool-result-invocation.test.ts b/tests/cursor-tool-result-invocation.test.ts index 545c1e4ef0..2c87fe0942 100644 --- a/tests/cursor-tool-result-invocation.test.ts +++ b/tests/cursor-tool-result-invocation.test.ts @@ -446,3 +446,110 @@ describe("cursor checkpoint continuation names the invocation from covered histo expect(root).not.toContain("echo SECOND"); }); }); + +/** + * devlog 260829 060: the index that names an invocation had no ordering constraint, so it would name a + * call that runs LATER in history than the result being labelled. Measured on the shipped tree, a + * result whose own output was `EARLY-OUT` came out as + * `invoked: exec_command with {"cmd":"echo LATER"}` — the mislabel the index's own comment calls worse + * than no label, because nothing downstream can detect it. + * + * The bound compares positions in FULL-HISTORY space. That matters because two call sites replay less + * than the whole history: the checkpoint path replays a suffix, and the turn builder starts at + * `historyMessageStart`. The comparison position is therefore `knownCallsOffset + start + local`, and + * dropping any term passes almost every test here — which is why the last case exists. + */ +describe("cursor invocation lookup is bounded by history position", () => { + const FWD = "call_fwd"; + + /** Result at index 1; the call claiming its id is at index 3. */ + function forwardHistory(): OcxMessage[] { + return [ + { role: "user", content: "start", timestamp: 1 }, + { role: "toolResult", toolCallId: FWD, toolName: "exec_command", content: "EARLY-OUT", isError: false, timestamp: 2 }, + { role: "user", content: "next", timestamp: 3 }, + { + role: "assistant", + content: [{ type: "toolCall", id: FWD, name: "exec_command", arguments: { cmd: "echo LATER" } }], + timestamp: 4, + }, + { role: "user", content: "answer", timestamp: 5 }, + ]; + } + + test("a result whose call appears LATER in history gets no invocation line", () => { + const root = resultRoot(encode(forwardHistory(), "grok-4.6-high")); + expect(root).toBeDefined(); + expect(root).toContain("EARLY-OUT"); + expect(root).not.toContain("invoked:"); + expect(root).not.toContain("echo LATER"); + }); + + test("the turn step is bounded too", () => { + const step = turnStepTexts(encode(forwardHistory(), "grok-4.6-high")) + .find(text => text.startsWith("[Tool Result]")); + if (step) { + expect(step).not.toContain("invoked:"); + expect(step).not.toContain("echo LATER"); + } + }); + + // The bound must not become a blanket refusal: without this, a lookup that returns nothing at all + // would satisfy the case above and look correct. + test("the ordinary call-then-result order is still named", () => { + const root = resultRoot(encode(history(), "grok-4.6-high")); + expect(root).toContain("invoked: exec_command with"); + expect(root).toContain("echo AAA"); + }); + + test("a call before the checkpoint cut is still named on the root path", () => { + const root = resultRoot(encodeCheckpoint(history(), "grok-4.6-high", 2)); + expect(root).toContain("invoked: exec_command with"); + }); + + /** + * The one case that needs all THREE offset terms. Audits r5, r6 and r7 each measured that a bound + * computing `knownCallsOffset + local` — dropping `historyMessageStart` — passes every other + * assertion in this file and the whole cursor suite, while emitting a live orphan here. + * + * It cannot be caught on the root path: `historyMessageStart` is an OUTPUT of `rootPromptMessages`, + * assigned after the loop that would use it, so that loop always walks full-history `i` from zero and + * the dropped term is identically zero there. Only `conversationTurns` carries a non-zero `start`. + * + * Both offsets must actually be non-zero for the case to bite, so the history forces a checkpoint cut + * AND enough root pressure to prune, and the assertion is on the TURN step. + */ + test("checkpoint plus root pruning still names the call on the turn path", () => { + const CK = "call_ck3"; + // CURSOR_EXTERNAL_ROOT_BYTE_LIMIT is 512 KiB; this must exceed it to force any pruning, so + // historyMessageStart lands above zero. A 400 KiB message left it at 0 and made the case toothless. + const bulky = "Z".repeat(600 * 1024); + const messages: OcxMessage[] = [ + { role: "user", content: "first", timestamp: 1 }, + // Pruned from the root, which is what pushes historyMessageStart above zero. + { role: "user", content: bulky, timestamp: 2 }, + { role: "user", content: "carry on", timestamp: 3 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CK, name: "exec_command", arguments: { cmd: "echo COVERED" } }], + timestamp: 4, + }, + { role: "toolResult", toolCallId: CK, toolName: "exec_command", content: "COVERED-OUT", isError: false, timestamp: 5 }, + { role: "user", content: "answer", timestamp: 6 }, + ]; + // Derived rather than guessed: dropping `start` under-counts a walked message's position by + // exactly `start`, so it flips the decision only when the call is INSIDE the slice and + // (w_result - w_call) <= start. The call must therefore sit next to its result in the replayed + // region, not in the covered region — three earlier fixtures put it in the covered region, where + // the call's position is below the offset and the under-count can never cross it. + const bytes = encodeCheckpoint(messages, "grok-4.6-high", 1); + // Assert on the TURN step specifically. Pooling roots and turn steps together hid the mutation: + // the root path has no historyMessageStart term to drop (it is an OUTPUT of rootPromptMessages, + // assigned after the loop that would use it), so the root keeps naming the call and an + // either-source assertion stays green. Only the turn step discriminates. + const step = turnStepTexts(bytes).find(text => text.includes("COVERED-OUT")); + expect(step).toBeDefined(); + expect(step).toContain("invoked: exec_command with"); + expect(step).toContain("echo COVERED"); + }); +}); From dd159db9088033bf58c91add8968b0b83bbf263c Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 30 Aug 2026 03:37:55 +0900 Subject: [PATCH 093/132] fix(cli): send the cleanup percent in the policy target the server reads (#2937) `ocx storage policy set --percent N` serialized a top-level `percent` field. The PUT contract has no such field: `normalizeStorageCleanupPolicy` reads only `target`, so the value was dropped and the previously stored target survived. The request still answered 200 with a policy body, so the operator saw success while the stored target was unchanged. On a policy holding the 25% default, `--percent 10` left cleanup authorized to remove considerably more data than was asked for. Send `target: { removeOldestPercent: N }` instead. Out-of-range values are still forwarded so the server answers with its named 400 rather than the CLI duplicating the 1-100 vocabulary; a rejected write is the correct outcome, and the silent accepted write is what this removes. --- src/cli/storage.ts | 11 ++++++++++- tests/cli-storage-inspect.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/cli/storage.ts b/src/cli/storage.ts index d23c181050..ed13aa6710 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -173,7 +173,16 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { } const body: Record = {}; if (enabled !== undefined) body.enabled = enabled === "true"; - if (percent !== undefined) body.percent = percent; + // The policy target is nested. A top-level `percent` is not part of the PUT contract: + // `normalizeStorageCleanupPolicy` reads only `target`, so the field was dropped and the + // previously stored target survived. `--percent 10` on a policy still holding the + // default 25% therefore reported success while leaving cleanup authorized to delete + // more than the operator asked for. + // + // An out-of-range value is deliberately still sent: the server owns the 1-100 + // vocabulary and answers with a named 400, which is a rejected write rather than the + // silent wrong write this replaces. + if (percent !== undefined) body.target = { removeOldestPercent: percent }; if (mode !== undefined) body.mode = mode; if (schedule !== undefined) body.schedule = schedule; if (Object.keys(body).length === 0) { diff --git a/tests/cli-storage-inspect.test.ts b/tests/cli-storage-inspect.test.ts index 3a39627f01..cdf992e852 100644 --- a/tests/cli-storage-inspect.test.ts +++ b/tests/cli-storage-inspect.test.ts @@ -142,7 +142,31 @@ describe("ocx storage trash and policy", () => { expect(calls[0]?.method).toBe("PUT"); // `enabled` is absent, which the server reads as "keep the stored value". Sending // `enabled: false` here would silently disable a policy the operator never mentioned. - expect(calls[0]?.body).toEqual({ percent: 40 }); + // The percent travels inside `target`: the PUT contract has no top-level `percent`, so + // that shape was accepted, dropped, and left the stored target in place. + expect(calls[0]?.body).toEqual({ target: { removeOldestPercent: 40 } }); + }); + + test("--percent reaches the server in the shape the policy target actually reads", async () => { + // A top-level `percent` round-trips as HTTP 200 while changing nothing: + // `normalizeStorageCleanupPolicy` reads only `target`, so a policy still holding the + // default 25% stayed at 25% after `--percent 10` reported success — cleanup remained + // authorized to delete more than the operator asked for. + const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); + const cap = capture(); + try { await handleStorageCommand(["policy", "set", "--percent", "10"], deps); } finally { cap.restore(); } + const body = calls[0]?.body as Record; + expect(body).toEqual({ target: { removeOldestPercent: 10 } }); + expect(body).not.toHaveProperty("percent"); + }); + + test("an out-of-range percent is still sent so the server can name the rejection", async () => { + // Rejecting locally would duplicate the server's 1-100 vocabulary. A named 400 is a + // refused write; the defect being fixed here was a silent accepted one. + const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); + const cap = capture(); + try { await handleStorageCommand(["policy", "set", "--percent", "0"], deps); } finally { cap.restore(); } + expect(calls[0]?.body).toEqual({ target: { removeOldestPercent: 0 } }); }); test("policy set with no fields is refused rather than sent as an empty write", async () => { From 3a9835ca91d34affe97a4028b616d33e86d09d5b Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 30 Aug 2026 03:43:49 +0900 Subject: [PATCH 094/132] fix(codex): reconcile the refreshed plan on the shared flight, not the owner's wait (#2933) A refresh flight deliberately outlives the caller that opened it: an aborted owner stops waiting while the shared work keeps running and still commits the rotated credential for every joiner. Plan reconciliation, however, still ran only after the owner's caller-scoped wait, and the same-account joiner path returns through the adopt-stored branch without reconciling either. A rotated token carrying a changed chatgpt_plan_type therefore committed while codexAccounts[].plan stayed stale for the life of the process, skewing plan-selected quota projection until a restart or an unrelated WHAM refresh. Attach reconciliation to the flight's committed result so it runs exactly once per flight regardless of which waiters are still present, including none. The joiner-CAS reconciliation for a different account id is unchanged. The regression polls for the persisted plan under a deadline rather than sleeping a fixed interval: the flight is detached from every caller by then, so a fixed delay can pass before the commit lands on a loaded worker, let teardown race unfinished work, and never prove reconciliation actually ran. --- src/codex/account-store.ts | 19 +++++- tests/codex-account-store.test.ts | 97 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 73658a6078..d919171364 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -638,7 +638,7 @@ async function resolveCodexToken( const abort = new AbortController(); const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]); let flight!: RefreshFlight; - const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise => { + const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise => { const current = readCodexAccountRecord(id); const lockedRecord = readCodexAccountRecord(id); const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined; @@ -759,6 +759,22 @@ async function resolveCodexToken( resolvedGrantFingerprint: refreshGrantFingerprint, selfRefreshed: true, }; + }); + /* + * Plan reconciliation belongs to the FLIGHT, not to whichever caller opened it. + * + * The flight outlives its initiating caller by design (gap 2): an aborted owner stops + * waiting while the shared work still runs and still commits the rotated credential. + * Reconciling the plan only after the owner's caller-scoped wait therefore dropped it + * whenever that owner walked away, and a same-account joiner returning through the + * adopt-stored branch does not reconcile either — so a changed `chatgpt_plan_type` + * stayed invisible in `codexAccounts[].plan` for the life of the process and skewed + * plan-selected quota projection. Attaching it to the flight runs it exactly once per + * committed result, for every waiter, including none. + */ + const refreshPromise = fetchPromise.then(async (result): Promise => { + await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation); + return result; }).finally(() => { if (refreshLocks.get(refreshGrantFingerprint) === flight) refreshLocks.delete(refreshGrantFingerprint); }); @@ -769,7 +785,6 @@ async function resolveCodexToken( // registered, so a joiner that arrives after this caller walks away still receives // the committed result. const result = await awaitOwnCancellation(refreshPromise, callerSignal); - await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation); return { accessToken: result.accessToken, chatgptAccountId: result.chatgptAccountId, diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 760cd610ab..6dcebd4d1e 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -16,6 +16,17 @@ function refreshLockPathForToken(refreshToken: string): string { return join(TEST_DIR, `codex-refresh-${digest}.lock`); } +/** Minimal unsigned JWT carrying the plan claim the store reconciles from. */ +function planJwt(plan: string, accountId = "acct-plan-flight"): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify({ + chatgpt_account_id: accountId, + chatgpt_plan_type: plan, + "https://api.openai.com/auth": { chatgpt_account_id: accountId, chatgpt_plan_type: plan }, + })).toString("base64url"); + return `${header}.${body}.sig`; +} + describe("codex-account-store CRUD", () => { beforeEach(() => { // These exercises cover credential-store contention, not Windows ACL behavior. @@ -995,3 +1006,89 @@ describe("codex-account-store CRUD", () => { } }); }); + +describe("shared refresh flight plan reconciliation (#2892 gap 2 follow-up)", () => { + beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + process.env.OPENCODEX_HOME = TEST_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + setIcaclsRunnerForTests(null); + delete process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("an aborted owner still reconciles the refreshed plan for the shared flight", async () => { + // The flight deliberately outlives the caller that opened it, so plan reconciliation + // must not hang off that caller's wait: a rotated token carrying a NEW + // chatgpt_plan_type would otherwise commit while codexAccounts[].plan stayed stale + // for the rest of the process, skewing plan-selected quota projection. + const { forceRefreshCodexPoolToken, readCodexAccountRecord, saveCodexAccountCredential } = + await import("../src/codex/account-store"); + const { loadConfig, saveConfig } = await import("../src/config"); + const { resetJwtPlanNotesForTests } = await import("../src/codex/plan-from-token"); + resetJwtPlanNotesForTests(); + + saveConfig({ + port: 10199, + providers: {}, + defaultProvider: "openai", + codexAccounts: [{ id: "plan-flight", email: "flight@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("plan-flight", { + accessToken: planJwt("plus"), + refreshToken: "plan-grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acct-plan-flight", + }); + const generation = readCodexAccountRecord("plan-flight")!.generation; + + const originalFetch = globalThis.fetch; + let releaseFetch: (() => void) | undefined; + const fetchStarted = new Promise(resolve => { + globalThis.fetch = (async () => { + resolve(); + await new Promise(release => { releaseFetch = release; }); + return Response.json({ + access_token: planJwt("pro"), + refresh_token: "plan-grant2", + expires_in: 3600, + }); + }) as typeof fetch; + }); + + try { + const owner = new AbortController(); + const ownerCall = forceRefreshCodexPoolToken("plan-flight", { + rejectedGeneration: generation, + rejectedAccessToken: planJwt("plus"), + signal: owner.signal, + }); + await fetchStarted; + owner.abort(new Error("client disconnected")); + await expect(ownerCall).rejects.toThrow("client disconnected"); + + releaseFetch?.(); + // The flight is detached from every caller now, so there is nothing to await. Poll + // for the persisted outcome under a deadline instead of a fixed delay: a fixed + // sleep can pass before the flight commits on a loaded worker and let teardown race + // unfinished work, and it never proves the reconciliation actually ran. + const deadline = Date.now() + 5_000; + let persisted = loadConfig().codexAccounts?.[0]; + while ((persisted?.plan !== "pro" || persisted?.planSource !== "jwt") && Date.now() < deadline) { + await Bun.sleep(10); + persisted = loadConfig().codexAccounts?.[0]; + } + + expect(persisted?.plan).toBe("pro"); + expect(persisted?.planSource).toBe("jwt"); + expect(readCodexAccountRecord("plan-flight")!.credential!.accessToken).toBe(planJwt("pro")); + } finally { + globalThis.fetch = originalFetch; + resetJwtPlanNotesForTests(); + } + }); +}); From f5b8529f73d9c1468ce3a005ecd26c4be12d1516 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 04:46:36 +0900 Subject: [PATCH 095/132] fix(codex): heal a dormant same-grant record and drop stale 401 evidence (#2934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): heal a dormant same-grant record and drop stale 401 evidence Two remaining gaps from #2892. Gaps 1-2 shipped as 8f199fcb6, gap 5 as 84049830e. Gap 3. A refresh rotates the refresh token, but only the flight owner and live joiners ever learn the new one. A non-deleted record holding the same grant that is not participating in the flight keeps a token upstream has just invalidated; its next refresh sends a dead grant, and `invalid_grant` classifies as `revoked`, retiring a healthy account because we rotated its grant and never told it. Owner and eligible aliases now commit in one lock acquisition and one `persist`, so no window exists where some records hold the dead grant. Eligibility is deliberately narrow: same pre-refresh fingerprint, access token, expiry, and the same `chatgptAccountId` as the owner. The rotated access token, refresh token, and expiry move together. An adversarial design audit rejected a wider version of this and it is worth recording why, because the wider version looks obviously better and is not. Repairing only the grant of an alias whose access token had moved on would advance its generation while keeping the old access token. `plan-from-token` reads a higher generation as proof of a newer JWT -- that is how JWT plan claims supersede a WHAM observation -- so a stale JWT could then overwrite an authoritative plan. Worse, flights are keyed by grant and do not record participants, so such a scan cannot distinguish a dormant record from a live joiner; rotating a joiner's grant while preserving its 401-rejected bearer makes the provenance CAS inapplicable and the recursion's freshness shortcut hands the rejected token straight back. This therefore closes gap 3 only for genuinely untouched duplicates. A mixed alias still needs durable grant lineage and verified identity binding, which the current fingerprint model cannot express safely, and #2892 says so rather than claiming otherwise. Gap 4. The reporter described an async interleaving between the generation check and the side effects. That specific race is not reachable: `recordCodexUpstreamOutcome` is synchronous and no `await` separates them. The cross-process race is real regardless, since the check is an unlocked read while writers hold the mutation lock, and OS preemption needs no `await`. Health and reauth carry no credential identity, so a stale 401 landing in that window quarantines the replacement. Taking the credential lock per outcome is not available -- it runs with `busy_timeout=0`, which would turn contention into request errors -- so the sequence stays synchronous and re-validates afterwards, restoring the prior health and reauth state when the generation stopped being live. The window remains; its effects no longer survive it. Affinity sweeping is not rolled back: entries already carry a generation and self-invalidate, and re-adding swept entries would be the worse bug. Verification. 420 pass / 0 fail across codex-routing, codex-account-store, responses-pool-401-refresh, codex-plan, and codex-auth-api. Typecheck and privacy:scan clean. Six named mutations each drove a test red: owner-only CAS, dropping the identity guard, dropping the untouched-access guard, removing the rollback, and making the rollback unconditional -- the last two failing opposite tests, which is what proves the rollback is conditional rather than a blanket "never quarantine". The gap-4 regression needs a seam. A single process cannot schedule another process's write, so both reads would observe one store and any assertion would be vacuous. `setCodexCredentialRevalidationSeamForTests` stands in for that writer, is undefined in production, and costs one null check. * fix(codex): scope 401 evidence by generation and reconcile alias plans Review fixes for the two generation-boundary defects @Ingwannu and the Codex reviewer found on a798e2dbf. Both were reproduced on that exact head; both are real, and my original approach to each was wrong. Gap 4 was not closed by a post-write re-read. A replacement can land at any point after `recordCodexUpstreamOutcome` returns, so re-reading the store inside it narrows the window and nothing more. The reproduction is simply: record a 401 at G, let the call return, then persist G+1 — the quarantine still applied to G+1. Evidence is now tagged with the credential it describes and checked when READ. `credentialFailureGeneration` records the generation a 401/403 came from, and the health readers drop a failure whose credential no longer exists. The reauth set became a map from account id to the justifying generation; `undefined` is preserved as an account-wide mark so a login flow holding no specific credential still quarantines unconditionally, and an account-wide mark outranks a scoped one. A healthy terminal retires the tag. Alias propagation installed the rotated JWT but not its plan. A plus -> pro rotation left the alias holding a Pro credential while its configured plan stayed plus, and its cached-token fast path never repairs that, so quota scoring and the 30-day projection stayed wrong until a restart or a WHAM refresh. Each propagated alias is now reconciled at its OWN committed generation, so the commit returns `{ id, generation }` pairs rather than ids: aliases need not share a generation and the plan note is generation-fenced. The test seam is gone. The regression now reproduces the real surviving ordering with no seam at all, which is strictly better than the seam it replaces. One assertion in the new plan test was vacuous when written: with one save per record, owner and alias generations coincided, so asserting the per-alias fence passed even with the owner's generation substituted. The fixture now advances the alias so they diverge, and that mutation turns red. 421 pass / 0 fail across codex-routing, codex-account-store, codex-plan, responses-pool-401-refresh, and codex-auth-api. Typecheck and privacy:scan clean. Mutations: unscoping the evidence turns the gap-4 test red while the live-credential test stays green; owner-only plan reconciliation turns the alias plan test red; the owner-generation fence turns it red only once generations diverge. * fix(codex): own credential provenance per health entry and fence sidecar 401s Second review round on f8e7bbdc4. All three boundaries @Ingwannu reproduced were real; the first was a defect my own previous commit introduced. 1. Provenance now lives ON the health entry, not in a side map keyed by account id. The side map meant "spend whatever health is current when the old credential is found dead", so a G1 401 followed by a G2 save and a genuine G2 503 deleted the 503. `credentialFailureGeneration` is a field on CodexUpstreamHealth, only an entry carrying it can be spent, and any later write replaces the entry along with the tag. `preservedCooldownFields` explicitly drops it, or a cooldown write would inherit provenance belonging to a different failure — which is exactly the ownership bug one level down. 2. Stored-pool sidecar outcomes carry the generation. `sidecarOutcomeRecorder` and both `recordOutcome` closures in openai-sidecar could take a vision or web-search 401/403 and record it account-wide, so the replacement inherited the quarantine. All three now pass `credentialGeneration` for `kind === "pool"`. `main-pool` keeps unfenced semantics deliberately: it has no stored-record generation. 3. `findFreshCredentialForGrant` requires identity equality. Adoption copies both the access and refresh tokens, so a shared grant fingerprint was never sufficient proof that two records are the same upstream account. Both `chatgptAccountId` values must be non-empty and exactly equal; without an expected identity the function adopts nothing. Regressions, each mutation-proven: G1 401 -> G2 save -> G2 503 keeps the 503, and the same for a workspace-denial overwrite; a sidecar 401 does not quarantine the replacement; a same-grant sibling on a different identity is never adopted and is itself left untouched. Removing each guard turns its own test red. 494 pass / 0 fail across codex-routing, codex-account-store, codex-plan, responses-pool-401-refresh, codex-auth-api, vision-sidecar-e2e, and web-search. Typecheck and privacy:scan clean. Still open and acknowledged: if #2933 lands first, the alias plan settlement should be unified with its shared-flight path rather than kept as two loops. * refactor(codex): settle alias plans on the shared flight, not the commit Rebased onto dev after #2933 landed and unified the two plan-settlement paths, as agreed on that PR rather than leaving two competing loops. #2933 moved the owner's plan reconciliation onto the flight, because a flight outlives the caller that opened it and an aborted owner would otherwise drop the note entirely. My alias propagation had its own loop inside the credential commit, which would have made two places responsible for the same concern. The committed aliases now travel on CodexRefreshResult, and the flight settles the owner and every alias in one place. That inherits #2933's guarantee for free: an aborted owner still reconciles alias plans, which the commit-site loop could only have done for whoever happened to be waiting. Each alias keeps its own committed generation, since the plan note is generation-fenced. The joiner-CAS branch is deliberately untouched, matching #2933's boundary. 426 pass / 0 fail across codex-routing, codex-account-store, codex-plan, responses-pool-401-refresh, and codex-auth-api. Typecheck and privacy:scan clean. Dropping the alias carry from the unified path turns the plan-propagation test red, so the single path is genuinely covered. * fix(codex): fail closed on absent identity and stop testing past the guard Third review round on 7d9544924. All three blockers were correct. Empty account ids are not an identity. `""` equals `""`, but that proves nothing about which upstream account either record was meant to use, and a matching bearer snapshot only shows the two records copied the same token once. Propagation now requires the owner identity to be non-empty and each alias identity to be non-empty before exact equality, and leaves an unidentified dormant record untouched. The sidecar test was passing for the wrong reason. It hardcoded `writerGeneration: 0`, which sits below whatever reconciliation state earlier tests advanced to, so `recordCodexUpstreamOutcome` could reject the outcome at its writer-generation guard before reaching the credential-generation logic under test. It now captures the current generation the way a production pool auth context does, and asserts the quarantine actually applied before asserting it is later released — so the test cannot silently stop exercising its own subject. The gap-4 comment described the rollback design that no longer exists. It claimed health has no generation field, reauth is a bare id set, and mutations are re-validated and restored afterwards; the shipped code does the opposite. Rewritten to describe tagging and read-time judgement, including why no re-read can close the race and why `preservedCooldownFields` must drop the tag. 496 pass / 0 fail across the seven-file set run AS A SET, not filtered: codex-routing, codex-account-store, codex-plan, responses-pool-401-refresh, codex-auth-api, vision-sidecar-e2e, web-search. Typecheck and privacy:scan clean. Accepting empty ids turns the new regression red. Noting one difference in evidence: the order-dependent failure reported at codex-routing.test.ts:583 did not reproduce here on Bun 1.4.0, either filtered or as a set. The fragility was real regardless — a stale hardcoded writer generation can short-circuit the guard — so the fixture is fixed rather than left resting on a runtime difference. * fix(codex): spend a stale credential failure before any branch reads health CodeRabbit found a third ownership path on the current head and it reproduces: after a G1 401, a G2 replacement, and a genuine G2 503, consecutiveFailures was 2 instead of 1. Reader-side spending was not sufficient. The transient and workspace branches derive their new entry from the current one, so a spent G1 401 donated its failure count to G2's first real failure and dropped the provenance tag while writing — after which no read could detect the inheritance. The account then reached the failover threshold one failure early. recordCodexUpstreamOutcome now spends a stale credential failure once, before any branch inspects health, so transient, workspace and quota all start from evidence that still describes a live credential. The regression covers both inheritance paths, transient and workspace denial. Removing the entry-point spend turns it red with the exact 2-versus-1 count. Worth recording: my first attempt to mutation-prove this patched the wrong call site — the reader inside getCodexUpstreamHealth rather than the entry point — and the test stayed green, which would have looked like vacuous coverage. Mutating the intended site fails it correctly. 497 pass / 0 fail across the seven-file set as a set. Typecheck and privacy:scan clean. --- .../260830_lane_q_2892_gaps_3_4/000_units.md | 139 ++++++++++++++ src/codex/account-runtime-state.ts | 44 ++++- src/codex/account-store.ts | 126 ++++++++++++- src/codex/routing.ts | 75 +++++++- src/providers/openai-sidecar.ts | 5 + src/server/responses/core.ts | 5 + tests/codex-account-store.test.ts | 171 ++++++++++++++++++ tests/codex-plan.test.ts | 49 +++++ tests/codex-routing.test.ts | 135 +++++++++++++- 9 files changed, 739 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260830_lane_q_2892_gaps_3_4/000_units.md diff --git a/devlog/_plan/260830_lane_q_2892_gaps_3_4/000_units.md b/devlog/_plan/260830_lane_q_2892_gaps_3_4/000_units.md new file mode 100644 index 0000000000..d1515809ed --- /dev/null +++ b/devlog/_plan/260830_lane_q_2892_gaps_3_4/000_units.md @@ -0,0 +1,139 @@ +# Lane Q — issue #2892 gaps 3 and 4 + +The last two of the five gaps #2892 raised against the merged stored-Pool 401 +recovery path. Gaps 1–2 shipped as `8f199fcb6` (#2920), gap 5 as `84049830e` +(#2922). An independent recon audit re-derived both remaining gaps from current +`dev` and confirmed each is still reachable — and corrected the reporter on one +point, recorded below. + +## Gap 3 — a rotated grant never reaches an inactive same-grant alias + +A successful refresh persists the rotated credential to the flight owner only, via +the generation CAS at `src/codex/account-store.ts:748`. A live joiner can CAS the +result onto its own record. Nothing writes to a third category: a non-deleted +record carrying the same `refreshGrantFingerprint` that is not participating in +the flight. + +`findFreshCredentialForGrant` (`src/codex/account-store.ts:393`) is a pre-fetch +lookup and propagates nothing. So the alias keeps a refresh token that upstream +has just rotated away. The next refresh on that alias sends a dead grant, and +`invalid_grant` is classified `revoked` — which retires a healthy account. That +classification is correct behavior for a genuinely dead grant; the defect is that +the grant died because we rotated it and never told the alias. + +### The design an adversarial audit rejected + +My first plan had two branches: an untouched alias adopts the rotated credential +whole, and an alias whose access token had changed concurrently keeps its own +access token but takes **only** the rotated refresh token. An independent audit +refused that second branch, with two findings I could not rebut: + +- The generation fence in `src/codex/plan-from-token.ts:32` treats a higher + generation as proof of a **newer access-token JWT**, which is what lets JWT plan + claims supersede an older WHAM observation. Bumping a generation while + deliberately keeping the old access token lets a stale JWT overwrite an + authoritative plan. `tests/codex-plan.test.ts:129` already pins that meaning. +- A flight is keyed by grant and does not record participant account ids + (`src/codex/account-store.ts:308`), so a scan cannot distinguish a dormant alias + from a live joiner. Rotating a joiner's grant while preserving its 401-rejected + access token makes the provenance CAS inapplicable, and the recursion's + freshness shortcut then returns the rejected bearer — defeating 401 recovery in + exactly the case the branch existed to serve. + +### What ships instead + +One batch compare-and-swap, one `persist`, and a deliberately narrow eligibility +test. An alias is repaired only when it is provably an untouched duplicate of the +pre-refresh credential: same old grant fingerprint, same access token, same +expiry, and the same `chatgptAccountId` as the owner. Such an alias receives the +rotated access token, refresh token, and expiry **together**, so the generation +bump keeps meaning what every fence already assumes. `replacedAt` and the +validation metadata are preserved, because the probe-lease lineage check accepts +only an intact `G → G+1`. + +Anything else is left alone: a differing access token, a differing account id, or +a tombstone. The `chatgptAccountId` equality requirement is not decoration — a +fingerprint is `sha256` of the refresh token and carries no identity claim +(`src/codex/account-store.ts:62`), and no repository invariant guarantees one +grant cannot span two account ids. + +This is a **partial** close of gap 3, and the issue comment says so. Dormant +duplicates stop being retired for a grant we rotated away; a mixed alias still is. +Healing that case needs durable grant lineage and verified identity binding, which +the current fingerprint-and-generation model cannot express safely. + +The flight's returned `resolvedGrantFingerprint` stays the **old** fingerprint: +joiners wait on that key, and retagging it would make every legitimate joiner look +foreign. + +## Gap 4 — stale credential evidence writes unscoped state + +The reporter described an async interleaving between validation and mutation. That +part is wrong and worth stating: `recordCodexUpstreamOutcome` is synchronous +(`src/codex/routing.ts:2095`) and there is **no `await`** between the generation +check at `src/codex/routing.ts:2210` and the mutations at 2216–2223. The +same-process race the issue describes is not reachable. + +The cross-process race is real regardless. The check is an unlocked synchronous +store read (`src/codex/account-store.ts:186`) while writers coordinate under the +mutation lock, and OS preemption needs no `await`. The side effects then carry no +credential identity: health entries have no generation field, reauth state is a +bare `Set` fenced only by config generation, and affinity clearing removes +every entry for the account. + +Affinity is already self-invalidating on the next generation check. Health and +reauth were not. + +My first attempt snapshotted the state, mutated, then re-read the generation and +rolled back. Two reviewers independently rejected it, correctly: a replacement can +land at any point *after* `recordCodexUpstreamOutcome` returns, so a post-write +read narrows the window without closing it. @Ingwannu reproduced the surviving +ordering on the exact head — record a 401 at G, return, then persist G+1, and the +quarantine still applied to G+1. + +The evidence is now tagged with the credential it came from and checked when it is +*read*, which is what actually settles it. `credentialFailureGeneration` holds the +generation a 401/403 was derived from, and the health readers (`shouldFailover`, +`getCodexUpstreamHealth`) drop a failure whose credential is gone. The reauth set +became a map from account id to the generation that justified the flag, with +`undefined` preserved as an account-wide mark so a login flow with no specific +credential still quarantines unconditionally. + +Affinity clearing stays un-reverted: entries already carry a generation and +self-invalidate, so re-adding swept entries would be the worse bug. +`recordCodexUpstreamOutcome` stays synchronous — many callers consume it as +`void` (`src/server/responses/core.ts:391`), so making it async would silently +leave mutations unawaited. The config lock is still never taken on the request +path; it runs with `busy_timeout=0`, and per-outcome acquisition would turn +contention into request errors. + +## The alias plan note + +Review also caught that propagation installs the rotated JWT on an alias but left +its configured plan alone: a `plus → pro` rotation gave the alias a Pro credential +while its plan stayed `plus`, and the cached-token fast path never repairs that, so +quota scoring and the 30-day projection stayed wrong until a restart or a WHAM +refresh. Each propagated alias is now reconciled at its **own** committed +generation, which is why the commit returns `{ id, generation }` rather than ids — +aliases need not share a generation, and the plan note is generation-fenced. + +That last point produced the one genuinely vacuous assertion of this unit: with a +single `saveCodexAccountCredential` per record, owner and alias generations +coincided, so an assertion about the per-alias fence passed even when the code used +the owner's generation. The fixture now advances the alias twice so the generations +diverge, and the mutation turns red. + +## Constraints the audit flagged + +The refresh-flight map is keyed by the old grant (`src/codex/account-store.ts:315`) +and joiner provenance deliberately carries that old fingerprint, so alias +propagation must not disturb that ordering. The config lock runs with +`busy_timeout=0` and must stay synchronous, so the routing path must not acquire +it per outcome. Affinity requires exact credential-generation equality, so any +alias generation bump has to be reasoned about rather than assumed harmless. + +## Evidence standard + +Each regression is driven red by a named mutation, using the existing blocked-fetch +seam rather than a timing sleep. Any assertion that survives its mutation is +deleted rather than kept. diff --git a/src/codex/account-runtime-state.ts b/src/codex/account-runtime-state.ts index a2a6495e6a..ff036429d7 100644 --- a/src/codex/account-runtime-state.ts +++ b/src/codex/account-runtime-state.ts @@ -1,18 +1,43 @@ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; +import { isCodexAccountGenerationLive } from "./account-store"; -const reauthAccounts = new Set(); +/** + * Accounts quarantined for reauthentication, each remembering WHICH credential produced the + * evidence (#2892 gap 4). + * + * A 401 describes one credential, not an account. Recording only the id let a 401 raced by a + * cross-process credential replacement quarantine the replacement: the flag outlived the credential + * it was evidence about, and routing then refused a perfectly good credential until a restart. A + * post-write re-read cannot fix that — the replacement may land at any point after the write — so + * the generation travels WITH the flag and is checked when the flag is read. + * + * `undefined` means "no credential generation was supplied", which stays account-wide: callers such + * as a login flow have no specific credential in hand, and their quarantine must not silently expire. + */ +const reauthAccounts = new Map(); let lastReconciledGeneration = 0; let liveAccountIds = new Set(); -export function markAccountNeedsReauth(id: string, writerGeneration = captureConfigGeneration()): void { +export function markAccountNeedsReauth( + id: string, + writerGeneration = captureConfigGeneration(), + credentialGeneration?: number, +): void { if (writerGeneration < lastReconciledGeneration && !liveAccountIds.has(id)) return; - reauthAccounts.add(id); + // An account-wide mark supersedes a generation-scoped one: it is the stronger claim. + if (credentialGeneration === undefined || !reauthAccounts.has(id)) { + reauthAccounts.set(id, credentialGeneration); + return; + } + const existing = reauthAccounts.get(id); + if (existing === undefined) return; + reauthAccounts.set(id, Math.max(existing, credentialGeneration)); } export function reconcileCodexReauthState(context: GenerationContext): number { if (context.generation <= lastReconciledGeneration) return 0; let removed = 0; - for (const id of reauthAccounts) { + for (const id of [...reauthAccounts.keys()]) { if (context.codexAccountIds.has(id)) continue; reauthAccounts.delete(id); removed += 1; @@ -23,7 +48,16 @@ export function reconcileCodexReauthState(context: GenerationContext): number { } export function isAccountNeedsReauth(id: string): boolean { - return reauthAccounts.has(id); + if (!reauthAccounts.has(id)) return false; + const credentialGeneration = reauthAccounts.get(id); + if (credentialGeneration === undefined) return true; + // The credential this evidence describes is gone, so the evidence is spent. Drop it rather than + // re-deriving the same answer on every read. + if (!isCodexAccountGenerationLive(id, credentialGeneration)) { + reauthAccounts.delete(id); + return false; + } + return true; } export function clearAccountNeedsReauth(id: string): void { diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index d919171364..011ac0692e 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -218,6 +218,97 @@ export function saveCodexAccountCredentialIfGeneration( }); } +/** + * Commit a refreshed credential to its owner AND to any record that is provably an untouched + * duplicate of the pre-refresh credential (#2892 gap 3). + * + * A refresh normally rotates the refresh token, and the owner CAS above changes only the owner's + * record. A second non-deleted record holding the same grant that is not participating in the + * flight therefore keeps a refresh token upstream has just rotated away. Its next refresh sends a + * dead grant, and `invalid_grant` classifies as `revoked` — retiring a healthy account because we + * rotated its grant and never told it. + * + * Eligibility is deliberately narrow, and each condition earns its place: + * + * - Same pre-refresh grant fingerprint, access token, AND expiry. Anything else means the alias was + * updated concurrently, and repairing only its grant while keeping its own access token would + * advance a generation without advancing the access-token JWT. `plan-from-token` reads a higher + * generation as proof of a newer JWT (that is how JWT plan claims supersede a WHAM observation), + * so that combination lets a stale JWT overwrite an authoritative plan. It would also hand a live + * forced-refresh joiner back its own 401-rejected bearer: flights are keyed by grant and do not + * record participants, so a scan cannot tell a dormant alias from a joiner, and the recursion's + * freshness shortcut does not re-compare against the rejected token. + * - Same `chatgptAccountId` as the owner. A fingerprint is `sha256` of the refresh token and + * carries no identity claim; no invariant here guarantees one grant cannot span two account ids, + * so identity is compared rather than assumed. + * + * The rotated access token, refresh token, and expiry move together, keeping a generation bump + * meaning what every fence already assumes. `replacedAt` and the validation metadata survive + * because the probe-lease settlement check accepts only an intact `G → G+1` lineage. + * + * One lock acquisition and one `persist` for the owner and every alias: `persist` writes the whole + * store, so a second pass would open a window in which some records hold the dead grant. + */ +export function commitRefreshedCodexCredentialWithAliases( + id: string, + generation: number, + cred: CodexAccountCredentials, +): { committed: boolean; propagatedAliases: { id: string; generation: number }[] } { + return withCredentialMutationLockSync(() => { + const store = loadCodexAccountRecordStore(); + const current = store[id]; + if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) { + return { committed: false, propagatedAliases: [] }; + } + const priorCredential = current.credential; + const priorFingerprint = recordGrantFingerprint(current); + const refreshGrantFingerprint = priorCredential.refreshToken === cred.refreshToken + ? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken) + : refreshGrantFingerprintForToken(cred.refreshToken); + store[id] = { + credential: cred, + generation: generation + 1, + refreshGrantFingerprint, + replacedAt: current.replacedAt, + ...preservedValidationMetadata(current), + }; + + // Each alias carries its OWN committed generation: aliases need not share one, and the plan + // reconciliation below is generation-fenced, so an id alone would be reconciled at the wrong fence. + const propagatedAliases: { id: string; generation: number }[] = []; + // Nothing to propagate when the grant did not actually rotate: the aliases already hold it. + // An absent owner identity fails closed: two empty strings compare equal but prove nothing about + // which upstream account either record was meant to use, and a matching bearer snapshot only + // shows they copied the same token once. Leave those dormant records alone. + if ( + priorFingerprint !== undefined + && priorCredential.refreshToken !== cred.refreshToken + && !!priorCredential.chatgptAccountId + ) { + for (const [aliasId, alias] of Object.entries(store)) { + if (aliasId === id || alias.deletedAt != null || !alias.credential) continue; + if (recordGrantFingerprint(alias) !== priorFingerprint) continue; + if (alias.credential.accessToken !== priorCredential.accessToken) continue; + if (alias.credential.expiresAt !== priorCredential.expiresAt) continue; + if (!alias.credential.chatgptAccountId) continue; + if (alias.credential.chatgptAccountId !== priorCredential.chatgptAccountId) continue; + const aliasGeneration = alias.generation + 1; + store[aliasId] = { + // The alias keeps its OWN chatgptAccountId value, which the guard above proved equal. + credential: { ...cred, chatgptAccountId: alias.credential.chatgptAccountId }, + generation: aliasGeneration, + refreshGrantFingerprint, + replacedAt: alias.replacedAt, + ...preservedValidationMetadata(alias), + }; + propagatedAliases.push({ id: aliasId, generation: aliasGeneration }); + } + } + persist(store); + return { committed: true, propagatedAliases }; + }); +} + export function tombstoneCodexAccount(id: string): number { return withCredentialMutationLockSync(() => { const store = loadCodexAccountRecordStore(); @@ -288,6 +379,12 @@ function withCredentialMutationLockSync(fn: () => T): T { type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number }; type CodexRefreshResult = CodexTokenResult & { credential?: CodexAccountCredentials; + /** + * Records that adopted this refresh's rotated credential through same-grant propagation, each + * with its own committed generation (#2892 gap 3). Carried on the result so the flight settles + * every plan in one place rather than the commit doing its own (#2933). + */ + propagatedAliases?: { id: string; generation: number }[]; /** * Grant the returned credential actually belongs to. * @@ -394,12 +491,20 @@ function findFreshCredentialForGrant( refreshGrantFingerprint: string, excludeId: string, rejectedAccessToken?: string, + expectedChatgptAccountId?: string, ): CodexAccountCredentials | null { const now = Date.now(); const records = loadCodexAccountRecordStore(); + // Adoption copies another record's access AND refresh tokens onto the caller, so the two records + // must be the same upstream identity. A grant fingerprint is `sha256` of the refresh token and + // carries no identity claim, and nothing here guarantees one grant cannot span two accounts, so + // require both ids to be present and exactly equal rather than inferring identity from the grant. + if (!expectedChatgptAccountId) return null; for (const [candidateId, candidate] of Object.entries(records)) { if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue; if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue; + if (!candidate.credential.chatgptAccountId) continue; + if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue; // A sibling alias can hold a still-unexpired copy of the exact token upstream // just rejected. Reusing it would bump the generation and replay the identical // bearer — a second 401 dressed up as recovery. @@ -679,6 +784,7 @@ async function resolveCodexToken( refreshGrantFingerprint, id, forced?.rejectedAccessToken, + lockedCred.chatgptAccountId, ); if (sameGrantFreshCredential) { if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, sameGrantFreshCredential)) { @@ -745,14 +851,26 @@ async function resolveCodexToken( expiresAt: safeExpiresAt, chatgptAccountId: lockedCred.chatgptAccountId, }; - if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) { + // Commit to the owner and, in the same write, to any record that is still an untouched + // duplicate of the credential this flight started from (#2892 gap 3). Without this the rotated + // grant reaches only the owner and live joiners, and a dormant same-grant record is left + // holding a refresh token upstream has invalidated. + const commit = commitRefreshedCodexCredentialWithAliases(id, startGeneration, updated); + if (!commit.committed) { throw new CodexCredentialGenerationConflictError(); } + if (commit.propagatedAliases.length > 0) { + console.warn(`[codex-auth] rotated refresh grant propagated to ${commit.propagatedAliases.length} dormant same-grant account record(s)`); + } return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId, generation: startGeneration + 1, credential: updated, + // Aliases that adopted this rotated credential travel on the result so the FLIGHT settles + // their plans in the same single place as the owner's (#2933). Each carries its own committed + // generation because the plan note is generation-fenced. + ...(commit.propagatedAliases.length > 0 ? { propagatedAliases: commit.propagatedAliases } : {}), // The grant this flight was OPENED for, not the rotated one it produced. Joiners // are waiting on that key, and a successful refresh normally rotates the refresh // token — tagging the new grant would make every legitimate joiner look foreign. @@ -774,6 +892,12 @@ async function resolveCodexToken( */ const refreshPromise = fetchPromise.then(async (result): Promise => { await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation); + // One settlement path for the whole flight: the refreshing account, then any dormant alias that + // adopted the same rotated JWT. An alias holds the identical access token, so a changed + // `chatgpt_plan_type` applies to it too, and its cached-token fast path would never reconcile it. + for (const alias of result.propagatedAliases ?? []) { + await notePlanFromRefreshedAccessToken(alias.id, result.accessToken, alias.generation); + } return result; }).finally(() => { if (refreshLocks.get(refreshGrantFingerprint) === flight) refreshLocks.delete(refreshGrantFingerprint); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 24fce08cfa..250aac9636 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -5,7 +5,7 @@ import { codexAccountLogLabel } from "./account-label"; import { isCodexAccountPaused } from "./account-pause"; import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; -import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { POOL_KEY_CODEX, normalizeAccountPoolStickyLimit, @@ -91,6 +91,15 @@ type CodexUpstreamHealth = { * flaky account without throwing CodexAccountCooldownError (hard-only). */ softAvoidUntil?: number; + /** + * Credential generation a 401/403 quarantine was derived from (#2892 gap 4). + * + * Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends + * "whatever health is current when the old credential is found dead", which deletes a later + * unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the + * entry that carries this field can be spent, and any later write simply replaces it. + */ + credentialFailureGeneration?: number; }; const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000; @@ -127,6 +136,22 @@ const upstreamHealth = new Map(); * from account-wide Retry-After/default throttles and transient health. */ const quotaScopedHealth = new Map>(); +/** + * Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4). + * + * A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after + * the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the + * window without closing it. The reader decides instead, and it may only spend an entry that + * actually carries credential provenance: a later transient or quota write replaces the entry and + * with it the tag, so this can never delete evidence that belongs to a different failure. + */ +function dropSpentCredentialFailure(accountId: string): void { + const health = upstreamHealth.get(accountId); + const generation = health?.credentialFailureGeneration; + if (health === undefined || generation === undefined) return; + if (isCodexAccountGenerationLive(accountId, generation)) return; + upstreamHealth.delete(accountId); +} let lastReconciledGeneration = 0; let liveHealthAccountIds = new Set(); @@ -311,6 +336,7 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number export function getCodexUpstreamHealth( accountId: string, ): CodexUpstreamHealth | null { + dropSpentCredentialFailure(accountId); return upstreamHealth.get(accountId) ?? null; } @@ -690,7 +716,13 @@ function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): Codex */ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial { if (!health) return {}; - const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health; + // `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive + // into a later transient or quota entry — otherwise that entry inherits the tag and gets spent + // when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review). + const { + consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, + softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields + } = health; return cooldownFields; } @@ -1587,6 +1619,7 @@ function applyQuotaAutoSwitch( function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean { const threshold = config.upstreamFailoverThreshold ?? 3; if (threshold <= 0) return false; + dropSpentCredentialFailure(accountId); const health = upstreamHealth.get(accountId); if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false; return !!health && health.consecutiveFailures >= threshold; @@ -2111,6 +2144,16 @@ export function recordCodexUpstreamOutcome( const now = meta.now ?? Date.now(); const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial); const quotaScope = codexQuotaScopeForModel(meta.modelId); + /* + * Spend a stale credential failure BEFORE any branch reads health (#2892 gap 4 review). + * + * Reader-side spending alone is not enough: the transient and workspace branches derive their new + * entry from the current one, so a spent G1 401 would donate its `consecutiveFailures` to G2's + * first genuine 503 and drop the tag while doing it. The account then reaches the failover + * threshold one failure early, and no later read can tell. Clearing it here means every branch + * starts from evidence that still describes a live credential. + */ + dropSpentCredentialFailure(accountId); if (outcomeClass === "success") { const scopedProbe = meta.probeQuotaScope ? scopedHealthFor(accountId, meta.probeQuotaScope) @@ -2213,13 +2256,39 @@ export function recordCodexUpstreamOutcome( ) { return; } + /* + * The pre-check above closes the same-process race, but not a cross-process one (#2892 gap 4). + * `isCodexAccountGenerationLive` is an unlocked read while credential writers coordinate under + * the mutation lock, and OS preemption needs no `await` — so another process can replace the + * credential after this check, or at any point after this whole function returns. No re-read here + * can close that: a replacement is always free to land one instruction later. + * + * Taking the credential lock is not an option either: it runs with `busy_timeout=0`, so acquiring + * it per outcome would turn ordinary contention into thrown request-path errors. + * + * So the evidence is TAGGED with the credential it describes and judged when it is READ. The + * health entry carries `credentialFailureGeneration` and the reauth map carries the same + * generation; `dropSpentCredentialFailure` and `isAccountNeedsReauth` discard an entry whose + * credential is gone. A later transient or quota write replaces the entry along with its tag, and + * `preservedCooldownFields` drops the tag explicitly, so this provenance can never be spent + * against a failure it did not describe. + * + * Affinity sweeping needs no tag: an affinity entry already carries a credential generation and + * self-invalidates on the next check, and re-adding swept entries would be a worse bug. + */ upstreamHealth.set(accountId, { consecutiveFailures: 1, lastFailureStatus, lastFailureAt: now, + // Provenance rides on the entry: only this failure can be spent when its credential dies. + ...(meta.credentialGeneration !== undefined + ? { credentialFailureGeneration: meta.credentialGeneration } + : {}), }); quotaScopedHealth.delete(accountId); - markAccountNeedsReauth(accountId, writerGeneration); + // The reauth flag carries the same provenance, so a replacement landing after this call cannot + // inherit a quarantine that was never about it. + markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration); clearThreadAccountMapForAccount(accountId); return; } diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 892b08462b..70e788882b 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -140,6 +140,9 @@ export async function resolveFirstUsableOpenAiSidecar( probeLeaseId: authContext.probeLeaseId, probeQuotaScope: authContext.probeQuotaScope, writerGeneration: authContext.writerGeneration, + // 401/403 here is evidence about this exact stored credential; without the generation a + // replacement inherits the quarantine (#2892 gap 4). + ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}), }, ), }; @@ -172,6 +175,8 @@ export async function resolveFirstUsableOpenAiSidecar( threadId: authContext.affinityKey, probeLeaseId: authContext.probeLeaseId, writerGeneration: authContext.writerGeneration, + // Same fence as the exact-account recorder above (#2892 gap 4). + ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}), }, ), } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 4557af6c03..b06237fac9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -399,6 +399,11 @@ export function sidecarOutcomeRecorder( probeLeaseId: authCtx.probeLeaseId, probeQuotaScope: authCtx.probeQuotaScope, writerGeneration: authCtx.writerGeneration, + // A vision or web-search sidecar can return 401/403, and that is evidence about the exact + // stored credential it used. Without the generation it becomes an account-wide quarantine + // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so + // it keeps the unfenced account-wide semantics. + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), }) : undefined; } diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 6dcebd4d1e..fe7274496d 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -1005,6 +1005,144 @@ describe("codex-account-store CRUD", () => { globalThis.fetch = originalFetch; } }); + + test("a successful refresh advances an untouched dormant same-grant alias in the same write (#2892 gap 3)", async () => { + const { getValidCodexToken, readCodexAccountRecord, saveCodexAccountCredential } = + await import("../src/codex/account-store"); + const expiresAt = 0; + const shared = { refreshToken: "dormant-grant", expiresAt, chatgptAccountId: "acc" }; + // Owner drives the refresh. `dormant` is an untouched duplicate that never calls in — the + // record the rotated grant used to skip, leaving it to send a dead grant on its next refresh. + saveCodexAccountCredential("dormant-owner", { accessToken: "shared-old", ...shared }); + saveCodexAccountCredential("dormant-alias", { accessToken: "shared-old", ...shared }); + // Negative cases: each must be left strictly alone. + saveCodexAccountCredential("alias-other-account", { + accessToken: "shared-old", + refreshToken: "dormant-grant", + expiresAt, + chatgptAccountId: "different-acc", + }); + saveCodexAccountCredential("alias-moved-on", { accessToken: "already-newer", ...shared }); + saveCodexAccountCredential("alias-other-grant", { + accessToken: "shared-old", + refreshToken: "unrelated-grant", + expiresAt, + chatgptAccountId: "acc", + }); + const aliasGeneration = readCodexAccountRecord("dormant-alias")!.generation; + const otherAccountGeneration = readCodexAccountRecord("alias-other-account")!.generation; + const movedOnGeneration = readCodexAccountRecord("alias-moved-on")!.generation; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + access_token: "rotated-access", + refresh_token: "rotated-grant", + expires_in: 3600, + })) as typeof fetch; + + try { + await getValidCodexToken("dormant-owner"); + + const owner = readCodexAccountRecord("dormant-owner")!; + expect(owner.credential).toMatchObject({ accessToken: "rotated-access", refreshToken: "rotated-grant" }); + + // The dormant alias adopts the rotated credential WHOLE — access token, refresh token, and + // expiry together — so its bumped generation still means "newer JWT", which is what the + // plan-from-token fence reads it as. + const alias = readCodexAccountRecord("dormant-alias")!; + expect(alias.credential?.refreshToken).toBe("rotated-grant"); + expect(alias.credential?.accessToken).toBe("rotated-access"); + expect(alias.credential?.expiresAt).toBe(owner.credential!.expiresAt); + expect(alias.credential?.chatgptAccountId).toBe("acc"); + expect(alias.generation).toBe(aliasGeneration + 1); + expect(alias.refreshGrantFingerprint).toBe(owner.refreshGrantFingerprint); + + // A same-grant record on a DIFFERENT chatgpt account is not provably the same identity: a + // fingerprint is sha256 of the refresh token and carries no identity claim. + const otherAccount = readCodexAccountRecord("alias-other-account")!; + expect(otherAccount.credential?.refreshToken).toBe("dormant-grant"); + expect(otherAccount.generation).toBe(otherAccountGeneration); + + // An alias whose access token already moved on must NOT be given a generation bump with a + // stale JWT, and must not be handed back a possibly-rejected bearer. + const movedOn = readCodexAccountRecord("alias-moved-on")!; + expect(movedOn.credential?.accessToken).toBe("already-newer"); + expect(movedOn.credential?.refreshToken).toBe("dormant-grant"); + expect(movedOn.generation).toBe(movedOnGeneration); + + expect(readCodexAccountRecord("alias-other-grant")!.credential?.refreshToken).toBe("unrelated-grant"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a tombstoned same-grant record is not resurrected by grant propagation (#2892 gap 3)", async () => { + const { getValidCodexToken, readCodexAccountRecord, saveCodexAccountCredential, tombstoneCodexAccount } = + await import("../src/codex/account-store"); + const shared = { accessToken: "tomb-old", refreshToken: "tomb-grant", expiresAt: 0, chatgptAccountId: "acc" }; + saveCodexAccountCredential("tomb-owner", { ...shared }); + saveCodexAccountCredential("tomb-deleted", { ...shared }); + tombstoneCodexAccount("tomb-deleted"); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + access_token: "tomb-new", + refresh_token: "tomb-rotated", + expires_in: 3600, + })) as typeof fetch; + try { + await getValidCodexToken("tomb-owner"); + const deleted = readCodexAccountRecord("tomb-deleted")!; + expect(deleted.deletedAt).toBeGreaterThan(0); + expect(deleted.credential).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + + test("a same-grant sibling on a DIFFERENT upstream identity is never adopted (#2892 review)", async () => { + const { forceRefreshCodexPoolToken, getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } = + await import("../src/codex/account-store"); + // Both records share one stored grant, but they claim different upstream accounts. Adoption + // copies BOTH tokens, so treating a shared fingerprint as proof of identity would hand this + // caller another account's credential. + saveCodexAccountCredential("foreign-caller", { + accessToken: "rejected-token", + refreshToken: "foreign-grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acct-one", + }); + saveCodexAccountCredential("foreign-sibling", { + accessToken: "sibling-fresh", + refreshToken: "foreign-grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acct-two", + }); + const generation = readCodexAccountRecord("foreign-caller")!.generation; + + const originalFetch = globalThis.fetch; + let tokenCalls = 0; + globalThis.fetch = (async () => { + tokenCalls += 1; + return Response.json({ access_token: "own-new", refresh_token: "own-rotated", expires_in: 3600 }); + }) as typeof fetch; + try { + const result = await forceRefreshCodexPoolToken("foreign-caller", { + rejectedGeneration: generation, + rejectedAccessToken: "rejected-token", + }); + // A real refresh must have run instead of adopting the foreign sibling. + expect(tokenCalls).toBe(1); + expect(result.accessToken).toBe("own-new"); + expect(getCodexAccountCredential("foreign-caller")?.accessToken).not.toBe("sibling-fresh"); + // The sibling is untouched: this path must not write to another identity's record. + expect(getCodexAccountCredential("foreign-sibling")?.accessToken).toBe("sibling-fresh"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); describe("shared refresh flight plan reconciliation (#2892 gap 2 follow-up)", () => { @@ -1091,4 +1229,37 @@ describe("shared refresh flight plan reconciliation (#2892 gap 2 follow-up)", () resetJwtPlanNotesForTests(); } }); + + test("records with EMPTY account ids are never treated as the same identity (#2892 review)", async () => { + const { getValidCodexToken, readCodexAccountRecord, saveCodexAccountCredential } = + await import("../src/codex/account-store"); + // Grant fingerprint, access token and expiry all match, and both ids are "". Two empty strings + // compare equal but prove nothing about which upstream account either record was meant to use, + // so propagation must fail closed rather than write a credential into an unidentified record. + const shared = { accessToken: "anon-old", refreshToken: "anon-grant", expiresAt: 0, chatgptAccountId: "" }; + saveCodexAccountCredential("anon-owner", { ...shared }); + saveCodexAccountCredential("anon-alias", { ...shared }); + const aliasGeneration = readCodexAccountRecord("anon-alias")!.generation; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + access_token: "anon-new", + refresh_token: "anon-rotated", + expires_in: 3600, + })) as typeof fetch; + try { + await getValidCodexToken("anon-owner"); + + // The owner still rotates normally. + expect(readCodexAccountRecord("anon-owner")!.credential?.refreshToken).toBe("anon-rotated"); + // The unidentified record is untouched, generation included. + const alias = readCodexAccountRecord("anon-alias")!; + expect(alias.credential?.accessToken).toBe("anon-old"); + expect(alias.credential?.refreshToken).toBe("anon-grant"); + expect(alias.generation).toBe(aliasGeneration); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); diff --git a/tests/codex-plan.test.ts b/tests/codex-plan.test.ts index 0c4b9c0936..788264e1da 100644 --- a/tests/codex-plan.test.ts +++ b/tests/codex-plan.test.ts @@ -198,3 +198,52 @@ describe("WHAM-wins plan provenance gate (release-audit fix)", () => { }); }); + +describe("rotated-JWT plan reconciliation across propagated aliases (#2892 gap 3)", () => { + test("a plan-changing rotated JWT reconciles the alias, not just the refresh owner", async () => { + const { getValidCodexToken, readCodexAccountRecord } = await import("../src/codex/account-store"); + const oldJwt = chatgptPlanJwt("plus"); + const shared = { refreshToken: "plan-grant", expiresAt: 0, chatgptAccountId: "acct" }; + saveCodexAccountCredential("plan-owner", { accessToken: oldJwt, ...shared }); + saveCodexAccountCredential("plan-alias", { accessToken: oldJwt, ...shared }); + // Advance the alias so its generation DIVERGES from the owner's. Without this both land on the + // same number and an assertion about the per-alias fence would pass even if the code used the + // owner's generation — a vacuous test. Re-saving the identical credential keeps the record an + // eligible untouched duplicate while bumping only its generation. + saveCodexAccountCredential("plan-alias", { accessToken: oldJwt, ...shared }); + saveCodexAccountCredential("plan-alias", { accessToken: oldJwt, ...shared }); + saveConfig({ + ...loadConfig(), + codexAccounts: [ + { id: "plan-owner", email: "owner@test", plan: "plus" }, + { id: "plan-alias", email: "alias@test", plan: "plus" }, + ], + } as OcxConfig); + + const rotatedJwt = chatgptPlanJwt("pro"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + access_token: rotatedJwt, + refresh_token: "plan-grant-rotated", + expires_in: 3600, + })) as typeof fetch; + try { + await getValidCodexToken("plan-owner"); + + // The alias holds the rotated Pro JWT after propagation... + const alias = readCodexAccountRecord("plan-alias")!; + expect(alias.credential?.accessToken).toBe(rotatedJwt); + + // ...so its configured plan must be reconciled too. Reconciling only the owner left the alias + // on "plus" while carrying a Pro credential, and its cached-token fast path never repairs + // that, so quota scoring stayed wrong until a restart or a WHAM refresh. + const accounts = loadConfig().codexAccounts ?? []; + expect(accounts.find(a => a.id === "plan-owner")?.plan).toBe("pro"); + expect(accounts.find(a => a.id === "plan-alias")?.plan).toBe("pro"); + // The alias is fenced at its OWN committed generation, not the owner's. + expect(accounts.find(a => a.id === "plan-alias")?.planCredentialGeneration).toBe(alias.generation); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index eb93bc14c2..1ec9f8cfda 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -34,7 +34,8 @@ import { tryAcquireCodexQuotaProbeLease, } from "../src/codex/routing"; import { clearPoolRotationState } from "../src/codex/pool-rotation"; -import { removeCodexAccountCredential, saveCodexAccountCredential } from "../src/codex/account-store"; +import { captureConfigGeneration } from "../src/lib/state-store-sweeper"; +import { readCodexAccountRecord, removeCodexAccountCredential, saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota, @@ -484,6 +485,138 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("credential-403-next", config)).toBe("b"); }); + test("a 401 does not quarantine a credential that replaced the rejected one AFTER the outcome (#2892 gap 4)", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + + // Record the 401 while the rejected credential is still the live one, so every side effect is + // legitimately applied. This is the ordering @Ingwannu reproduced: the replacement lands AFTER + // recordCodexUpstreamOutcome returns, which no post-write re-read inside it can ever observe. + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: generation }); + expect(isAccountNeedsReauth("a")).toBe(true); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1, lastFailureStatus: 401 }); + + // Another process replaces the credential. The 401 was evidence about a credential that no + // longer exists, so it must not hold the replacement out of rotation. + saveTestCredential("a"); + expect(readCodexAccountRecord("a")!.generation).toBe(generation + 1); + + expect(isAccountNeedsReauth("a")).toBe(false); + expect(getCodexUpstreamHealth("a")).toBeNull(); + expect(resolveCodexAccountForThread("gap4-replacement-selectable", config)).toBe("a"); + }); + + test("a 401 on the live credential still quarantines the account (#2892 gap 4 does not over-roll-back)", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + + // No concurrent replacement: the evidence is about the credential still in the store, so every + // side effect must survive. This is the assertion that stops the rollback from being a blanket + // "never quarantine" regression. + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: generation }); + + expect(isAccountNeedsReauth("a")).toBe(true); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1, lastFailureStatus: 401 }); + }); + + + test("a later transient failure is not deleted by a spent credential-failure tag (#2892 gap 4 review)", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + + // G1 401, then the credential is replaced, then a GENUINE 503 against G2 — all before any + // health read. Provenance keyed only by account id would spend "whatever health is current" + // and delete this 503; provenance on the entry cannot, because the 503 write replaced the tag. + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: generation }); + saveTestCredential("a"); + recordCodexUpstreamOutcome(config, "a", 503); + + expect(getCodexUpstreamHealth("a")).toMatchObject({ lastFailureStatus: 503 }); + expect(isAccountNeedsReauth("a")).toBe(false); + }); + + test("a workspace denial overwriting a spent credential failure survives the read (#2892 gap 4 review)", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: generation }); + saveTestCredential("a"); + // A workspace denial is a different ownership class and must not be collateral damage. + recordCodexUpstreamOutcome(config, "a", 403, { denial: "workspace" }); + + expect(getCodexUpstreamHealth("a")).toMatchObject({ lastFailureStatus: 403 }); + }); + + + test("a sidecar 401 does not quarantine the credential that replaced it (#2892 gap 4 review)", async () => { + const { sidecarOutcomeRecorder } = await import("../src/server/responses/core"); + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + + // A vision or web-search sidecar returns 401 for a stored Pool credential. Recording that + // without the credential generation produced an account-wide quarantine, so the replacement + // inherited it and the account stayed unroutable. + const record = sidecarOutcomeRecorder(config, { + kind: "pool", + accountId: "a", + // Use the CURRENT captured generation, as a production pool auth context does. A hardcoded 0 + // is below whatever reconciliation state earlier tests advanced to, so + // recordCodexUpstreamOutcome could reject the outcome at its writer-generation guard and the + // assertion would pass without ever reaching the credential-generation logic under test. + writerGeneration: captureConfigGeneration(), + generation, + accessToken: "access-a", + chatgptAccountId: "acct-a", + }); + expect(record).toBeDefined(); + record!(401); + // Guard the guard: if this is false the outcome never applied, so the assertions below would be + // vacuous rather than proving the replacement is not quarantined. + expect(isAccountNeedsReauth("a")).toBe(true); + + saveTestCredential("a"); + expect(isAccountNeedsReauth("a")).toBe(false); + expect(getCodexUpstreamHealth("a")).toBeNull(); + }); + + + test("a spent credential failure does not donate its failure count to a later transient (#2892 gap 4 review)", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + saveTestCredential("a"); + const generation = readCodexAccountRecord("a")!.generation; + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: generation }); + saveTestCredential("a"); + // G2's first genuine transient must start the count at 1. Inheriting the spent 401's count + // pushes the account over the failover threshold a failure early, and because the transient + // write drops the provenance tag, no later read can detect that it happened. + recordCodexUpstreamOutcome(config, "a", 503); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1, lastFailureStatus: 503 }); + // The same inheritance path exists for a workspace denial. + clearCodexUpstreamHealthForAccount("a"); + recordCodexUpstreamOutcome(config, "a", 401, { credentialGeneration: readCodexAccountRecord("a")!.generation }); + saveTestCredential("a"); + recordCodexUpstreamOutcome(config, "a", 403, { denial: "workspace" }); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1, lastFailureStatus: 403 }); + }); + + test("connect failures contribute to transient failover", () => { const config = makeConfig(); updateAccountQuota("a", 10); From f3393aa7116d47b62255374e315a93719633b129 Mon Sep 17 00:00:00 2001 From: potota90 <85318310+adtumk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:49:37 +0200 Subject: [PATCH 096/132] feat(providers): native Ollama /api/chat transport with /api/show metadata for ollama-cloud (#2863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): native Ollama /api/chat transport for ollama-cloud * feat(providers): bounded Ollama Cloud /api/show metadata enrichment * docs(providers): describe Ollama Cloud's native transport and drop the discarded adapter line The ollama-cloud registry row now uses the ollama-native transport, so the guide's "OpenAI-compatible at https://ollama.com/v1" description no longer matches what runs, and the configuration and sidecar examples still carry "adapter": "openai-chat" for that provider — a line routedProviderConfig() discards for every transport-matched registry row. The configured base URL is unchanged and still documented as https://ollama.com/v1. The local-Ollama section is untouched: that is a user-defined openai-chat provider, not this registry row. All eight locales updated together so translated content does not contradict the English source. * fix(providers): refuse structured output on Ollama Cloud instead of silently dropping it Ollama documents that "Ollama's Cloud currently does not support structured outputs" (docs/capabilities/structured-outputs.mdx). Cloud does not reject the `format` field: it answers 200 and ignores it. Confirmed directly against https://ollama.com/api/chat with glm-5.3-flash — both format:"json" and a minimal JSON schema returned 200 with unconstrained prose and no error. Forwarding `format` there hands the caller free text while their request said the answer would be schema-valid. Refuse the contract instead, before any upstream request is made, the same call Kiro makes for a wire that cannot enforce it. Local and custom self-hosted Ollama honour `format` and keep mapping it: json_object -> "json", json_schema -> the schema object itself. Requests with no text.format are unchanged on every endpoint kind. * docs(providers): document ollama-native as a public adapter and the Cloud structured-output limit The adapter reference still described ollama-cloud under openai-chat, and the provider-configuration adapter enum omitted ollama-native, so a user could not discover the built-in Cloud transport or configure a custom native provider from the docs. Adds an ollama-native adapter reference section (native /api/chat target, registry selection, bounded /api/show metadata, NDJSON streaming, think mapping, native images and tools, and the two fail-closed boundaries), adds ollama-native to the provider adapter value list, and adds the Cloud structured-output limitation note to the Ollama Cloud guide section in all eight locales. Also makes the structured-output unit test honest: the pre-outbound-ordering case's spy fetch was never consumed by buildRequest, so its outbound-call assertion proved nothing. The case now asserts the property buildRequest actually establishes — the refusal happens instead of returning an AdapterRequest — and points to the staging evidence for no-outbound. No production change. * fix(providers): close native Ollama review findings * fix(ollama): harden cloud host classification and auth coverage --------- Co-authored-by: adtumk --- .../src/content/docs/fr/guides/providers.md | 15 +- .../src/content/docs/fr/guides/sidecars.md | 1 - .../src/content/docs/fr/reference/adapters.md | 46 +- .../fr/reference/configuration/providers.md | 3 +- .../src/content/docs/guides/providers.md | 12 +- docs-site/src/content/docs/guides/sidecars.md | 1 - .../src/content/docs/ja/guides/providers.md | 11 +- .../src/content/docs/ja/guides/sidecars.md | 1 - .../src/content/docs/ja/reference/adapters.md | 40 +- .../ja/reference/configuration/providers.md | 3 +- .../src/content/docs/ko/guides/providers.md | 11 +- .../src/content/docs/ko/guides/sidecars.md | 1 - .../src/content/docs/ko/reference/adapters.md | 36 +- .../ko/reference/configuration/providers.md | 3 +- .../src/content/docs/reference/adapters.md | 40 +- .../docs/reference/configuration/providers.md | 3 +- .../src/content/docs/ru/guides/providers.md | 14 +- .../src/content/docs/ru/guides/sidecars.md | 1 - .../src/content/docs/ru/reference/adapters.md | 43 +- .../ru/reference/configuration/providers.md | 3 +- .../src/content/docs/tr/guides/providers.md | 12 +- .../src/content/docs/tr/guides/sidecars.md | 1 - .../src/content/docs/tr/reference/adapters.md | 43 +- .../tr/reference/configuration/providers.md | 3 +- .../content/docs/zh-cn/guides/providers.md | 6 +- .../src/content/docs/zh-cn/guides/sidecars.md | 1 - .../content/docs/zh-cn/reference/adapters.md | 34 +- .../reference/configuration/providers.md | 3 +- .../content/docs/zh-tw/guides/providers.md | 10 +- .../src/content/docs/zh-tw/guides/sidecars.md | 1 - .../content/docs/zh-tw/reference/adapters.md | 34 +- .../reference/configuration/providers.md | 3 +- src/adapters/ollama-native-url.ts | 111 ++ src/adapters/ollama-native.ts | 1131 +++++++++++++++++ src/adapters/registry.ts | 7 + src/codex/catalog/provider-fetch.ts | 41 +- src/lib/redact.ts | 7 +- src/providers/ollama-show.ts | 311 +++++ src/providers/registry.ts | 25 +- .../adapter-buffered-tool-conformance.test.ts | 18 + tests/adapter-registry-authority.test.ts | 7 +- tests/adapter-tool-conformance.test.ts | 13 +- .../adapter-conformance/wire-drivers.ts | 39 + tests/ollama-native-parser.test.ts | 529 ++++++++ tests/ollama-native-reasoning-wire.test.ts | 124 ++ tests/ollama-native-structured-output.test.ts | 126 ++ tests/ollama-native-v4.test.ts | 126 ++ tests/ollama-native.test.ts | 264 ++++ tests/ollama-show-enrichment-v7.test.ts | 502 ++++++++ tests/ollama-show-enrichment.test.ts | 358 ++++++ tests/ollama-show-ignore-abort.test.ts | 115 ++ 51 files changed, 4237 insertions(+), 56 deletions(-) create mode 100644 src/adapters/ollama-native-url.ts create mode 100644 src/adapters/ollama-native.ts create mode 100644 src/providers/ollama-show.ts create mode 100644 tests/ollama-native-parser.test.ts create mode 100644 tests/ollama-native-reasoning-wire.test.ts create mode 100644 tests/ollama-native-structured-output.test.ts create mode 100644 tests/ollama-native-v4.test.ts create mode 100644 tests/ollama-native.test.ts create mode 100644 tests/ollama-show-enrichment-v7.test.ts create mode 100644 tests/ollama-show-enrichment.test.ts create mode 100644 tests/ollama-show-ignore-abort.test.ts diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 40ee8021f1..5de4260813 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -574,15 +574,24 @@ connexion par clé. ### Ollama Cloud -Ollama Cloud est une version hébergée — et non locale — d'Ollama, compatible avec OpenAI à l'adresse -`https://ollama.com/v1` et accessible avec une clé créée sur -[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex classe les modèles cloud selon leurs +Ollama Cloud est une version hébergée — et non locale — d'Ollama, à configurer à l'adresse +`https://ollama.com/v1` avec une clé créée sur +[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex l'atteint via l'API REST +native d'Ollama (`POST /api/chat`) plutôt que via la surface compatible OpenAI, et découvre la +liste des modèles auprès du fournisseur : les nouveaux modèles Ollama Cloud apparaissent sans +modifier la configuration. opencodex classe les modèles cloud selon leurs capacités visuelles, afin que le [service auxiliaire de vision](/fr/guides/sidecars/) n'intervienne que pour les modèles exclusivement textuels. Ces derniers, par exemple `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x` et `nemotron-3-*`, figurent dans `noVisionModels` ; les modèles à vision native, comme `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5` et `gemini-3-flash-preview`, n'y figurent pas. La correspondance tolère les balises `:size` d'Ollama : `gpt-oss` couvre donc `gpt-oss:120b` et `gpt-oss:20b`. +Ollama documente actuellement la sortie structurée comme non prise en charge sur Ollama Cloud. +Pour `ollama-cloud` canonique, opencodex refuse donc les requêtes à sortie structurée +(`text.format`) avec une erreur explicite plutôt que de renvoyer silencieusement une prose libre ; +les points de terminaison locaux et personnalisés `ollama-native` conservent le comportement +natif `format` d'Ollama. + ## 4. Fournisseurs locaux Faites pointer opencodex vers un serveur local compatible OpenAI, généralement avec une clé vide : diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index c5b7a50b1e..763b02ae73 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -140,7 +140,6 @@ Un modèle est marqué en texte uniquement par fournisseur : { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index ae107baeac..b98394708f 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -22,7 +22,7 @@ interface ProviderAdapter { ## `openai-chat` -**Cibles :** l’API **Chat Completions** d’OpenAI (`POST {baseUrl}/chat/completions` ; un suffixe `/chat/completions` ou `/` est d’abord retiré de `baseUrl`) et tous les fournisseurs compatibles — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local et cloud), entre autres. +**Cibles :** l’API **Chat Completions** d’OpenAI (`POST {baseUrl}/chat/completions` ; un suffixe `/chat/completions` ou `/` est d’abord retiré de `baseUrl`) et tous les fournisseurs compatibles — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), entre autres. **Authentification :** `key` (Bearer). - Convertit les messages internes en rôles OpenAI ; mappe les outils vers `{type:"function", function:{…}}` et `tool_choice` (`auto`/`none`/`required` ou une fonction nommée). @@ -32,6 +32,50 @@ interface ProviderAdapter { - Diffuse `delta.content` (texte), `delta.reasoning_content` (raisonnement) et `delta.tool_calls[]`, et recueille `usage`. - ClinePass utilise le format de passerelle vérifié en conditions réelles `reasoning: { enabled: true, effort }` (ou `{ enabled: false }` lorsque le raisonnement est désactivé). Sa documentation publique d’API ne précise pas encore cette forme de requête. L’adaptateur préserve les niveaux `low`, `medium`, `high`, `xhigh` et `max` demandés, accepte les deltas de raisonnement provenant de `delta.reasoning_content` ou de `delta.reasoning`, demande les données d’utilisation en flux avec `stream_options.include_usage` et lit ces données dans les enveloppes de réponse hors flux. +## `ollama-native` + +**Cibles :** l’**API Chat** native d’Ollama (`POST /api/chat`) plutôt que sa surface compatible +OpenAI. Le fournisseur intégré `ollama-cloud` est sélectionné sur cet adaptateur par le registre ; +il peut aussi être configuré sur un fournisseur Ollama personnalisé ou auto-hébergé distinct avec +`adapter: "ollama-native"`. +**Authentification :** `key` (Bearer) pour les cibles cloud/personnalisées ; aucun identifiant +n’est envoyé aux cibles de boucle locale ou en `authMode: "local"`. + +- **La sélection par le registre est déterminante.** La ligne intégrée `ollama-cloud` conserve + l’URL de base `https://ollama.com/v1` pour la découverte en direct via `/v1/models`, tandis que + l’inférence est normalisée vers `POST https://ollama.com/api/chat`. Un champ `adapter` configuré + est écarté pour cette ligne de fournisseur. L’Ollama local intégré reste sur `openai-chat` ; + choisir `ollama-native` pour un point de terminaison local ou auto-hébergé est une décision + explicite de configuration de fournisseur, détectée par hôte afin qu’une destination non-Ollama + ne soit jamais réécrite silencieusement. +- **Métadonnées des modèles :** `/v1/models` ne porte aucune métadonnée par modèle ; pour Ollama + Cloud canonique, le fournisseur enrichit chaque identifiant découvert via un `POST /api/show` + *borné* (256 KiB par réponse, 8 s par requête, concurrence 4, 48 requêtes, échéance de 12 s pour + toute la phase) afin d’obtenir la véritable fenêtre de contexte et la capacité de vision. La + requête show est de même origine et ne suit jamais une redirection ; un échec dégrade ce seul + modèle sans jamais faire échouer la découverte. +- **Diffusion :** le NDJSON natif d’Ollama. Les deltas de texte et de `message.thinking` sont + transmis dès leur arrivée ; un tour ne se termine que sur un enregistrement terminal + `done: true`, et un `done: false` bufferisé ou un terminal manquant supprime entièrement le + texte partiel et les appels d’outils. +- **Raisonnement :** cartographie le champ natif `think` d’Ollama (`low`/`medium`/`high`/`max`, + plus les booléens), limité à l’échelle annoncée du modèle, et respecte la sémantique de la + sentinelle `__omit__` configurée en amont. +- **Images :** envoyées nativement dans le tableau `images` du message lorsque le modèle prend en + charge la vision ; la vidéo est refusée plutôt que mal envoyée, et les URL d’images distantes ne + sont pas récupérées. +- **Outils :** déclarés dans la forme native d’Ollama ; les appels d’outils diffusés sont des + enregistrements entiers avec des `arguments` objet, et le rejeu des résultats d’outils est + apparié strictement par identifiant d’appel et nom d’outil. `tool_choice: "none"` et `auto` + se comportent normalement ; **`required` ou un choix nommé exact échoue fermement**, car + `/api/chat` d’Ollama n’a aucun champ `tool_choice` pour l’imposer. +- **La sortie structurée est refusée sur Ollama Cloud canonique.** Ollama documente actuellement + la sortie structurée comme non prise en charge sur son Cloud, et Cloud n’applique pas le champ + `format` ; OpenCodex fait donc échouer cette requête plutôt que de renvoyer une prose libre en + réponse à une demande structurée par schéma. Les points de terminaison `ollama-native` locaux et + personnalisés conservent le mappage natif `format` d’Ollama (`json_object` → `"json"`, + `json_schema` → l’objet de schéma). + ## `openai-responses` **Cibles :** l’API **Responses** d’OpenAI. **`passthrough: true`** — transmet tel quel le corps brut de la requête et renvoie le flux de réponse **sans traduction**. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 822c7ba47c..47029ee45b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -64,7 +64,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | Champ | Type | Signification | | --- | --- | --- | -| `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (ou alias `azure`). | +| `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (ou alias `azure`). | | `baseUrl` | `string` | URL de base de l'API en amont. La plupart des points de terminaison fixes intégrés ignorent une valeur incompatible ; les préréglages de clés protégés contre les collisions préservent une ancienne destination personnalisée portant le même nom. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. Le nombre de requêtes par minute est converti en intervalle régulier ; `minIntervalMs` peut imposer un intervalle plus long. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont, par exemple `nvidia/llama-3.1-nemotron-ultra-253b-v1`, et ne peuvent qu’ajouter du délai. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP, Responses WebSocket et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. | | `responsesPath?` | `string` | Chemin de ressource relatif pour les requêtes d'authentification par clé `openai-responses`. Il doit commencer par `/` et ne contenir aucun schéma, requête ou fragment. | @@ -419,7 +419,6 @@ avec un contexte de `922000` et une entrée maximale de `922000` ; OpenRouter i "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b57473114b..f8d13cbe87 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -602,14 +602,22 @@ Cursor is still not shown in key-login lists. ### Ollama Cloud -Ollama Cloud is a hosted (not local) Ollama, OpenAI-compatible at `https://ollama.com/v1` with a key -from [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex classifies its cloud +Ollama Cloud is a hosted (not local) Ollama. Configure it at `https://ollama.com/v1` with a key +from [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex reaches it over +Ollama's own REST API (`POST /api/chat`) rather than the OpenAI-compatible surface, and discovers +the live model roster from the provider, so new Ollama Cloud models appear without a config +change. opencodex classifies its cloud lineup by vision capability so the [vision sidecar](/guides/sidecars/) only kicks in for text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) are listed in `noVisionModels`; vision-native models (e.g. `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) are not. Matching is tolerant of Ollama's `:size` tags, so `gpt-oss` covers `gpt-oss:120b` and `gpt-oss:20b`. +Ollama currently documents structured outputs as unsupported on Ollama Cloud. For canonical +`ollama-cloud`, opencodex therefore refuses structured-output requests (`text.format`) with a clear +error instead of silently returning unconstrained prose; local and custom `ollama-native` +endpoints keep Ollama's native `format` behavior. + ## 4. Local providers Point opencodex at a local OpenAI-compatible server — usually with a blank key: diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d92316d796..bb4c4346df 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -178,7 +178,6 @@ A model is marked text-only per provider: { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f99bf35aec..51d018f478 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -412,14 +412,21 @@ MCP、画面録画、computer-use はエグゼキューターフックで開か ### Ollama Cloud -Ollama Cloud はホステッド型(ローカルではない)Ollama で、`https://ollama.com/v1` で OpenAI 互換、キーは -[ollama.com/settings/keys](https://ollama.com/settings/keys) で発行されます。opencodex はクラウド +Ollama Cloud はホステッド型(ローカルではない)Ollama です。`https://ollama.com/v1` を設定し、キーは +[ollama.com/settings/keys](https://ollama.com/settings/keys) で発行します。opencodex は OpenAI 互換 +サーフェスではなく Ollama 自身の REST API(`POST /api/chat`)で接続し、モデル一覧はプロバイダーから +動的に取得するため、新しい Ollama Cloud モデルは設定変更なしで現れます。opencodex はクラウド ラインナップをビジョン機能で分類し、[ビジョンサイドカー](/ja/guides/sidecars/)がテキスト専用モデルにのみ 動作するようにします。テキスト専用モデル(例: `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、 `minimax-m2.x`、`nemotron-3-*`)は `noVisionModels` に列挙され、ビジョンネイティブモデル(例: `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)は含まれません。マッチングは Ollama の `:size` タグに寛容なので `gpt-oss` は `gpt-oss:120b` と `gpt-oss:20b` の両方を含みます。 +Ollama は現在、構造化出力は Ollama Cloud では未対応であるとドキュメントしています。正規の +`ollama-cloud` に対する構造化出力リクエスト(`text.format`)は、自由文を黙って返す代わりに +opencodex が明示的なエラーで拒否します。ローカル / カスタムの `ollama-native` エンドポイントは +Ollama ネイティブの `format` 動作を保持します。 + ## 4. ローカルプロバイダー opencodex をローカルの OpenAI 互換サーバーに向けてください — 通常は空キーで使います: diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 7a44113a5d..48af198fe3 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -119,7 +119,6 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` を { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index f3f7b65452..5e9427de59 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -22,7 +22,7 @@ interface ProviderAdapter { ## `openai-chat` **対象:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)および互換プロバイダー -— xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(ローカルとクラウド)など。 +— xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(ローカル)など。 **認証:** `key`(Bearer)。 - 内部メッセージを OpenAI role に変換し、ツールは `{type:"function", function:{…}}` と @@ -41,6 +41,44 @@ interface ProviderAdapter { `xhigh`、`max` tier をそのまま保持し、`delta.reasoning_content` または `delta.reasoning` を reasoning delta として扱い、`stream_options.include_usage` でストリーム usage を要求し、非ストリームのレスポンス envelope からも usage を読み取ります。 +## `ollama-native` + +**対象:** OpenAI 互換サーフェスではなく、Ollama 自身の **Chat API**(`POST /api/chat`)。 +組み込みの `ollama-cloud` プロバイダーはこの adapter にレジストリで選択され、別名のカスタム / +セルフホスト Ollama プロバイダーに `adapter: "ollama-native"` を設定して使うこともできます。 +**認証:** cloud / カスタム宛先は `key`(Bearer)。loopback または `authMode: "local"` +の宛先には資格情報を送りません。 + +- **レジストリ選択が実質的に効きます。** 組み込みの `ollama-cloud` 行は `/v1/models` による + ライブ探索のため `https://ollama.com/v1` を維持しつつ、推論は + `POST https://ollama.com/api/chat` に正規化されます。このプロバイダー行では設定した + `adapter` は破棄されます。通常の組み込みローカル Ollama は `openai-chat` のままです。 + ローカル / セルフホスト宛先に `ollama-native` を選ぶのは、プロバイダー設定での明示的な判断 + であり、ホストで判定されるため非 Ollama 宛先が黙って書き換えられることはありません。 +- **モデルメタデータ:** `/v1/models` にはモデルごとのメタデータがないため、正規の Ollama + Cloud では *上限付き* の `POST /api/show`(応答 256 KiB、1 要求 8 秒、並列 4、48 要求、 + フェーズ全体に 12 秒の締切)で発見された各 id を補完し、実際の context window と vision + 対応を取得します。show 要求は同一オリジンでリダイレクトを追わず、失敗してもその 1 モデル + だけが劣化し、発見自体は失敗しません。 +- **ストリーミング:** Ollama ネイティブの NDJSON。テキストと `message.thinking` の delta を + 到着順に転送し、`done: true` の終端レコードでのみターンを完了します。buffer された + `done: false` や終端の欠落では部分的なテキストも tool call も一切出力しません。 +- **Reasoning:** Ollama ネイティブの `think` フィールド(`low` / `medium` / `high` / `max` と + boolean)に対応し、モデルの公開 ladder へクランプし、上流で設定された `__omit__` sentinel の + 意味論に従います。 +- **画像:** vision 対応モデルではメッセージの `images` 配列でネイティブ送信します。video は + 誤送信ではなく拒否され、リモート画像 URL の取得は行いません。 +- **ツール:** Ollama のネイティブ形状で宣言し、ストリームされる tool call は `arguments` が + オブジェクトの whole-call レコード、tool result のリプレイは call id と tool 名で厳密に対応 + 付けられます。`tool_choice: "none"` と `auto` は通常どおりです。**`required` や名前指定は + fail closed** です。Ollama の `/api/chat` にはそれを強制できる `tool_choice` フィールドが + ありません。 +- **構造化出力は正規の Ollama Cloud では拒否されます。** Ollama は Cloud で構造化出力が未対応 + であると現在ドキュメントしており、Cloud は `format` フィールドを強制しません。そのため + OpenCodex は、schema 指定の要求に対して自由文を返すのではなく、要求を閉じて失敗させます。 + ローカル / カスタムの `ollama-native` エンドポイントは Ollama ネイティブの `format` マッピング + (`json_object` → `"json"`、`json_schema` → schema オブジェクトそのもの)を保持します。 + ## `openai-responses` **対象:** OpenAI **Responses API**。**`passthrough: true`** — 通常は元のリクエストとレスポンスをそのまま渡し、ルーティング先ゲートウェイに必要な限定的な互換変換だけを適用します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index f1210892a5..b51e8a2e32 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -54,7 +54,7 @@ account を削除しても mapping は保持され、同じ id を再追加す |フィールド |タイプ |意味 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai` (または別名 `azure`) のいずれか。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai` (または別名 `azure`) のいずれか。 | | `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP、Responses WebSocket、明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | @@ -337,7 +337,6 @@ OpenRouter は、複数の推論プロバイダーを通じて 1 つのモデル "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index b24e17f3fc..3c28be0a59 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -403,14 +403,21 @@ model discovery는 이 실험적 어댑터에서 활성화되어 있으며, Curs ### Ollama Cloud -Ollama Cloud는 호스팅형(로컬이 아님) Ollama로, `https://ollama.com/v1`에서 OpenAI 호환이며 키는 -[ollama.com/settings/keys](https://ollama.com/settings/keys)에서 발급받습니다. opencodex는 클라우드 +Ollama Cloud는 호스팅형(로컬이 아님) Ollama입니다. `https://ollama.com/v1`으로 설정하고 키는 +[ollama.com/settings/keys](https://ollama.com/settings/keys)에서 발급받습니다. opencodex는 OpenAI 호환 +표면이 아니라 Ollama 자체 REST API(`POST /api/chat`)로 연결하며, 모델 목록을 공급자에서 직접 +발견하므로 새 Ollama Cloud 모델이 설정 변경 없이 나타납니다. opencodex는 클라우드 라인업을 비전 기능에 따라 분류하여 [비전 사이드카](/ko/guides/sidecars/)가 텍스트 전용 모델에만 작동하도록 합니다. 텍스트 전용 모델(예: `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`)은 `noVisionModels`에 나열되며, 비전 네이티브 모델(예: `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`)은 포함되지 않습니다. 매칭은 Ollama의 `:size` 태그에 관대하므로 `gpt-oss`는 `gpt-oss:120b`와 `gpt-oss:20b`를 모두 포괄합니다. +Ollama는 현재 구조화 출력이 Ollama Cloud에서 지원되지 않는다고 문서화하고 있습니다. 정식 +`ollama-cloud`에 대한 구조화 출력 요청(`text.format`)은 opencodex가 자유 서술을 조용히 돌려주는 +대신 명확한 오류로 거부합니다. 로컬 / 커스텀 `ollama-native` 엔드포인트는 Ollama의 네이티브 +`format` 동작을 유지합니다. + ## 4. 로컬 프로바이더 opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 빈 키와 함께 사용합니다: diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index e75e0521e5..2aab84f438 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -121,7 +121,6 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 657089f218..dd81eddb41 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -26,7 +26,7 @@ interface ProviderAdapter { ## `openai-chat` **대상:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)와 모든 호환 프로바이더 -— xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama(로컬 및 클라우드) 등. +— xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama(로컬) 등. **인증:** `key`(Bearer). - 내부 메시지를 OpenAI role로 변환하고, 툴은 `{type:"function", function:{…}}`과 @@ -47,6 +47,40 @@ interface ProviderAdapter { 유지하고, `delta.reasoning_content` 또는 `delta.reasoning`을 reasoning delta로 처리하며, `stream_options.include_usage`로 스트림 usage를 요청하고 비스트림 응답 envelope에서도 usage를 읽습니다. +## `ollama-native` + +**대상:** OpenAI 호환 표면이 아니라 Ollama 자체의 **Chat API**(`POST /api/chat`). 내장 +`ollama-cloud` 공급자는 이 어댑터로 레지스트리에서 선택되며, 별도 이름의 커스텀/셀프호스팅 +Ollama 공급자에 `adapter: "ollama-native"`로 설정할 수도 있습니다. +**인증:** cloud/커스텀 대상은 `key`(Bearer). loopback 또는 `authMode: "local"` 대상에는 +자격 증명을 보내지 않습니다. + +- **레지스트리 선택이 실질적입니다.** 내장 `ollama-cloud` 행은 `/v1/models` 라이브 발견을 위해 + `https://ollama.com/v1` 기준 URL을 유지하면서 추론은 `POST https://ollama.com/api/chat`으로 + 정규화됩니다. 이 공급자 행에서는 설정한 `adapter`가 버려집니다. 일반 내장 로컬 Ollama는 + `openai-chat`을 유지하며, 로컬/셀프호스팅 대상에 `ollama-native`를 선택하는 것은 명시적인 + 공급자 구성 결정이고 호스트로 판별되므로 비(非)Ollama 대상이 조용히 재작성되지 않습니다. +- **모델 메타데이터:** `/v1/models`에는 모델별 메타데이터가 없으므로, 정식 Ollama Cloud에서는 + *제한된* `POST /api/show`(응답 256 KiB, 요청당 8초, 동시성 4, 48요청, 전체 단계 12초 마감)로 + 발견된 각 id를 보완해 실제 context window와 vision 지원을 채웁니다. show 요청은 동일 + 오리진이며 리다이렉트를 따르지 않고, 실패해도 해당 모델만 저하되고 발견 자체는 실패하지 않습니다. +- **스트리밍:** Ollama 네이티브 NDJSON. 텍스트와 `message.thinking` delta를 도착 즉시 전달하고, + `done: true` 터미널 레코드에서만 턴을 완료합니다. 버퍼된 `done: false`나 누락된 터미널은 부분 + 텍스트와 tool call을 전부 억제합니다. +- **Reasoning:** Ollama 네이티브 `think` 필드(`low`/`medium`/`high`/`max` 및 불리언)로 매핑되고 + 모델의 공개 ladder로 클램프되며, 업스트림에서 구성한 `__omit__` sentinel 의미를 따릅니다. +- **이미지:** vision 지원 모델이면 메시지의 `images` 배열로 네이티브 전송됩니다. video는 잘못 + 보내지 않고 거부하며, 원격 이미지 URL은 가져오지 않습니다. +- **도구:** Ollama 네이티브 형태로 선언되고, 스트림 tool call은 `arguments`가 객체인 whole-call + 레코드이며 tool result 리플레이는 call id와 tool 이름으로 엄격히 짝지어집니다. + `tool_choice: "none"`과 `auto`는 정상 동작합니다. **`required`나 정확한 이름 지정은 fail + closed**입니다. Ollama의 `/api/chat`에는 이를 강제할 `tool_choice` 필드가 없기 때문입니다. +- **구조화 출력은 정식 Ollama Cloud에서 거부됩니다.** Ollama는 현재 Cloud에서 구조화 출력을 + 지원하지 않는다고 문서화하고 있으며 Cloud는 `format` 필드를 강제하지 않습니다. 따라서 + OpenCodex는 스키마가 지정된 요청에 자유 서술을 돌려주는 대신 요청을 닫고 실패시킵니다. 로컬 / + 커스텀 `ollama-native` 엔드포인트는 Ollama 네이티브 `format` 매핑(`json_object` → `"json"`, + `json_schema` → schema 객체 자체)을 유지합니다. + ## `openai-responses` **대상:** OpenAI **Responses API**. **`passthrough: true`** — 일반적으로 원본 요청과 응답을 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index ccacb0a94f..b9eb746726 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -54,7 +54,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | 필드 | 타입 | 의미 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | +| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | | `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP, Responses WebSocket, 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | @@ -338,7 +338,6 @@ OpenRouter는 하나의 모델을 여러 추론 공급자로 제공할 수 있 "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8919cd428b..dd566be5be 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -26,7 +26,7 @@ then turns the events into Responses SSE. ## `openai-chat` **Targets:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; a trailing `/chat/completions` or `/` on `baseUrl` is stripped first) and every compatible -provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), and more. +provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and more. **Auth:** `key` (Bearer). - Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and @@ -47,6 +47,44 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +## `ollama-native` + +**Targets:** Ollama's own **Chat API** (`POST /api/chat`) rather than its OpenAI-compatible +surface. The built-in `ollama-cloud` provider is registry-selected onto this adapter; it can also +be configured on a separately named custom or self-hosted Ollama provider with +`adapter: "ollama-native"`. +**Auth:** `key` (Bearer) for cloud/custom endpoints; no credential is sent to loopback or +`authMode: "local"` targets. + +- **Registry selection is load-bearing.** The built-in `ollama-cloud` row keeps the base URL + `https://ollama.com/v1` for `/v1/models` live discovery, while inference is normalized onto + `POST https://ollama.com/api/chat`. A config-level `adapter` is discarded for that provider row. + Ordinary built-in local Ollama stays on `openai-chat`; choosing `ollama-native` for a local or + self-hosted endpoint is an explicit provider-configuration decision, detected by host so a + non-Ollama destination is never silently rewritten. +- **Model metadata:** `/v1/models` carries no per-model metadata, so for canonical Ollama Cloud the + adapter's provider enriches each discovered id through a *bounded* `POST /api/show` (256 KiB per + response, 8 s per request, concurrency 4, 48 requests, a 12 s deadline for the whole phase) to fill + the true context window and vision capability. The show request is same-origin and never follows a + redirect; failures degrade that one model and never fail discovery. +- **Streaming:** Ollama's native NDJSON. Text and `message.thinking` deltas are forwarded as they + arrive; a turn completes only on a `done: true` terminal record, and buffered `done: false` or a + missing terminal suppresses partial text and tool calls entirely. +- **Reasoning:** maps onto Ollama's native `think` field (`low`/`medium`/`high`/`max`, plus + booleans), clamped to the model's advertised ladder, and honours the `__omit__` sentinel semantics + upstream configures. +- **Images:** sent natively in the message `images` array where the model is vision-capable; video + is refused rather than mis-sent, and remote image URLs are not fetched. +- **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with + object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name. + `tool_choice: "none"` and `auto` behave normally; **`required` or an exact named choice fails + closed**, because Ollama's `/api/chat` has no `tool_choice` field to enforce it with. +- **Structured output is refused on canonical Ollama Cloud.** Ollama currently documents structured + outputs as unsupported on its Cloud, and Cloud does not enforce the `format` field, so OpenCodex + fails that request closed rather than returning unconstrained prose in answer to a schema-shaped + request. Local and custom `ollama-native` endpoints keep Ollama's native `format` mapping + (`json_object` → `"json"`, `json_schema` → the schema object). + ## `openai-responses` **Targets:** the OpenAI **Responses API**. **`passthrough: true`** — normally forwards the raw request diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f70ede7ea3..8840acf900 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -64,7 +64,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | @@ -604,7 +604,6 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1dfe171a58..b99eb88ec2 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -447,9 +447,12 @@ MCP, запись экрана и computer-use доступны как хуки ### Ollama Cloud -Ollama Cloud — это размещённая в облаке (не локальная) Ollama, OpenAI-совместимая по адресу -`https://ollama.com/v1`, с ключом со страницы -[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex классифицирует её облачную +Ollama Cloud — это размещённая в облаке (не локальная) Ollama. Укажите адрес +`https://ollama.com/v1` и ключ со страницы +[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex обращается к ней через +собственный REST API Ollama (`POST /api/chat`), а не через OpenAI-совместимую поверхность, и +получает список моделей от провайдера, поэтому новые модели Ollama Cloud появляются без +изменения конфигурации. opencodex классифицирует её облачную линейку по поддержке изображений, чтобы [vision-сайдкар](/ru/guides/sidecars/) включался только для текстовых моделей. Текстовые модели (например, `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) перечислены в `noVisionModels`; модели с нативной @@ -457,6 +460,11 @@ Ollama Cloud — это размещённая в облаке (не локал `gemini-3-flash-preview`) — нет. Сопоставление терпимо к тегам Ollama вида `:size`, поэтому `gpt-oss` покрывает и `gpt-oss:120b`, и `gpt-oss:20b`. +Ollama в документации указывает, что структурированный вывод сейчас не поддерживается на Ollama +Cloud. Поэтому для канонического `ollama-cloud` opencodex отклоняет такие запросы +(`text.format`) явной ошибкой, а не молча возвращает свободную прозу; локальные и пользовательские +`ollama-native` конечные точки сохраняют нативное поведение `format` Ollama. + ## 4. Локальные провайдеры Направьте opencodex на локальный OpenAI-совместимый сервер — обычно с пустым ключом: diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index fed573bd8e..bc2a691a67 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -133,7 +133,6 @@ SSE-событие `response.failed`. { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 84398da179..ecff865ffc 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -28,7 +28,7 @@ interface ProviderAdapter { ## `openai-chat` **Назначение:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`) и все совместимые -провайдеры — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (локально и в облаке) и другие. +провайдеры — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (локально) и другие. **Аутентификация:** `key` (Bearer). - Преобразует внутренние сообщения в роли OpenAI; инструменты отображаются в @@ -51,6 +51,47 @@ interface ProviderAdapter { `delta.reasoning_content` или `delta.reasoning`, запрашивает usage потока через `stream_options.include_usage` и читает usage из envelope нестримингового ответа. +## `ollama-native` + +**Цели:** собственный **Chat API** Ollama (`POST /api/chat`) вместо его OpenAI-совместимой +поверхности. Встроенный провайдер `ollama-cloud` выбирается на этот адаптер реестром; его также +можно настроить для отдельного пользовательского или self-hosted провайдера Ollama с +`adapter: "ollama-native"`. +**Аутентификация:** `key` (Bearer) для cloud/пользовательских адресов; учётные данные не +отправляются на loopback-цели и при `authMode: "local"`. + +- **Выбор через реестр имеет решающее значение.** Встроенная строка `ollama-cloud` сохраняет + базовый URL `https://ollama.com/v1` для живого обнаружения через `/v1/models`, тогда как вывод + нормализуется на `POST https://ollama.com/api/chat`. Настроенный уровень `adapter` для этой + строки провайдера отбрасывается. Обычный встроенный локальный Ollama остаётся на `openai-chat`; + выбор `ollama-native` для локального или self-hosted адреса — это явное решение конфигурации + провайдера, определяемое по хосту, так что не-Ollama назначение никогда не переписывается молча. +- **Метаданные моделей:** `/v1/models` не несёт метаданных по моделям, поэтому для канонического + Ollama Cloud провайдер дополняет каждый обнаружённый id через *ограниченный* `POST /api/show` + (256 KiB на ответ, 8 с на запрос, параллельность 4, 48 запросов, дедлайн 12 с на всю фазу), чтобы + получить реальное окно контекста и поддержку зрения. Запрос show — того же источника и никогда + не следует за перенаправлением; сбой деградирует только эту модель и не ломает обнаружение. +- **Стриминг:** нативный NDJSON Ollama. Дельты текста и `message.thinking` пересылаются по мере + поступления; ход завершается только по терминальной записи `done: true`, а буферизованный + `done: false` или отсутствующий терминал полностью подавляют частичный текст и вызовы + инструментов. +- **Reasoning:** отображается на нативное поле `think` Ollama (`low`/`medium`/`high`/`max`, плюс + булевы значения), зажимается до заявленной лестницы модели и соблюдает семантику sentinel + `__omit__`, настроенную выше по стеку. +- **Изображения:** отправляются нативно в массиве `images` сообщения, если модель поддерживает + зрение; видео отклоняется, а не отправляется неверно, удалённые URL изображений не загружаются. +- **Инструменты:** объявляются в нативной форме Ollama; стриминговые вызовы инструментов — + цельные записи с `arguments` в виде объекта, а повтор результатов инструментов строго + сопоставляется по id вызова и имени инструмента. `tool_choice: "none"` и `auto` работают + обычно; **`required` или точное именованное значение завершается ошибкой**, потому что + `/api/chat` Ollama не имеет поля `tool_choice`, которым его можно было бы навязать. +- **Структурированный вывод отклоняется на каноническом Ollama Cloud.** Ollama в документации + указывает, что структурированные выходные данные сейчас не поддерживаются в его Cloud, и Cloud не + соблюдает поле `format`, поэтому OpenCodex закрывает такой запрос с ошибкой, а не возвращает + свободную прозу в ответ на запрос с указанием схемы. Локальные и пользовательские + `ollama-native` конечные точки сохраняют нативное отображение `format` Ollama + (`json_object` → `"json"`, `json_schema` → сам объект схемы). + ## `openai-responses` **Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index c415517074..94334dae06 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -67,7 +67,7 @@ cross-route credential fallback не существует. Строки API GPT- | Поле | Тип | Значение | | --- | --- | --- | -| `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или alias `azure`). | +| `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (или alias `azure`). | | `baseUrl` | `string` | Базовый URL API upstream'а. Большинство built-in fixed-endpoint'ов игнорируют несовпадение; collision-safe key-preset'ы сохраняют старый custom destination с тем же именем. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональное клиентское выравнивание начала исходящих запросов, отдельное от учёта использования, биллинга и индикаторов rate limit апстрима. Лимит провайдера действует на все модели, а `models` сопоставляется с точными ID моделей апстрима и может только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. Поддерживаются HTTP, Responses WebSocket и явные вызовы адаптеров `fetchResponse`/`runTurn`. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | @@ -423,7 +423,6 @@ Pool/Direct рекламирует `922000`; синхронизированны "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 15e2ab3cf4..4559133d43 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -622,8 +622,11 @@ anahtar girişi listelerinde hala gösterilmez. ### Ollama Cloud Ollama Cloud, [ollama.com/settings/keys](https://ollama.com/settings/keys) -adresinden alınan bir anahtarla `https://ollama.com/v1` adresinde OpenAI uyumlu -barındırılan (yerel olmayan) bir Ollama'dır. opencodex, bulut serisini vizyon +adresinden alınan bir anahtarla `https://ollama.com/v1` adresinde yapılandırılan, +barındırılan (yerel olmayan) bir Ollama'dır. opencodex ona OpenAI uyumlu yüzey +yerine Ollama'nın kendi REST API'si (`POST /api/chat`) üzerinden erişir ve model +listesini sağlayıcıdan keşfeder; böylece yeni Ollama Cloud modelleri yapılandırma +değişikliği olmadan görünür. opencodex, bulut serisini vizyon yeteneğine göre sınıflandırır, böylece [vizyon sidecar'ı](/tr/guides/sidecars/) yalnızca salt metin modeller için devreye girer. Salt metin modeller (örneğin `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, @@ -633,6 +636,11 @@ yalnızca salt metin modeller için devreye girer. Salt metin modeller (örneği etiketlerine toleranslıdır, bu nedenle `gpt-oss`, `gpt-oss:120b` ve `gpt-oss:20b`'yi kapsar. +Ollama şu anda yapılandırılmış çıktıyı Ollama Cloud'da desteklemediğini belgeliyor. Kanonik +`ollama-cloud` için opencodex, yapılandırılmış çıktı isteklerini (`text.format`) serbest metni +sessizce döndürmek yerine net bir hatayla reddeder; yerel ve özel `ollama-native` uç noktaları +Ollama'nın yerel `format` davranışını korur. + ## 4. Yerel sağlayıcılar opencodex'i yerel bir OpenAI uyumlu sunucuya yönlendirin — genellikle boş bir diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 1057ab70a7..5a46fbabc3 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -171,7 +171,6 @@ Bir model, sağlayıcı başına salt metin olarak işaretlenir: { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 42db6d20d4..b872a455d2 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -30,8 +30,7 @@ olayları Responses SSE'ye dönüştürür. **Hedefler:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; `baseUrl` üzerindeki sondaki `/chat/completions` veya `/` önce kaldırılır) ve -her uyumlu sağlayıcı — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (yerel -ve bulut) ve daha fazlası. +her uyumlu sağlayıcı — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (yerel) ve daha fazlası. **Kimlik Doğrulama:** `key` (Bearer). - Dahili mesajları OpenAI rollerine dönüştürür; araçları `{type:"function", @@ -56,6 +55,46 @@ ve bulut) ve daha fazlası. yürütme farklarını kabul eder, `stream_options.include_usage` ile akışlı kullanım ister ve akışsız yanıt zarflarından kullanımı okur. +## `ollama-native` + +**Hedefler:** OpenAI uyumlu yüzey yerine Ollama'nın kendi **Chat API'si** (`POST /api/chat`). +Yerleşik `ollama-cloud` sağlayıcısı kayıt defteri tarafından bu adaptöre seçilir; ayrıca ayrı adlı +özel veya kendi kendine barındırılan bir Ollama sağlayıcısında `adapter: "ollama-native"` ile +yapılandırılabilir. +**Kimlik Doğrulama:** bulut/özel hedefler için `key` (Bearer). Loopback veya `authMode: "local"` +hedeflerine kimlik bilgisi gönderilmez. + +- **Kayıt defteri seçimi belirleyicidir.** Yerleşik `ollama-cloud` satırı, `/v1/models` canlı + keşfi için `https://ollama.com/v1` temel URL'sini korurken çıkarım + `POST https://ollama.com/api/chat` üzerine normalleştirilir. Sağlayıcı satırındaki yapılandırılmış + `adapter` değeri atılır. Sıradan yerleşik yerel Ollama `openai-chat` üzerinde kalır; yerel veya + self-hosted bir hedef için `ollama-native` seçmek açık bir sağlayıcı yapılandırma kararıdır ve + ana bilgisayara göre belirlenir, böylece Ollama olmayan bir hedef hiçbir zaman sessizce + yeniden yazılmaz. +- **Model meta verileri:** `/v1/models` model başına meta veri taşımaz; bu yüzden kanonik Ollama + Cloud için sağlayıcı, keşfedilen her kimliği *sınırlı* bir `POST /api/show` ile zenginleştirir + (yanıt başına 256 KiB, istek başına 8 sn, eşzamanlılık 4, 48 istek, tüm aşama için 12 sn süre) ve + gerçek bağlam penceresi ile vision yeteneğini doldurur. show isteği aynı kaynaktadır ve asla bir + yönlendirmeyi izlemez; hata yalnızca o modeli düşürür, keşfi asla bozmaz. +- **Akış:** Ollama'nın yerel NDJSON'u. Metin ve `message.thinking` delta'ları geldikçe iletilir; + bir tur yalnızca `done: true` terminal kaydında tamamlanır ve tamponlanmış `done: false` ya da + eksik terminal, kısmi metni ve araç çağrılarını tamamen bastırır. +- **Reasoning:** Ollama'nın yerel `think` alanına (`low`/`medium`/`high`/`max` ve booleans) + eşlenir, modelin duyurulan merdivenine kırpılır ve üst katmanda yapılandırılan `__omit__` + sentinel semantiğine uyar. +- **Görseller:** model vision destekliyorsa mesajın `images` dizisinde yerel olarak gönderilir; + video yanlış gönderilmek yerine reddedilir ve uzak görsel URL'leri alınmaz. +- **Araçlar:** Ollama'nın yerel biçiminde bildirilir; akış halindeki araç çağrıları `arguments` + alanı nesne olan bütün çağrı kayıtlarıdır ve araç sonucu yeniden oynatma, çağrı kimliği ve araç + adına göre sıkı şekilde eşleştirilir. `tool_choice: "none"` ve `auto` normal çalışır; + **`required` veya tam adlandırılmış seçim fail closed** olur, çünkü Ollama'nın `/api/chat` + arabiriminde bunu dayatacak bir `tool_choice` alanı yoktur. +- **Yapılandırılmış çıktı kanonik Ollama Cloud'da reddedilir.** Ollama şu anda yapılandırılmış + çıktıyı Cloud'da desteklemediğini belgeliyor ve Cloud `format` alanını zorunlu kılmıyor; bu + yüzden OpenCodex, şema tanımlı bir isteğe karşılık serbest metin döndürmek yerine isteği kapatarak + başarısız kılar. Yerel ve özel `ollama-native` uç noktaları Ollama'nın yerel `format` eşlemesini + korur (`json_object` → `"json"`, `json_schema` → şema nesnesinin kendisi). + ## `openai-responses` **Hedefler:** OpenAI **Responses API**. **`passthrough: true`** — ham istek diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 4db3211bc5..e4f5497f8c 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -74,7 +74,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | Alan | Tip | Anlamı | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (veya takma ad `azure`) seçeneklerinden biri. | +| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (veya takma ad `azure`) seçeneklerinden biri. | | `baseUrl` | `string` | Yukarı akış API temel URL'si. Çoğu yerleşik sabit uç nokta uyumsuzluğu yok sayar; çakışma güvenli anahtar önayarları aynı adlı daha eski özel bir hedefi korur. | | `responsesPath?` | `string` | Anahtar kimlik doğrulamalı `openai-responses` istekleri için göreli kaynak yolu. `/` ile başlamalı ve şema, sorgu veya parça içermemelidir. | | `supportsServiceTier?` | `boolean` | Üç durumlu `service_tier` yeteneği. `true`: hızlı mod enjekte edebilir ve arayan değerleri korunur. `false`: alan kaldırılır ve asla enjekte edilmez (desteklemediği belgelenen yukarı akış bunu almamalıdır). Yok: sağlayıcı sınıflandırılmamıştır — arayan tarafından sağlanan değerler dokunulmadan korunur ve hızlı mod asla enjekte etmez. Kayıt defteri kurallı OpenAI'yi (`true`), DeepSeek'i ve Volcengine Ark'ı (`false`) sınıflandırır; bunu yalnızca katmanları gerçekten destekleyen özel ağ geçitleri için açıkça ayarlayın. | @@ -462,7 +462,6 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index a73676e496..a4bbab6186 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -386,7 +386,11 @@ Cursor OAuth 和 live model discovery 已在这个实验性 adapter 中启用; ### Ollama Cloud -Ollama Cloud 是托管(而非本地)的 Ollama,在 `https://ollama.com/v1` 上兼容 OpenAI,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 +Ollama Cloud 是托管(而非本地)的 Ollama,配置地址为 `https://ollama.com/v1`,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 通过 Ollama 自身的 REST API(`POST /api/chat`)连接,而不是 OpenAI 兼容接口,并从提供方动态发现模型列表,因此新的 Ollama Cloud 模型无需改动配置即可出现。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 + +Ollama 目前在文档中说明结构化输出在 Ollama Cloud 上不受支持。因此对正典 `ollama-cloud`, +opencodex 会以明确的错误拒绝结构化输出请求(`text.format`),而不是悄悄返回不受约束的自由 +文本;本地 / 自定义 `ollama-native` 端点保留 Ollama 原生的 `format` 行为。 ## 4. 本地提供商 diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 522dfdb41c..ea758b48e8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -109,7 +109,6 @@ Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会 { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 3b124df7f6..046488f9e3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -25,7 +25,7 @@ interface ProviderAdapter { ## `openai-chat` **目标:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)以及所有兼容 provider, -包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本地与云端)等。 +包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本地)等。 **认证:** `key`(Bearer)。 - 把内部消息转换成 OpenAI role;工具映射为 `{type:"function", function:{…}}` 和 @@ -43,6 +43,38 @@ interface ProviderAdapter { `medium`、`high`、`xhigh` 或 `max` 档位,把 `delta.reasoning_content` 或 `delta.reasoning` 作为 reasoning delta,通过 `stream_options.include_usage` 请求流式 usage,并从非流式响应 envelope 中读取 usage。 +## `ollama-native` + +**目标:** Ollama 自有的 **Chat API**(`POST /api/chat`),而不是其 OpenAI 兼容接口。内置的 +`ollama-cloud` 提供方由 registry 选择到该 adapter;也可以在单独命名的自定义 / 自托管 Ollama +提供方上配置 `adapter: "ollama-native"`。 +**认证:** cloud / 自定义端点使用 `key`(Bearer);loopback 或 `authMode: "local"` +端点不会收到任何凭据。 + +- **registry 选择起决定作用。** 内置的 `ollama-cloud` 行保留 `https://ollama.com/v1` 作为 + `/v1/models` 动态发现的基础 URL,同时推理会规范化到 `POST https://ollama.com/api/chat`。 + 对该提供方行,配置中的 `adapter` 会被丢弃。普通内置本地 Ollama 仍走 `openai-chat`;为本 + 地或自托管端点选择 `ollama-native` 是显式的提供方配置决策,并按主机名判别,因此非 Ollama + 目标永远不会被悄悄改写。 +- **模型元数据:** `/v1/models` 不携带任何模型级元数据,因此在正典 Ollama Cloud 上,提供方 + 会通过 *有界限的* `POST /api/show`(每响应 256 KiB、每请求 8 秒、并发 4、48 个请求、整阶段 + 12 秒期限)补全每个被发现 id 的真实 context window 与 vision 能力。show 请求同源且从不 + 跟随重定向;失败只降级该模型,不会令发现本身失败。 +- **流式:** Ollama 原生 NDJSON。文本与 `message.thinking` delta 到达即转发;回合仅在 + `done: true` 终止记录上完成,缓冲的 `done: false` 或缺失终记录会完全抑制部分文本与工具调用。 +- **Reasoning:** 映射到 Ollama 原生 `think` 字段(`low`/`medium`/`high`/`max`,外加布尔值), + 按模型声明的档位收紧,并遵守上游配置的 `__omit__` sentinel 语义。 +- **图像:** 在模型具备 vision 能力时原样放入消息的 `images` 数组发送;video 会被拒绝而非 + 误发,远程图像 URL 不会被拉取。 +- **工具:** 以 Ollama 原生形状声明;流式 tool call 是 `arguments` 为对象的整调用记录, + tool result 回放按 call id 与工具名严格配对。`tool_choice: "none"` 与 `auto` 表现正常; + **`required` 或精确命名选择会 fail closed**,因为 Ollama 的 `/api/chat` 没有可用来强制它的 + `tool_choice` 字段。 +- **正典 Ollama Cloud 上拒绝结构化输出。** Ollama 目前在文档中说明其 Cloud 不支持结构化输出, + 且 Cloud 不会强制 `format` 字段,因此对按 schema 提出的请求,OpenCodex 会让其显式失败,而 + 不是返回不受约束的自由文本。本地 / 自定义 `ollama-native` 端点保留 Ollama 原生的 `format` + 映射(`json_object` → `"json"`,`json_schema` → schema 对象本身)。 + ## `openai-responses` **目标:** OpenAI **Responses API**。**`passthrough: true`** —— 通常原样转发请求与响应,仅对 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 3630a9ba6c..ef827e9d32 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -54,7 +54,7 @@ selector,而不是分配一个新名称。 | 字段 | 类型 | 含义 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai`(或别名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API 基础 URL。大多数内置固定端点会忽略不匹配的值;具备冲突安全键的预设会保留一个更早、同名的自定义目标。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的客户端出站请求启动节流,与上游用量、计费和限流指标相互独立。提供商限制适用于所有模型,`models` 按上游模型精确 ID 匹配且只能增加延迟。排队等待不计入响应头超时。覆盖 HTTP、Responses WebSocket 以及显式适配器 `fetchResponse`/`runTurn` 调用。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | @@ -340,7 +340,6 @@ OpenRouter 可以通过多个推理提供者来提供同一个模型。`openRout "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 28298c768a..0c20867124 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -484,14 +484,20 @@ Cursor 仍不會出現在 key-login list。 ### Ollama Cloud -Ollama Cloud 是 hosted、不是 local 的 Ollama,在 `https://ollama.com/v1` 提供 OpenAI-compatible API, -key 來自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 依 vision capability 分類其 +Ollama Cloud 是 hosted、不是 local 的 Ollama,設定位址為 `https://ollama.com/v1`, +key 來自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 以 Ollama 自身的 +REST API(`POST /api/chat`)連線,而非 OpenAI-compatible 介面,並向 provider 動態探索模型清單, +因此新的 Ollama Cloud 模型不需改設定就會出現。opencodex 依 vision capability 分類其 cloud lineup,讓 [vision sidecar](/zh-tw/guides/sidecars/) 只對純文字模型生效。純文字模型,例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`,會列在 `noVisionModels`;原生 vision 模型,例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、 `gemini-3-flash-preview`,不會列入。matching 可容忍 Ollama 的 `:size` tag,因此 `gpt-oss` 同時涵蓋 `gpt-oss:120b` 與 `gpt-oss:20b`。 +Ollama 目前在文件中說明結構化輸出在 Ollama Cloud 上不受支援。因此對正典 `ollama-cloud`, +opencodex 會以明確的錯誤拒絕結構化輸出請求(`text.format`),而不是悄悄回傳不受約束的 +散文式文字;本機 / 自訂 `ollama-native` 端點保留 Ollama 原生的 `format` 行為。 + ## 4. 本機供應商 讓 opencodex 指向本機 OpenAI-compatible server,通常使用空 key: diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 1ffe807961..5d9c7d1de4 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -104,7 +104,6 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index f1312d818c..c92be2b73d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -25,7 +25,7 @@ interface ProviderAdapter { ## `openai-chat` **目標:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)以及所有相容 provider, -包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本機與雲端)等。 +包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本機)等。 **認證:** `key`(Bearer)。 - 把內部訊息轉換成 OpenAI role;工具對映為 `{type:"function", function:{…}}` 和 @@ -42,6 +42,38 @@ interface ProviderAdapter { 的 reasoning delta,以 `stream_options.include_usage` 請求串流 usage,並從非串流回應 envelope 讀取 usage。 +## `ollama-native` + +**目標:** Ollama 自身的 **Chat API**(`POST /api/chat`),而非其 OpenAI 相容介面。內建的 +`ollama-cloud` 提供者由 registry 選擇到此 adapter;也可以在另外命名的自訂 / 自架 Ollama +提供者上設定 `adapter: "ollama-native"`。 +**驗證:** cloud / 自訂端點使用 `key`(Bearer);loopback 或 `authMode: "local"` 端點不會 +收到任何憑證。 + +- **registry 選擇具有決定性。** 內建 `ollama-cloud` 列保留 `https://ollama.com/v1` 作為 + `/v1/models` 動態探索的基礎 URL,同時推論會正規化到 `POST https://ollama.com/api/chat`。 + 對該提供者列,設定中的 `adapter` 會被丟棄。一般內建本機 Ollama 仍在 `openai-chat`;為本機 + 或自架端點選擇 `ollama-native` 是明確的提供者設定決定,並依主機判別,因此非 Ollama 目的 + 地不會被默默改寫。 +- **模型中繼資料:** `/v1/models` 不攜帶任何模型級中繼資料,因此在正典 Ollama Cloud 上, + 提供者會透過 *有界限的* `POST /api/show`(每回應 256 KiB、每請求 8 秒、並行 4、48 個請求、 + 整階段 12 秒期限)補上每個被探索 id 的真實 context window 與 vision 能力。show 請求同源 + 且絕不跟隨重新導向;失敗只會降級該模型,不會讓探索本身失敗。 +- **串流:** Ollama 原生 NDJSON。文字與 `message.thinking` delta 隨到隨轉發;回合僅在 + `done: true` 終止記錄上完成,緩衝的 `done: false` 或缺少終端會完全抑制部分文字與工具呼叫。 +- **Reasoning:** 對映到 Ollama 原生 `think` 欄位(`low`/`medium`/`high`/`max`,外加布林值), + 依模型宣告的層級夾限,並遵守上游設定的 `__omit__` sentinel 語義。 +- **圖像:** 在模型具備 vision 能力時,原樣放進訊息的 `images` 陣列送出;video 會被拒絕而非 + 誤送,遠端圖像 URL 不會被擷取。 +- **工具:** 以 Ollama 原生形狀宣告;串流 tool call 是 `arguments` 為物件的整呼叫記錄, + tool result 重播按 call id 與工具名嚴格配對。`tool_choice: "none"` 與 `auto` 正常運作; + **`required` 或精確名稱選擇會 fail closed**,因為 Ollama 的 `/api/chat` 沒有可用來強制它的 + `tool_choice` 欄位。 +- **正典 Ollama Cloud 上拒絕結構化輸出。** Ollama 目前在文件中說明其 Cloud 不支援結構化輸出, + 且 Cloud 不會強制 `format` 欄位,因此 OpenCodex 會讓該請求顯式失敗,而不是在 schema 指定的 + 請求上回傳不受約束的散文。本機 / 自訂 `ollama-native` 端點保留 Ollama 原生的 `format` 映射 + (`json_object` → `"json"`,`json_schema` → schema 物件本身)。 + ## `openai-responses` **目標:** OpenAI **Responses API**。**`passthrough: true`** —— 轉發原始請求 body,並把回應 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index b0a46f49ec..398a396dc7 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -38,7 +38,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | 欄位 | 型別 | 意義 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或別名 `azure`)之一。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai`(或別名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API base URL。多數內建固定端點忽略不符;碰撞安全的金鑰預設保留較舊的同名自訂目的地。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的用戶端出站請求啟動節流,與上游用量、計費及限流指標彼此獨立。供應商限制適用於所有模型,`models` 依上游模型精確 ID 比對且只能增加延遲。排隊等待不計入回應標頭逾時。涵蓋 HTTP、Responses WebSocket 及明確的適配器 `fetchResponse`/`runTurn` 呼叫。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | @@ -308,7 +308,6 @@ OpenRouter 可透過多個推論供應商提供一個模型。`openRouterRouting "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/src/adapters/ollama-native-url.ts b/src/adapters/ollama-native-url.ts new file mode 100644 index 0000000000..ef84210c1d --- /dev/null +++ b/src/adapters/ollama-native-url.ts @@ -0,0 +1,111 @@ +/** + * URL policy for Ollama's native REST API. + * + * The built-in Ollama provider historically stored an OpenAI-compatible `/v1` base URL. The + * native adapter deliberately canonicalizes that compatibility spelling only for Ollama's known + * local/cloud hosts. An arbitrary custom host with a `/v1` path is never silently rewritten. + */ + +export type OllamaNativeEndpointKind = "local" | "cloud" | "custom"; + +const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); +const CLOUD_HOSTNAMES = new Set(["ollama.com"]); +const REJECTED_CLOUD_ALIAS_HOSTNAMES = new Set(["www.ollama.com"]); + +function normalizedHostname(url: URL): string { + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; +} + +function normalizedPath(url: URL): string { + const path = url.pathname.replace(/\/+$/, ""); + return path === "/" ? "" : path; +} + +function endpointKind(url: URL): OllamaNativeEndpointKind { + // WHATWG URL keeps IPv6 brackets in `hostname` on Bun/Node (`[::1]`), while the + // loopback policy is stored in its canonical host form (`::1`). + const hostname = normalizedHostname(url); + if (REJECTED_CLOUD_ALIAS_HOSTNAMES.has(hostname)) { + throw new Error("ollama-native requires canonical Ollama Cloud host ollama.com; www.ollama.com is rejected"); + } + if (LOCAL_HOSTNAMES.has(hostname)) return "local"; + if (CLOUD_HOSTNAMES.has(hostname)) return "cloud"; + return "custom"; +} + +function assertCloudTransport(url: URL, kind: OllamaNativeEndpointKind): void { + if (kind !== "cloud") return; + if (url.protocol !== "https:") { + throw new Error("ollama-native canonical Ollama Cloud requires HTTPS"); + } + if (url.port) { + throw new Error("ollama-native canonical Ollama Cloud rejects non-default ports"); + } +} + +function parseBaseUrl(baseUrl: string): URL { + const trimmed = baseUrl.trim(); + if (!trimmed) throw new Error("ollama-native requires a non-empty baseUrl"); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("ollama-native requires an absolute http(s) baseUrl"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("ollama-native only supports http(s) base URLs"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("ollama-native baseUrl must not contain credentials, a query, or a fragment"); + } + return url; +} + +/** Return the endpoint family used by native authentication policy. */ +export function ollamaNativeEndpointKind(baseUrl: string): OllamaNativeEndpointKind { + const url = parseBaseUrl(baseUrl); + const kind = endpointKind(url); + assertCloudTransport(url, kind); + return kind; +} + +/** True when the base URL points at canonical Ollama Cloud (not a self-hosted destination). */ +export function isCanonicalOllamaCloudUrl(baseUrl: string): boolean { + return ollamaNativeEndpointKind(baseUrl) === "cloud"; +} + +/** + * Build the native chat endpoint from a configured base URL. + * + * Recognized compatibility forms on canonical Ollama hosts: + * - `/`, `/api`, `/api/chat` + * - legacy `/v1` and `/v1/chat/completions` + * + * For an unrelated custom host only `/`, `/api`, and `/api/chat` are accepted. In particular, + * `/v1` is rejected instead of being stripped or guessed at. + */ +export function ollamaNativeChatUrl(baseUrl: string): string { + const url = parseBaseUrl(baseUrl); + const kind = endpointKind(url); + assertCloudTransport(url, kind); + const path = normalizedPath(url); + const canonicalPaths = new Set(["", "/api", "/api/chat", "/v1", "/v1/chat/completions"]); + const customPaths = new Set(["", "/api", "/api/chat"]); + + if (kind === "custom" && !customPaths.has(path)) { + throw new Error( + `ollama-native refuses custom baseUrl path "${path || "/"}"; use a native root, /api, or /api/chat`, + ); + } + if (kind !== "custom" && !canonicalPaths.has(path)) { + throw new Error( + `ollama-native refuses Ollama baseUrl path "${path || "/"}"; use root, /v1, /api, or /api/chat`, + ); + } + + if (kind === "cloud") url.hostname = normalizedHostname(url); + url.pathname = "/api/chat"; + return url.toString(); +} diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts new file mode 100644 index 0000000000..569ae90622 --- /dev/null +++ b/src/adapters/ollama-native.ts @@ -0,0 +1,1131 @@ +import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; +import { randomUUID } from "node:crypto"; +import type { + AdapterEvent, + OcxAssistantMessage, + OcxContentPart, + OcxMessage, + OcxParsedRequest, + OcxProviderConfig, + OcxThinkingContent, + OcxToolCall, + OcxUsage, +} from "../types"; +import { + isAllowedToolChoice, + modelInList, + namespacedToolName, + toolChoiceToolPredicate, +} from "../types"; +import { configuredReasoningEfforts, isReasoningEffortOmitted, mapReasoningEffort, modelRecordValue, reasoningEffortMapFor } from "../reasoning-effort"; +import { + readBoundedResponseBytes, +} from "../lib/bounded-body"; +import { debugProviderDiagnostic } from "../lib/debug"; +import { + isTranslatorBudgetExceededError, + retainTranslatedEventBatch, + TRANSLATOR_MAX_SSE_EVENT_BYTES, + TranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { redactSecretString, SENSITIVE_KEY_PATTERN } from "../lib/redact"; +import { parseDataUrl } from "./image"; +import { + ollamaNativeChatUrl, + ollamaNativeEndpointKind, + type OllamaNativeEndpointKind, +} from "./ollama-native-url"; + +/** Native `/api/chat` message shape used by this adapter. */ +export interface OllamaNativeMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + thinking?: string; + images?: string[]; + tool_call_id?: string; + tool_name?: string; + tool_calls?: Array<{ + type: "function"; + function: { + index?: number; + name: string; + arguments: Record; + }; + id?: string; + }>; +} + +interface OllamaNativeTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +interface PendingToolCall { + id: string; + name: string; + namespace?: string; + wireName: string; + order: number; + result?: OcxMessage & { role: "toolResult" }; +} + +interface PendingToolBatch { + calls: PendingToolCall[]; + byId: Map; +} + +interface NativeStreamToolCall { + key: string; + budgetKey: string; + order: number; + name: string; + nativeId?: string; + nativeIndex?: number; + arguments: Record; + argumentBytes: number; +} + +interface NativeStreamState { + toolCalls: Map; + nextToolOrder: number; + usage?: OcxUsage; + stopReason?: string; + sawMessage: boolean; + terminal: boolean; + terminalError: boolean; + allowParallelToolCalls: boolean; +} + +type JsonRecord = Record; +type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value?: undefined }; + +const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]); +const NATIVE_TOOL_ID_MAX_LENGTH = 256; +const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u; + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +/** + * Provider-owned call ids are carried into the client-visible Responses call_id field, so never + * expose malformed or unbounded strings. A duplicate native id is treated like an unusable id: + * Ollama is allowed to omit ids or repeat them across requests, while the OCX history contract + * requires one stable, globally unique pairing key. + */ +function validNativeToolCallId(value: unknown): string | undefined { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > NATIVE_TOOL_ID_MAX_LENGTH + || value !== value.trim() + || NATIVE_TOOL_ID_CONTROL.test(value) + ) return undefined; + return value; +} + +function mintNativeToolCallId(nativeIndex: number | undefined, issuedIds: Set): string { + let id = ""; + do { + id = "ollama_call_" + randomUUID() + "_" + (nativeIndex ?? "na"); + } while (issuedIds.has(id)); + issuedIds.add(id); + return id; +} + +function allocateNativeToolCallId( + nativeId: unknown, + nativeIndex: number | undefined, + issuedIds: Set, +): string { + const valid = validNativeToolCallId(nativeId); + if (valid && !issuedIds.has(valid)) { + issuedIds.add(valid); + return valid; + } + return mintNativeToolCallId(nativeIndex, issuedIds); +} + +function safeNativeString(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const redacted = redactSecretString(value.trim()); + return redacted.length > 400 ? `${redacted.slice(0, 400)}…` : redacted; +} + +function errorDetail(value: unknown): string | undefined { + if (typeof value === "string") return value.trim() || undefined; + if (!isRecord(value)) return undefined; + if (typeof value.error === "string" && value.error.trim()) return value.error.trim(); + if (isRecord(value.error) && typeof value.error.message === "string" && value.error.message.trim()) { + return value.error.message.trim(); + } + if (typeof value.detail === "string" && value.detail.trim()) return value.detail.trim(); + if (typeof value.message === "string" && value.message.trim()) return value.message.trim(); + return undefined; +} + +function nativeErrorEvent( + detail: unknown, + usage?: OcxUsage, + status = 502, +): Extract { + return { + type: "error", + status, + errorType: "upstream_error", + code: "ollama_native_error", + message: safeNativeString(errorDetail(detail), "Ollama native upstream error"), + ...(usage ? { usage } : {}), + }; +} + +function malformedNativeEvent(message: string, usage?: OcxUsage): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: "invalid_ollama_native_payload", + message, + ...(usage ? { usage } : {}), + }; +} + +function translationBudgetEvent(usage?: OcxUsage): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: "translation_buffer_limit", + message: "upstream translation buffer exceeded the safe limit", + ...(usage ? { usage } : {}), + }; +} + +function wireModelId(provider: OcxProviderConfig, modelId: string): string { + if (!provider.modelSuffixBracketStrip) return modelId; + const end = modelId.trimEnd(); + if (!end.endsWith("]")) return modelId; + const start = end.lastIndexOf("["); + return start > 0 ? end.slice(0, start) : modelId; +} + +function assertObjectArguments(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`ollama-native ${label} arguments must be a JSON object`); + return value; +} + +function normalizedBase64(value: string, label: string): string { + const base64 = value.replace(/\s+/g, ""); + if ( + base64.length === 0 + || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64) + || base64.length % 4 === 1 + ) { + throw new Error(`ollama-native ${label} image is not valid base64`); + } + return base64; +} + +function imageToBase64(imageUrl: string, label: string): string { + const data = parseDataUrl(imageUrl); + if (data) { + if (!data.mediaType.toLowerCase().startsWith("image/")) { + throw new Error(`ollama-native ${label} image data URL is not an image`); + } + return normalizedBase64(data.base64, label); + } + if (/^https?:\/\//i.test(imageUrl)) { + throw new Error(`ollama-native does not fetch remote ${label} image URLs; provide a data URL/base64 image`); + } + return normalizedBase64(imageUrl, label); +} + +function contentToNative( + content: string | OcxContentPart[], + label: string, + allowImages = true, +): { content: string; images?: string[] } { + if (typeof content === "string") return { content }; + let text = ""; + const images: string[] = []; + for (const part of content) { + if (part.type === "text") { + text += part.text; + continue; + } + // Ollama's native /api/chat message shape carries `images: string[]` and has no video + // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image. + if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`); + if (!allowImages) throw new Error(`ollama-native cannot preserve images in ${label} developer content`); + images.push(imageToBase64(part.imageUrl, label)); + } + return images.length > 0 ? { content: text, images } : { content: text }; +} + +function assistantTextThinkingAndCalls(message: OcxAssistantMessage): { + content: string; + thinking?: string; + calls: OcxToolCall[]; +} { + let content = ""; + let thinking = ""; + const calls: OcxToolCall[] = []; + for (const part of message.content) { + if (part.type === "text") content += part.text; + else if (part.type === "thinking") thinking += (part as OcxThinkingContent).thinking; + else if (part.type === "toolCall") calls.push(part); + } + return { + content, + ...(thinking ? { thinking } : {}), + calls, + }; +} + +function buildNativeMessages( + parsed: OcxParsedRequest, + reservedToolCallIds: Set, +): OllamaNativeMessage[] { + const messages: OllamaNativeMessage[] = []; + for (const system of parsed.context.systemPrompt ?? []) { + messages.push({ role: "system", content: system }); + } + + // A request-boundary adapter is a fresh object in production. Reserve every id already + // present in the parsed history before the next provider response is translated, so a provider + // id reused on a later request cannot become a duplicate OCX call_id. The set is deliberately + // owned by this adapter/request lifecycle rather than process-global state. + reservedToolCallIds.clear(); + let pending: PendingToolBatch | undefined; + + const flushPending = (): void => { + if (!pending) return; + for (const call of pending.calls) { + if (!call.result) { + throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`); + } + } + for (const call of pending.calls) { + const result = call.result!; + const translated = contentToNative(result.content, "tool result"); + messages.push({ + role: "tool", + tool_call_id: call.id, + tool_name: call.wireName, + content: translated.content, + ...(translated.images ? { images: translated.images } : {}), + }); + } + pending = undefined; + }; + + for (const message of parsed.context.messages) { + if (message.role === "toolResult") { + if (!pending) { + throw new Error(`ollama-native orphan tool result ${message.toolCallId || ""}`); + } + const call = pending.byId.get(message.toolCallId); + if (!call) { + throw new Error(`ollama-native tool result ${message.toolCallId || ""} has no originating call`); + } + if (call.result) { + throw new Error(`ollama-native duplicate tool result for ${message.toolCallId}`); + } + if (call.name !== message.toolName || call.namespace !== message.toolNamespace) { + throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`); + } + call.result = message; + continue; + } + + // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A + // new conversational message is a hard boundary; unresolved calls are never fabricated. + if (pending) flushPending(); + + switch (message.role) { + case "user": { + const translated = contentToNative(message.content, "user"); + messages.push({ role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) }); + break; + } + case "developer": { + const translated = contentToNative(message.content, "developer", false); + messages.push({ role: "system", content: translated.content }); + break; + } + case "assistant": { + const extracted = assistantTextThinkingAndCalls(message); + const wireCalls: PendingToolCall[] = []; + const nativeCalls = extracted.calls.map((call, index) => { + if (!call.id || reservedToolCallIds.has(call.id)) { + throw new Error(`ollama-native assistant tool call id is missing or duplicated: ${call.id || ""}`); + } + reservedToolCallIds.add(call.id); + const args = assertObjectArguments(call.arguments, `assistant tool call ${call.id}`); + // `customWireName` belongs to the prior caller/provider wire. It must not override + // this adapter's deterministic namespace flattening during replay: a native turn is + // paired by the OCX name/namespace, then lowered to the native wire name here. + const wireName = namespacedToolName(call.namespace, call.name); + if (!wireName) throw new Error(`ollama-native assistant tool call ${call.id} has no name`); + const pendingCall: PendingToolCall = { + id: call.id, + name: call.name, + namespace: call.namespace, + wireName, + order: index, + }; + wireCalls.push(pendingCall); + return { + type: "function" as const, + id: call.id, + function: { index, name: wireName, arguments: args }, + }; + }); + const native: OllamaNativeMessage = { + role: "assistant", + content: extracted.content, + ...(extracted.thinking ? { thinking: extracted.thinking } : {}), + ...(nativeCalls.length > 0 ? { tool_calls: nativeCalls } : {}), + }; + messages.push(native); + if (wireCalls.length > 0) { + pending = { calls: wireCalls, byId: new Map(wireCalls.map(call => [call.id, call])) }; + } + break; + } + } + } + if (pending) flushPending(); + return messages; +} + +function buildNativeTools(parsed: OcxParsedRequest): OllamaNativeTool[] | undefined { + const declared = parsed.context.tools; + if (!declared || declared.length === 0 || parsed.options.toolChoice === "none") return undefined; + + const choice = parsed.options.toolChoice; + if ( + choice === "required" + || (isAllowedToolChoice(choice) && choice.mode === "required") + || (choice && typeof choice === "object" && !isAllowedToolChoice(choice) && "name" in choice) + ) { + throw new Error("ollama-native does not support required or exact named tool_choice"); + } + const predicate = toolChoiceToolPredicate(choice, declared); + const seenNames = new Set(); + const tools: OllamaNativeTool[] = []; + for (const tool of declared) { + if (!predicate(tool)) continue; + const name = namespacedToolName(tool.namespace, tool.name); + if (!name || seenNames.has(name)) throw new Error(`ollama-native duplicate flattened tool name: ${name || ""}`); + if (!isRecord(tool.parameters)) throw new Error(`ollama-native tool ${name} has no JSON schema object`); + seenNames.add(name); + tools.push({ + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + // Native Ollama accepts the schema directly. In particular, do not copy OpenAI's + // function.strict flag: `/api/chat` has no documented strict field. + parameters: tool.parameters, + }, + }); + } + return tools.length > 0 ? tools : undefined; +} + +function nativeThink( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, +): false | true | "low" | "medium" | "high" | "max" | undefined { + const requested = parsed.options.reasoning; + // The Responses parser leaves reasoning undefined when the caller made no reasoning decision. + // Ollama distinguishes an omitted think field from think:false; preserve that distinction. + if (requested === undefined) return undefined; + // An explicit `__omit__` wire mapping (issue #2356) is an intentional decision to send NO + // reasoning field. mapReasoningEffort() collapses the sentinel to `undefined`, and the + // `?? requested` fallback below would then re-emit the requested label — defeating the + // sentinel. Upstream consults only the BOUNDARY spelling (`ultra` → `max`) and states raw + // ultra must never influence the provider wire, so only wireMap[boundary] can authorize an + // omission. The explicit mapping is checked before the native `none`/noReasoning fallbacks so + // it stays authoritative over them. + const wireMap = reasoningEffortMapFor(provider, parsed.modelId); + if (wireMap) { + const boundary = requested === "ultra" ? "max" : requested; + if (isReasoningEffortOmitted(wireMap[boundary])) return undefined; + } + if (requested === "none" || modelInList(provider.noReasoningModels, parsed.modelId)) return false; + // Upstream intentionally advertises synthetic top rungs on routed rows so Codex/subagent effort + // overrides validate against catalog membership; the wire stays honest because the native + // adapter clamps the requested effort onto the provider's real supported ladder + // (clampToSupportedCodexEffort: max/ultra on a [low,medium,high] model serializes "high"). + const mapped = mapReasoningEffort(provider, parsed.modelId, requested); + if (mapped !== undefined) { + let value = mapped; + if (value === "minimal") value = "low"; + if (value === "xhigh" || value === "ultra") value = "max"; + if (value === "enabled" || value === "adaptive" || value === "true") return true; + if (value === "disabled" || value === "false") return false; + if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max"; + throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`); + } + // mapReasoningEffort() returned undefined. For an ordinary Codex label against a declared + // non-empty ladder this can ONLY be an authoritative post-clamp `__omit__` sentinel (the + // clamp resolved the requested effort onto a rung whose wire mapping is the sentinel) — + // honour it; never resurrect the raw requested label. Native boolean aliases have no + // mapping at all, so their raw passthrough stays isolated here. + const supported = configuredReasoningEfforts(provider, parsed.modelId); + const ordinaryLabel = requested === "minimal" || requested === "low" || requested === "medium" + || requested === "high" || requested === "xhigh" || requested === "ultra" + || requested === "max"; + if (supported !== undefined && supported.length > 0 && ordinaryLabel) return undefined; + let value = requested; + if (value === "minimal") value = "low"; + if (value === "enabled" || value === "adaptive" || value === "true") return true; + if (value === "disabled" || value === "false") return false; + if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max"; + throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`); +} + +function nativeFormat( + parsed: OcxParsedRequest, + endpointKind: OllamaNativeEndpointKind, +): "json" | Record | undefined { + const format = parsed.options.textFormat; + if (!format) return undefined; + // Ollama's own documentation states "Ollama's Cloud currently does not support structured + // outputs" (docs/capabilities/structured-outputs.mdx). Cloud does not reject `format`: it + // returns 200 and ignores the constraint, so sending it would turn an output-shape contract + // into unconstrained prose the caller believes is schema-valid. Refuse the contract instead, + // the same call Kiro makes for a wire that cannot enforce it. Local and custom self-hosted + // Ollama keep the native `format` mapping, which their contract does honour. + if (endpointKind === "cloud") { + throw new Error("ollama-native does not support structured output on Ollama Cloud"); + } + if (format.type === "json_object") return "json"; + if (!format.schema || !isRecord(format.schema)) { + throw new Error("ollama-native json_schema output requires a JSON schema object"); + } + // Ollama's native contract takes the schema itself, unlike OpenAI's response_format wrapper. + return format.schema; +} + +function usageFromNative(value: JsonRecord | undefined): OcxUsage | undefined { + if (!value) return undefined; + const input = isFiniteNonNegativeInteger(value.prompt_eval_count) ? value.prompt_eval_count : undefined; + const output = isFiniteNonNegativeInteger(value.eval_count) ? value.eval_count : undefined; + if (input === undefined && output === undefined) return undefined; + return { inputTokens: input ?? 0, outputTokens: output ?? 0 }; +} + +function stopReasonFromNative(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + if (value === "length") return "max_tokens"; + return value; +} + +function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budget: TranslatorBudget): AdapterEvent[] { + const events: AdapterEvent[] = []; + if (message.role !== undefined && message.role !== "assistant") { + throw new Error("ollama-native response message role was not assistant"); + } + // Deltas are forwarded as they arrive; the parser keeps no second complete copy of the + // response. In-flight memory is bounded by the per-line reservation in the stream reader and + // the bounded buffered read, matching how the openai-chat adapter accounts deltas. + if (message.thinking !== undefined) { + if (typeof message.thinking !== "string") throw new Error("ollama-native response thinking was not text"); + if (message.thinking) events.push({ type: "reasoning_raw_delta", text: message.thinking }); + } + if (message.content !== undefined) { + if (typeof message.content !== "string") throw new Error("ollama-native response content was not text"); + if (message.content) events.push({ type: "text_delta", text: message.content }); + } + if (message.tool_calls !== undefined) { + if (!Array.isArray(message.tool_calls)) throw new Error("ollama-native response tool_calls was not an array"); + for (let position = 0; position < message.tool_calls.length; position++) { + const rawCall = message.tool_calls[position]; + if (!isRecord(rawCall) || !isRecord(rawCall.function)) { + throw new Error("ollama-native response tool call was malformed"); + } + const fn = rawCall.function; + if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name"); + const args = assertObjectArguments(fn.arguments, "response tool call"); + const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined; + const nativeId = validNativeToolCallId(rawCall.id); + const key = index === undefined ? `position:${position}` : `index:${index}`; + // Tool-call identity is explicitly keyed by the provider index when supplied: a later + // frame for the same index updates that call's arguments, while a distinct index creates a + // second call. This narrow tool-call compatibility rule is independent from text/thinking + // semantics, where every non-empty native field is an appended partial delta. + const existing = state.toolCalls.get(key); + if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) { + throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested"); + } + if (existing) { + if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function"); + if (nativeId && existing.nativeId && nativeId !== existing.nativeId) { + throw new Error("ollama-native response changed a tool-call id for an existing index"); + } + if (!existing.nativeId && nativeId) existing.nativeId = nativeId; + replaceNativeToolArguments(existing, args, budget); + } else { + const call: NativeStreamToolCall = { + key, + budgetKey: `ollama-native:${key}`, + order: state.nextToolOrder++, + name: fn.name, + ...(nativeId ? { nativeId } : {}), + ...(index !== undefined ? { nativeIndex: index } : {}), + arguments: args, + argumentBytes: 0, + }; + budget.openCall(call.budgetKey); + try { + replaceNativeToolArguments(call, args, budget); + state.toolCalls.set(key, call); + } catch (error) { + budget.closeCall(call.budgetKey); + throw error; + } + } + events.push({ type: "heartbeat" }); + } + } + state.sawMessage = true; + return events; +} + +function flushNativeStreamToolCalls( + state: NativeStreamState, + issuedToolCallIds: Set, +): AdapterEvent[] { + const events: AdapterEvent[] = []; + const ordered = [...state.toolCalls.values()].sort((a, b) => a.order - b.order); + for (const call of ordered) { + const id = allocateNativeToolCallId(call.nativeId, call.nativeIndex, issuedToolCallIds); + events.push({ type: "tool_call_start", id, name: call.name }); + events.push({ type: "tool_call_delta", arguments: JSON.stringify(call.arguments) }); + events.push({ type: "tool_call_end" }); + } + return events; +} + +function replaceNativeToolArguments( + call: NativeStreamToolCall, + args: Record, + budget: TranslatorBudget, +): void { + const nextBytes = new TextEncoder().encode(JSON.stringify(args)).byteLength; + if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("tool_args", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (nextBytes === 0) { + if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey }); + call.arguments = args; + call.argumentBytes = 0; + return; + } + const reservation = budget.reserveTransient(nextBytes, { kind: "tool_args", callId: call.budgetKey }); + try { + reservation.commitRetained(); + if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey }); + call.arguments = args; + call.argumentBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } +} + +function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void { + for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey); +} + +function nativeBodyMessage(value: unknown): JsonRecord { + if (!isRecord(value)) throw new Error("ollama-native response message was missing or malformed"); + return value; +} + +function nativeEventsFromResponsePayload( + payload: unknown, + budget: TranslatorBudget, + issuedToolCallIds: Set, + allowParallelToolCalls = true, +): AdapterEvent[] { + if (!isRecord(payload)) return [malformedNativeEvent("Ollama native response was not a JSON object")]; + if (payload.error !== undefined && payload.error !== null) return [nativeErrorEvent(payload.error)]; + + const state: NativeStreamState = { + toolCalls: new Map(), + nextToolOrder: 0, + usage: usageFromNative(payload), + stopReason: stopReasonFromNative(payload.done_reason), + sawMessage: false, + terminal: false, + terminalError: false, + allowParallelToolCalls, + }; + // Same terminal contract as the NDJSON path — enforced BEFORE any actionable emission. The + // complete payload is already in memory and known invalid, so partial text and tool calls from + // it are suppressed along with the terminal: a truncated upstream reply must never be + // mistaken for a finished turn, and tool calls parsed out of one must never execute. + if (payload.done !== true) { + state.terminalError = true; + const reason = payload.done === undefined + ? "Ollama native response did not include done:true" + : payload.done === false + ? "Ollama native response reported done:false" + : "Ollama native response done flag was not boolean"; + return [malformedNativeEvent(reason, state.usage)]; + } + try { + const events = nativeMessageEvents(nativeBodyMessage(payload.message), state, budget); + events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds)); + events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) }); + state.terminal = true; + return events; + } catch (error) { + const events = isTranslatorBudgetExceededError(error) + ? [translationBudgetEvent(state.usage)] + : [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native response", state.usage)]; + state.terminalError = true; + return events; + } finally { + releaseNativeStateBuffers(state, budget); + } +} + +function formatNativeErrorBody(status: number, _headers: Headers, payloadText: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(payloadText); + } catch { + return status === 401 || status === 403 + ? "Ollama authentication failed" + : status === 404 + ? "Ollama native endpoint or model was not found" + : status === 429 + ? "Ollama rate limit was exceeded" + : status >= 500 + ? "Ollama native upstream failed" + : ""; + } + const detail = errorDetail(parsed); + if (detail) return redactSecretString(detail).slice(0, 400); + return status === 401 || status === 403 + ? "Ollama authentication failed" + : status === 404 + ? "Ollama native endpoint or model was not found" + : status === 429 + ? "Ollama rate limit was exceeded" + : status >= 500 + ? "Ollama native upstream failed" + : ""; +} + +function buildHeaders( + provider: OcxProviderConfig, + endpointKind: OllamaNativeEndpointKind, +): { headers: Record; hasCredential: boolean } { + if (provider.authMode === "forward") { + throw new Error("ollama-native does not support forwarded caller credentials"); + } + const hasApiKey = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; + const local = endpointKind === "local" || provider.authMode === "local"; + const plaintextRemote = endpointKind === "custom" && new URL(provider.baseUrl).protocol === "http:"; + // A copied provider row can carry credential headers even when apiKey is empty, and a + // key-optional custom row would otherwise ship them to a plaintext remote. Detect them with + // the shared credential-bearing name authority instead of a narrower local list. + const credentialHeaders = Object.keys(provider.headers ?? {}).filter(key => + SENSITIVE_KEY_PATTERN.test(key.trim()), + ); + if (plaintextRemote && (hasApiKey || credentialHeaders.length > 0)) { + throw new Error( + "ollama-native refuses to send credentials over plaintext non-loopback HTTP" + + (hasApiKey ? "" : ` (credential headers: ${credentialHeaders.join(", ")})`), + ); + } + const hasCredential = hasApiKey; + const requiresCredential = !local && (provider.authMode === undefined || provider.authMode === "key" || provider.authMode === "oauth"); + if (requiresCredential && !hasCredential && !provider.keyOptional) { + throw new Error("ollama-native cloud/custom endpoint requires a non-empty API credential"); + } + + // Same precedence as openAIChatTransport(): the generated Bearer is laid down FIRST and + // provider.headers are applied LAST, so an explicitly configured Authorization wins. Collision + // handling is case-insensitive and leaves exactly ONE effective credential spelling on the wire. + const headers: Record = { "Content-Type": "application/json" }; + if (!local && hasCredential) headers.Authorization = `Bearer ${provider.apiKey!.trim()}`; + for (const [key, value] of Object.entries(provider.headers ?? {})) { + // Loopback/local targets get no credentials at all — not even ones the row already carried — + // so a shared provider object cannot leak a shared credential to a local endpoint. + if (local && SENSITIVE_KEY_PATTERN.test(key.trim())) continue; + const lower = key.toLowerCase(); + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === lower && existing !== key) delete headers[existing]; + } + headers[key] = value; + } + // Diagnostics carry the FACT that a credential is attached, never any header value. + return { + headers, + hasCredential: !local && (hasCredential + || Object.keys(provider.headers ?? {}).some(k => SENSITIVE_KEY_PATTERN.test(k.trim()))), + }; +} + +function replaceLiveBuffer( + budget: TranslatorBudget, + previousBytes: number, + nextBytes: number, +): void { + // Release BEFORE reserving: the retained bound tracks what is actually in memory, so growing + // the residual never transiently charges old + new together. + if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "live_transient" }); + if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (nextBytes > 0) { + const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" }); + reservation.commitRetained(); + } +} + +async function readWithAbort( + reader: ReadableStreamDefaultReader, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return await reader.read() as NativeReadResult; + if (signal.aborted) throw signal.reason; + const read = reader.read(); + void read.catch(() => undefined); + let rejectAbort: ((reason: unknown) => void) | undefined; + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; }); + const onAbort = () => rejectAbort?.(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + try { + const result = await Promise.race([read, aborted]); + if (signal.aborted) throw signal.reason; + return result as NativeReadResult; + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function streamState(allowParallelToolCalls = true): NativeStreamState { + return { + toolCalls: new Map(), + nextToolOrder: 0, + sawMessage: false, + terminal: false, + terminalError: false, + allowParallelToolCalls, + }; +} + +function processNativeLine( + line: string, + state: NativeStreamState, + budget: TranslatorBudget, + issuedToolCallIds: Set, +): AdapterEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream contained malformed NDJSON")]; + } + if (!isRecord(parsed)) { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream line was not a JSON object")]; + } + if (state.terminal) { + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream emitted data after its terminal record")]; + } + if (parsed.error !== undefined && parsed.error !== null) { + state.terminal = true; + state.terminalError = true; + return [nativeErrorEvent(parsed.error, state.usage)]; + } + if (parsed.prompt_eval_count !== undefined || parsed.eval_count !== undefined) { + state.usage = usageFromNative(parsed) ?? state.usage; + } + if (parsed.done_reason !== undefined) state.stopReason = stopReasonFromNative(parsed.done_reason); + + const events: AdapterEvent[] = []; + if (parsed.message !== undefined) { + try { + events.push(...nativeMessageEvents(nativeBodyMessage(parsed.message), state, budget)); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) throw error; + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native stream message", state.usage)]; + } + } + if (parsed.done !== undefined && typeof parsed.done !== "boolean") { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream done flag was not boolean", state.usage)]; + } + if (parsed.done === true) { + state.terminal = true; + events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds)); + events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) }); + } + return events; +} + +async function* parseOllamaNativeStream( + response: Response, + budget: TranslatorBudget, + signal?: AbortSignal, + issuedToolCallIds?: Set, + allowParallelToolCalls = true, +): AsyncGenerator { + if (!response.body) { + yield malformedNativeEvent("Ollama native response had no body"); + return; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const encoder = new TextEncoder(); + const state = streamState(allowParallelToolCalls); + const issuedIds = issuedToolCallIds ?? new Set(); + let buffer = ""; + let bufferBytes = 0; + let mustCancel = false; + + const ingest = function* (text: string): Generator { + if (!text) return; + buffer += text; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + // The safety bound applies to the genuinely retained residual — the incomplete NDJSON record + // still being assembled — and to each complete record below. It must never depend on the + // transport read size (one read may carry many valid records) nor transiently charge + // old + replacement together. On a ceiling violation the old reservation is left in place so + // the generator's finally releases exactly what is held. + const residualBytes = encoder.encode(buffer).byteLength; + if (residualBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + replaceLiveBuffer(budget, bufferBytes, residualBytes); + bufferBytes = residualBytes; + + for (const rawLine of lines) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const lineBytes = encoder.encode(line).byteLength; + if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (lineBytes > 0) { + const reservation = budget.reserveTransient(lineBytes, { kind: "live_transient" }); + reservation.commitRetained(); + try { + // The Responses terminal guard may stop consuming immediately after it sees `done`. + const events = processNativeLine(line, state, budget, issuedIds); + yield* events; + } finally { + budget.releaseRetained(lineBytes, { kind: "live_transient" }); + } + } + if (state.terminal) { + return; + } + } + }; + + try { + while (true) { + const read = await readWithAbort(reader, signal); + if (read.done) break; + if (!read.value || read.value.byteLength === 0) continue; + yield* ingest(decoder.decode(read.value, { stream: true })); + if (state.terminal) { + mustCancel = true; + return; + } + } + yield* ingest(decoder.decode()); + if (!state.terminal && buffer.length > 0) { + const rawLine = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + const lineBytes = encoder.encode(rawLine).byteLength; + if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + try { + // The EOF record keeps its residual charge while it is parsed and consumed — releasing it + // first would drop the accounting before the record is translated (and let a near-limit + // record's tool arguments slip past the turn cap that the newline-terminated path pays). + const events = processNativeLine(rawLine, state, budget, issuedIds); + yield* events; + } finally { + replaceLiveBuffer(budget, bufferBytes, 0); + bufferBytes = 0; + } + } + if (!state.terminal) { + mustCancel = true; + const event = malformedNativeEvent( + state.sawMessage || state.toolCalls.size > 0 + ? "Ollama native stream ended before done:true" + : "Ollama native stream ended without a terminal record", + state.usage, + ); + state.terminalError = true; + yield event; + } else { + mustCancel = true; + } + } catch (error) { + mustCancel = true; + let event: AdapterEvent; + if (isTranslatorBudgetExceededError(error)) { + event = translationBudgetEvent(state.usage); + } else if (signal?.aborted) { + event = { type: "error", status: 499, message: "client closed request while reading Ollama native stream" }; + } else { + event = malformedNativeEvent("Ollama native stream could not be decoded", state.usage); + } + state.terminalError = true; + yield event; + } finally { + if (bufferBytes > 0) budget.releaseRetained(bufferBytes, { kind: "live_transient" }); + releaseNativeStateBuffers(state, budget); + if (mustCancel) { + try { await reader.cancel(); } catch { /* the upstream body may already be closed */ } + } + try { reader.releaseLock(); } catch { /* already released */ } + } +} + +async function parseOllamaNativeResponse( + response: Response, + budget: TranslatorBudget, + issuedToolCallIds: Set, + allowParallelToolCalls = true, +): Promise { + const bounded = await readBoundedResponseBytes(response, { maxBytes: TRANSLATOR_MAX_SSE_EVENT_BYTES }); + if (bounded.oversized) return [malformedNativeEvent("Ollama native response exceeded the safe body limit")]; + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes)); + } catch { + return [malformedNativeEvent("Ollama native response was not valid JSON")]; + } + const retainedBytes = bounded.bytes.byteLength; + if (retainedBytes > 0) budget.chargeRetained(retainedBytes, { kind: "retained_collectors" }); + try { + const events = nativeEventsFromResponsePayload(payload, budget, issuedToolCallIds, allowParallelToolCalls); + try { + retainTranslatedEventBatch(events, budget); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) return [translationBudgetEvent()]; + throw error; + } + return events; + } finally { + if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + } +} + +export function createOllamaNativeAdapter(provider: OcxProviderConfig): ProviderAdapter { + let requestAbortSignal: AbortSignal | undefined; + let requestAllowsParallelToolCalls = true; + const issuedToolCallIds = new Set(); + return { + name: "ollama-native", + formatErrorBody: formatNativeErrorBody, + + buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta): AdapterRequest { + requestAbortSignal = incoming?.abortSignal; + requestAllowsParallelToolCalls = parsed.options.parallelToolCalls !== false; + const url = ollamaNativeChatUrl(provider.baseUrl); + const endpointKind = ollamaNativeEndpointKind(provider.baseUrl); + const { headers, hasCredential } = buildHeaders(provider, endpointKind); + const messages = buildNativeMessages(parsed, issuedToolCallIds); + const tools = buildNativeTools(parsed); + const format = nativeFormat(parsed, endpointKind); + const options: Record = {}; + const maxOutputTokens = parsed.options.maxOutputTokens + ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + // Same gate semantics as the openai-chat adapter (modelInList on the provider lists). + // Ollama's native Options carries every one of these under its own spelling. + if (maxOutputTokens !== undefined) options.num_predict = maxOutputTokens; + if (parsed.options.temperature !== undefined + && !modelInList(provider.noTemperatureModels, parsed.modelId)) { + options.temperature = parsed.options.temperature; + } + if (parsed.options.topP !== undefined + && !modelInList(provider.noTopPModels, parsed.modelId)) { + options.top_p = parsed.options.topP; + } + if (parsed.options.stopSequences !== undefined) options.stop = parsed.options.stopSequences; + if (parsed.options.presencePenalty !== undefined + && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + options.presence_penalty = parsed.options.presencePenalty; + } + if (parsed.options.frequencyPenalty !== undefined + && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + options.frequency_penalty = parsed.options.frequencyPenalty; + } + + const think = nativeThink(provider, parsed); + const body: Record = { + model: wireModelId(provider, parsed.modelId), + messages, + stream: parsed.stream, + ...(think !== undefined ? { think } : {}), + ...(tools ? { tools } : {}), + ...(format !== undefined ? { format } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}), + }; + const bodyJson = JSON.stringify(body); + debugProviderDiagnostic("ollama-native", "request", { + host: (() => { try { return new URL(url).host; } catch { return "upstream"; } })(), + model: body.model, + stream: parsed.stream, + messageCount: messages.length, + toolCount: tools?.length ?? 0, + hasCredential, + bodyBytes: new TextEncoder().encode(bodyJson).byteLength, + thinkingRequested: parsed.options.reasoning !== undefined, + }); + return { url, method: "POST", headers, body: bodyJson }; + }, + + parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + return parseOllamaNativeStream( + response, + budget, + requestAbortSignal, + issuedToolCallIds, + requestAllowsParallelToolCalls, + ); + }, + + async parseResponse(response: Response, budget: TranslatorBudget): Promise { + return await parseOllamaNativeResponse( + response, + budget, + issuedToolCallIds, + requestAllowsParallelToolCalls, + ); + }, + }; +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index e9f54bcb8a..81fdbf99a4 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -8,6 +8,7 @@ import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; import { createOpenAIChatAdapter } from "./openai-chat"; +import { createOllamaNativeAdapter } from "./ollama-native"; import { createResponsesPassthroughAdapter } from "./openai-responses"; import type { OcxProviderConfig } from "../types"; import { createAdapterTierMetadata } from "../providers/fastwire"; @@ -21,6 +22,7 @@ export interface AdapterFactoryContext { export type AdapterWire = | "command-code" | "openai-chat" + | "ollama-native" | "anthropic" | "openai-responses" | "google" @@ -62,6 +64,11 @@ export const ADAPTER_REGISTRY = { create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)), }, + "ollama-native": { + wire: "ollama-native", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOllamaNativeAdapter(provider), + }, anthropic: { wire: "anthropic", mutation: "codex-owned", diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5ef9dffa2b..b5190d4162 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -71,6 +71,7 @@ import { type ProviderModelsApiItem, type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -1370,7 +1371,16 @@ async function fetchProviderModelsWithAuth( ); } const url = request.url; - const headers = materializeCapturedHeaders(request, apiKey); + let headers = materializeCapturedHeaders(request, apiKey); + // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery + // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the + // SAME effective credential/header authority. buildModelsRequest's generic tail writes the + // generated Bearer AFTER configured headers, but the native inference adapter applies + // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured + // provider headers here so the whole Ollama request family shares that one authority. + if (ollamaShowEnrichable(name, prov)) { + headers = applyConfiguredHeadersLast(headers, prov.headers); + } const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") ? "vertex-aiplatform" : "provider-models"; @@ -1500,13 +1510,40 @@ async function fetchProviderModelsWithAuth( return observed(models, "degraded"); } const items = extracted.items; + // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata, + // so a newly announced id would otherwise publish generic defaults. /api/show fills that + // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured + // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered + // window only where exact config is absent, and the provider context cap still caps it). + const showEnrichment = ollamaShowEnrichable(name, prov) + ? await fetchOllamaShowEnrichment({ + headers, + discoveryUrl: request.url, + modelIds: items.map(m => m.id), + provider: prov, + }).catch(() => undefined) + : undefined; const live = items.map(m => { const ownedBy = boundedOwnedBy(m.owned_by); + // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the + // models-API row does not carry. applyProviderConfigHints then applies explicit + // configured metadata over both, and the provider context cap still caps the result. + const modelsApiHints = catalogHintsFromModelsApiItem(name, m); + const show = showEnrichment?.metadata.get(m.id); + const discoveredHints = { + ...modelsApiHints, + ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined + ? { contextWindow: show.contextWindow } + : {}), + ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true + ? { inputModalities: ["text", "image"] as string[] } + : {}), + }; return applyProviderConfigHints(name, prov, { id: m.id, provider: name, ...(ownedBy ? { owned_by: ownedBy } : {}), - ...catalogHintsFromModelsApiItem(name, m), + ...discoveredHints, }, contextCap); }) .filter(m => shouldExposeProviderModel(name, m.id)); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 9f9bb4af44..ab82047aa3 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -1,6 +1,11 @@ export const REDACTED_SECRET = "[REDACTED]"; -const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +/** + * Credential-bearing header/field names. Exported for transports that must refuse to send + * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than + * re-deriving a narrower local list. + */ +export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body diff --git a/src/providers/ollama-show.ts b/src/providers/ollama-show.ts new file mode 100644 index 0000000000..ba33b11323 --- /dev/null +++ b/src/providers/ollama-show.ts @@ -0,0 +1,311 @@ +/** + * Bounded Ollama Cloud `/api/show` metadata enrichment. + * + * `/v1/models` is the authoritative live ID roster, but it carries no per-model context or + * capability metadata, so a newly announced Ollama model (e.g. glm-5.3 during its rollout) + * would otherwise be advertised to Codex with generic defaults — a 1M-context model published + * at 128K, and native vision unknown. `/api/show` fills that gap for canonical Ollama Cloud + * destinations only. + * + * The show request reuses the discovery request's already-materialized captured headers + * (credential + configured-header precedence resolved by `buildModelsRequest`, not re-derived + * here) and executes through the same outbound-policy transport as discovery + * (`providerOutboundPost`: destination policy, DNS pinning, manual redirects, caller-owned + * executor). + * + * Failure is per model and fail-soft: any transport, status, redirect, size, parse, or timeout + * failure drops the enrichment for that one model and never touches other rows or the ID roster + * itself. Only evidence-backed fields are extracted; templates, licenses, and tokenizer + * payloads exist only inside the bounded body and are never projected into CatalogModel + * metadata. + */ +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; +import { providerOutboundPost, providerRedirectError } from "../lib/provider-outbound"; + +/** Hard per-response bound: cloud /api/show metadata is small; anything larger is discarded. */ +const SHOW_MAX_RESPONSE_BYTES = 256 * 1024; +/** + * Aggregate deadline for the ENTIRE show-enrichment phase, independent of roster size and of + * the generic discovery row limit. A stalled endpoint must never turn a successful /v1/models + * discovery into a multi-minute catalog stall. + */ +const SHOW_AGGREGATE_DEADLINE_MS = 12_000; +/** Per-request timeout: a single show request never outlives this, deadline or not. */ +const SHOW_REQUEST_TIMEOUT_MS = 8_000; +/** + * Show-specific request cap, independent of the generic 2000-row discovery hard limit. + * Conservative for the current Ollama Cloud roster (~19 ids) while leaving room for growth; + * ids beyond the cap simply stay on the existing safe fallback metadata. + */ +const SHOW_REQUEST_CAP = 48; +/** Concurrent /api/show requests never exceed this, regardless of roster size. */ +const SHOW_MAX_CONCURRENCY = 4; +/** A discovered context window must be a plausible positive integer, not arbitrary data. */ +const SHOW_MAX_CONTEXT_LENGTH = 16 * 1024 * 1024; + +export interface OllamaShowMetadata { + /** Trained context length reported by the model's own architecture metadata. */ + contextWindow?: number; + /** Native vision capability reported by Ollama (`capabilities` includes "vision"). */ + nativeVision?: boolean; +} + +export interface OllamaShowEnrichmentResult { + metadata: Map; + /** /api/show requests issued (bounded by the request cap and the roster). */ + showRequests: number; + /** True when the aggregate deadline stopped the enrichment early. */ + deadlineHit: boolean; +} + +export interface OllamaShowEnrichmentOptions { + /** The already-materialized captured discovery headers (credential + configured precedence). */ + headers: Record; + /** The discovery request URL actually captured for this provider (same origin is used). */ + discoveryUrl: string; + modelIds: readonly string[]; + /** Show-specific request cap, independent of the generic discovery row limit. */ + showRequestCap?: number; + /** Aggregate wall-clock deadline for the whole enrichment phase (injectable for tests). */ + deadlineMs?: number; + /** Per-request timeout (injectable for deterministic tests). */ + requestTimeoutMs?: number; + /** Outbound config for the policy-checked transport (must carry the test executor). */ + provider: { + baseUrl: string; + adapter?: string; + fetch?: typeof fetch; + }; +} + +/** + * Scope gate: enrichment runs ONLY for the canonical Ollama Cloud destination. Custom + * ollama-native providers (self-hosted or renamed rows) and every unrelated provider are + * untouched, so `/api/show` behavior can never widen into a generic provider surface. + */ +export function ollamaShowEnrichable( + providerName: string, + provider: { adapter?: string; baseUrl?: string }, +): boolean { + if (providerName !== "ollama-cloud") return false; + if (provider.adapter !== "ollama-native") return false; + const baseUrl = provider.baseUrl; + if (typeof baseUrl !== "string" || !baseUrl) return false; + return isCanonicalOllamaCloudUrl(baseUrl); +} + +/** + * Extract only evidence-backed catalog metadata from an `/api/show` payload. The input is not + * mutated; templates, licenses, and tokenizer payloads exist only inside the bounded parse and + * are never projected into CatalogModel metadata. + */ +export function ollamaShowMetadataFromPayload(payload: unknown): OllamaShowMetadata | undefined { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const raw = payload as Record; + const modelInfo = raw.model_info; + let contextWindow: number | undefined; + const info = modelInfo !== null && typeof modelInfo === "object" && !Array.isArray(modelInfo) + ? modelInfo as Record + : undefined; + if (info !== undefined) { + // Prefer the context length named by the model's own architecture, then fall back to a + // unique `*.context_length` key only when the architecture spelling is absent or ambiguous. + // The architecture key is FILTERED while collecting fallback candidates — the parsed input is + // never mutated. + const architecture = typeof info["general.architecture"] === "string" + ? (info["general.architecture"] as string) + : undefined; + const architectureKey = architecture !== undefined ? `${architecture}.context_length` : undefined; + if (architectureKey !== undefined) { + const value = info[architectureKey]; + if (isPlausibleContextLength(value)) contextWindow = value; + } + if (contextWindow === undefined) { + const candidates = Object.entries(info) + .filter(([key, value]) => + key.endsWith(".context_length") + && key !== architectureKey + && isPlausibleContextLength(value)) + .map(([, value]) => value as number); + if (candidates.length === 1) contextWindow = candidates[0]; + } + } + + const capabilities = Array.isArray(raw.capabilities) + ? raw.capabilities.filter((c): c is string => typeof c === "string") + : undefined; + const nativeVision = capabilities?.includes("vision") === true; + + if (contextWindow === undefined && capabilities === undefined) return undefined; + return { + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(capabilities !== undefined ? { nativeVision } : {}), + }; +} + +function isPlausibleContextLength(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value > 0 + && value <= SHOW_MAX_CONTEXT_LENGTH; +} + +/** + * Reapply the configured provider headers LAST over an already-materialized header map, + * case-insensitively: a configured Authorization/authorization replaces the generated Bearer + * (exactly one effective credential spelling survives), matching the native /api/chat adapter's + * precedence (generated Bearer first, provider.headers last). Non-credential configured headers + * are likewise reapplied so an explicit operator spelling wins. + */ +export function applyConfiguredHeadersLast( + headers: Record, + providerHeaders: Record | undefined, +): Record { + const out: Record = { ...headers }; + for (const [key, value] of Object.entries(providerHeaders ?? {})) { + const lower = key.toLowerCase(); + for (const existing of Object.keys(out)) { + if (existing.toLowerCase() === lower && existing !== key) delete out[existing]; + } + out[key] = value; + } + return out; +} + +/** + * Show headers for the JSON POST: force Content-Type case-insensitively (the endpoint has a + * JSON body), leave every other captured header — including configured Authorization/auth + * spellings — exactly as the materialized discovery request produced them. + */ +export function showHeadersFromCaptured( + capturedHeaders: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(capturedHeaders)) { + if (key.toLowerCase() === "content-type") continue; + out[key] = value; + } + out["Content-Type"] = "application/json"; + return out; +} + +/** + * Enrich discovered Ollama Cloud ids through `POST /api/show`, executed through the same + * outbound-policy transport as discovery (`providerOutboundPost`) with the already-materialized + * captured headers — the show request never manufactures its own auth contract. + * + * Fail-soft per model: transport errors, non-2xx responses, redirects (never followed, so the + * credential can never reach another origin), oversized payloads, malformed data, and deadline + * aborts each skip that model's enrichment without affecting other rows or the success of + * discovery itself. The aggregate deadline stops new launches and aborts active work; partial + * results are returned and unenriched ids stay on the existing safe fallback metadata. + */ +export async function fetchOllamaShowEnrichment( + options: OllamaShowEnrichmentOptions, +): Promise { + const { + headers: capturedHeaders, + discoveryUrl, + modelIds, + showRequestCap = SHOW_REQUEST_CAP, + deadlineMs = SHOW_AGGREGATE_DEADLINE_MS, + requestTimeoutMs = SHOW_REQUEST_TIMEOUT_MS, + provider, + } = options; + // Late-worker isolation: workers that complete or throw after the phase returns mutate ONLY + // the internal map; the returned metadata is the EXACT snapshot the phase promise resolved + // with — never a re-snapshot of the mutable map after resolution. + const metadata = new Map(); + + // Same origin as the materialized discovery request, so /api/show can never point at another + // destination than the one the credential was already materialized for. + const showUrl = new URL("/api/show", new URL(discoveryUrl).origin).toString(); + const showHeaders = showHeadersFromCaptured(capturedHeaders); + + const deadlineAbort = new AbortController(); + const requestSignal = () => AbortSignal.any([ + AbortSignal.timeout(requestTimeoutMs), + deadlineAbort.signal, + ]); + + const ids = modelIds.slice(0, Math.min(modelIds.length, showRequestCap)); + let cursor = 0; + let active = 0; + let showRequests = 0; + let deadlineHit = false; + let settled = false; + let deadlineTimer: ReturnType | undefined; + let resolvePhase: ((snapshot: Map) => void) | undefined; + + // The phase-finishing path: stops launches (settled guard), takes the metadata snapshot at + // this exact moment, and resolves the phase promise with it. The caller returns THAT resolved + // snapshot, so late worker settlement can never change the returned metadata. + const finish = (deadline: boolean): void => { + if (settled) return; + settled = true; + deadlineHit = deadline; + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + const snapshot = new Map(metadata); + resolvePhase?.(snapshot); + }; + + const result = await new Promise((resolve) => { + resolvePhase = (snapshot) => resolve({ + metadata: snapshot, + showRequests, + deadlineHit, + }); + + // The aggregate deadline TIMER ITSELF is the return bound: it prevents further launches + // (settled guard in pump), aborts active workers, and resolves the phase IMMEDIATELY. It + // never relies on a worker settling, its finally block, the per-request timeout, or another + // pump() call. Declared after finish so the callback has no TDZ reference. + deadlineTimer = setTimeout(() => { + if (settled) return; + deadlineAbort.abort(new DOMException("ollama /api/show aggregate deadline", "TimeoutError")); + finish(true); + }, deadlineMs); + + const pump = () => { + if (settled) return; + while (active < SHOW_MAX_CONCURRENCY && cursor < ids.length) { + const id = ids[cursor++]; + active += 1; + showRequests += 1; + void (async () => { + try { + const res = await providerOutboundPost("ollama-cloud", provider, showUrl, { + headers: showHeaders, + body: JSON.stringify({ model: id }), + signal: requestSignal(), + }); + const redirectError = await providerRedirectError(res, showUrl); + // Redirect handling: never follow — the credential must never reach another origin. + // A redirected or non-2xx show response is a per-model failure, not a retry. + if (redirectError || !res.ok || ![200, 201].includes(res.status)) return; + const bounded = await readBoundedResponseBytes(res, { maxBytes: SHOW_MAX_RESPONSE_BYTES }); + if (bounded.oversized) return; + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes)); + } catch { + return; + } + const parsed = ollamaShowMetadataFromPayload(payload); + if (parsed) metadata.set(id, parsed); + } catch { + // Fail-soft: this model simply stays unenriched. Late settlement after the phase has + // returned only touches the internal map — the caller holds the exact snapshot. + } finally { + active -= 1; + pump(); + } + })(); + } + if (cursor >= ids.length && active === 0) finish(false); + }; + pump(); + }); + return result; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a8cca87828..ad4f809448 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2554,13 +2554,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "ollama-cloud", label: "Ollama Cloud", + // The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it + // to /api/chat, and live model discovery declares its own /v1/models path against the origin, + // so the native transport needs no base-URL edit here or in the free-provider directory. baseUrl: "https://ollama.com/v1", - adapter: "openai-chat", + // The native transport must be declared HERE, not in configuration. routedProviderConfig() + // overwrites provider.adapter with the registry adapter for every row whose transport + // matches, so a config-level adapter is silently discarded. + adapter: "ollama-native", authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.3", + // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have + // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep + // their existing precedence; these values prevent a failed show from becoming generic. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 }, noVisionModels: [ // glm-5.3-flash is absent on purpose: native VLM // (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar. @@ -2570,6 +2580,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "deepseek-v4-pro", "deepseek-v4-flash", "gpt-oss", "qwen3-coder:480b", ], + // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter + // never emits one, so a routed row must not inherit the Codex template's verbosity picker. + // Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that + // arrive later from live discovery must opt out too (the live-discovery gap closed by #2578). + supportsVerbosity: false, + // Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models, + // so the generic discovery pipeline needs no special-casing. The path is spelled against the + // ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery + // spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which + // 307-redirects to /search and discovery falls back to the configured list. + modelDiscovery: { + path: "/v1/models", + }, }, // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, diff --git a/tests/adapter-buffered-tool-conformance.test.ts b/tests/adapter-buffered-tool-conformance.test.ts index 6c2f11b928..3472afdd67 100644 --- a/tests/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapter-buffered-tool-conformance.test.ts @@ -16,6 +16,7 @@ const PATCH = `*** Begin Patch const WIRE_MODELS: Record = { "openai-chat": "grok-4.6", + "ollama-native": "glm-5.3-flash", anthropic: "claude-haiku-4-5", google: "gemini-3.5-flash", "command-code": "deepseek/deepseek-v4-flash", @@ -27,6 +28,7 @@ const WIRE_MODELS: Record = { function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { const baseUrls: Record = { "openai-chat": "https://api.x.ai/v1", + "ollama-native": "https://ollama.com/v1", anthropic: "https://api.anthropic.com", google: "https://generativelanguage.googleapis.com", "command-code": "https://api.commandcode.ai", @@ -87,6 +89,22 @@ function bufferedResponse(wire: AdapterWire, wireName = "apply_patch"): Response usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, })); } + if (wire === "ollama-native") { + // Ollama's native /api/chat buffered envelope: one message object, arguments as a JSON + // object rather than the OpenAI-style encoded string, and `done` instead of finish_reason. + return new Response(JSON.stringify({ + model: "glm-5.3-flash", + message: { + role: "assistant", + content: "", + tool_calls: [{ type: "function", id: "call_buffered_patch", function: { name: wireName, arguments: args } }], + }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + })); + } if (wire === "anthropic") { return new Response(JSON.stringify({ content: [{ type: "tool_use", id: "call_buffered_patch", name: wireName, input: args }], diff --git a/tests/adapter-registry-authority.test.ts b/tests/adapter-registry-authority.test.ts index fa10f27984..1bbfc28c40 100644 --- a/tests/adapter-registry-authority.test.ts +++ b/tests/adapter-registry-authority.test.ts @@ -12,6 +12,7 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { "command-code": "command-code", "openai-chat": "openai-chat", + "ollama-native": "ollama-native", anthropic: "anthropic", "openai-responses": "openai-responses", google: "google", @@ -29,7 +30,11 @@ function provider(adapter: string): OcxProviderConfig { // adapter accepts the placeholder URL. baseUrl: adapter === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" - : "https://example.invalid/v1", + // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than + // guessing that an arbitrary destination speaks Ollama's compatibility surface. + : adapter === "ollama-native" + ? "https://example.invalid/api" + : "https://example.invalid/v1", authMode: "key", apiKey: "test-key", defaultMaxOutputTokens: 4096, diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index f70374ee47..a3a6ae2800 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -27,6 +27,7 @@ const EXEC_DESCRIPTION = const WIRE_MODELS: Record = { "openai-chat": "grok-4.6", + "ollama-native": "glm-5.3-flash", anthropic: "claude-haiku-4-5", google: "gemini-3.5-flash", "command-code": "deepseek/deepseek-v4-flash", @@ -38,6 +39,7 @@ const WIRE_MODELS: Record = { function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { const baseUrls: Record = { "openai-chat": "https://api.x.ai/v1", + "ollama-native": "https://ollama.com/v1", anthropic: "https://api.anthropic.com", google: "https://generativelanguage.googleapis.com", "command-code": "https://api.commandcode.ai", @@ -212,7 +214,8 @@ async function outbound(adapterId: string, parsed: OcxParsedRequest): Promise; - if (wire === "openai-chat") { + if (wire === "openai-chat" || wire === "ollama-native") { + // Ollama's native /api/chat declares tools with the same {type,function:{name}} shape. const tools = parsed.tools as Array<{ function?: { name?: string } }> | undefined; return (tools ?? []).flatMap(tool => typeof tool.function?.name === "string" ? [tool.function.name] : []); } @@ -477,6 +480,14 @@ describe("registry-derived routed tool conformance", () => { await expect(outbound(adapterId, parsed)).rejects.toThrow("Kiro supports only automatic tool choice or tool_choice:none"); continue; } + if (contract.wire === "ollama-native") { + // Ollama's native chat API has no tool_choice field, so a "required" selector cannot be + // enforced on the wire. The adapter refuses rather than advertising an unenforced choice. + await expect(outbound(adapterId, parsed)).rejects.toThrow( + "ollama-native does not support required or exact named tool_choice", + ); + continue; + } const body = await outbound(adapterId, parsed); expect(advertisedToolNames(contract.wire, body), adapterId).toHaveLength(0); } diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts index 3abc7855eb..979d1d4780 100644 --- a/tests/helpers/adapter-conformance/wire-drivers.ts +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -162,6 +162,44 @@ const openAiChatDriver: ToolWireDriver = { streamingToolCall: openAiChatToolCall, }; +const ollamaNativeDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { tools?: Array<{ function?: { name?: string } }> }; + const match = parsed.tools?.find(tool => tool.function?.name?.includes(canonicalName))?.function?.name; + return requireWireToolName(match, canonicalName, "ollama-native"); + }, + streamingToolCall(wireName, wrappedArguments) { + // Ollama streams NDJSON, not SSE, and delivers each tool call whole: `arguments` is a JSON + // object rather than a string fragmented across frames, so there is nothing to split here. + const frames = [ + { + model: "glm-5.3-flash", + message: { + role: "assistant", + content: "", + tool_calls: [{ + type: "function", + id: "call_patch", + function: { name: wireName, arguments: JSON.parse(wrappedArguments) as Record }, + }], + }, + done: false, + }, + { + model: "glm-5.3-flash", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + }, + ]; + const ndjson = frames.map(frame => `${JSON.stringify(frame)}\n`).join(""); + return new Response(ndjson, { headers: { "content-type": "application/x-ndjson" } }); + }, +}; + const anthropicDriver: ToolWireDriver = { observeOutbound: observeHttpOutbound, extractWireToolName(body, canonicalName) { @@ -229,6 +267,7 @@ const responsesDriver: ToolWireDriver = { export const TOOL_WIRE_DRIVERS = { "openai-chat": openAiChatDriver, + "ollama-native": ollamaNativeDriver, anthropic: anthropicDriver, google: googleDriver, "command-code": commandCodeDriver, diff --git a/tests/ollama-native-parser.test.ts b/tests/ollama-native-parser.test.ts new file mode 100644 index 0000000000..589d84e3fc --- /dev/null +++ b/tests/ollama-native-parser.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { ollamaNativeChatUrl } from "../src/adapters/ollama-native-url"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { AdapterEvent } from "../src/types"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +/** + * Parser/request contract tests for the native Ollama transport. + * Structural-receipt tooling deliberately does not exist in production code; these tests use the + * public adapter surface (buildRequest / parseStream / parseResponse) and plain fixtures only. + */ + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith( + messages: unknown[], + options: Record = {}, + modelId = "glm-5.3-flash", +): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages } } as unknown as OcxParsedRequest; +} + +function ndjsonResponse(frames: unknown[]): Response { + const body = frames.map(frame => `${JSON.stringify(frame)}\n`).join(""); + return new Response(body, { headers: { "content-type": "application/x-ndjson" } }); +} + +function frame(message: Record, done: boolean, extra: Record = {}): Record { + return { model: "glm-5.3-flash", message, done, ...extra }; +} + +async function collect(adapter: ReturnType, response: Response): Promise { + const budget = createTestTranslatorBudget(); + const out: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) out.push(event); + return out; +} + +describe("ollama-native — observer-free streaming", () => { + test("no structural-receipt machinery exists in the module surface", async () => { + const mod = await import("../src/adapters/ollama-native") as unknown as Record; + for (const name of [ + "setOllamaNativeObservationSink", + "createOllamaNativeObservationBuffer", + ]) { + expect(mod[name], name).toBeUndefined(); + } + }); + + test("a ~30 MiB valid line split across reads is delivered intact", async () => { + // The safety bound applies to the assembled RECORD, not to read boundaries. The old + // accounting committed old + replacement together, so growth steps double-charged. + const line = "x".repeat(30 * 1024 * 1024); // 30 MiB: under the 32 MiB record ceiling... + // First read: 18 MiB of the giant line (incomplete), second: the remaining ~12 MiB + newline. + // Under old+replacement charging this transiently holds 18 + 30 = 48 MiB against the 32 MiB + // turn cap and the turn dies, even though the finished record is perfectly valid. + const giantLine = `${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: true, done_reason: "stop" })}\n`; + const read1 = giantLine.slice(0, 18 * 1024 * 1024); + const read2 = giantLine.slice(18 * 1024 * 1024); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(read1)); + controller.enqueue(new TextEncoder().encode(read2)); + controller.close(); + }, + }); + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + const text = events.filter(e => e.type === "text_delta").reduce((s, e) => s + (e as { text: string }).text.length, 0); + expect(text).toBe(line.length); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("one >32 MiB read carrying individually-valid smaller records is accepted", async () => { + // Nine complete 4 MiB records arrive in ONE transport read of 36 MiB. The old accounting + // reserved the whole READ before splitting it and rejected at 36 MiB even though every record + // was individually valid. The record is the safety unit, not the read. + const line = "y".repeat(4 * 1024 * 1024); + const frames: string[] = []; + for (let i = 0; i < 8; i++) { + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: false })} +`); + } + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop" })} +`); + const oneRead = frames.join(""); + expect(new TextEncoder().encode(oneRead).byteLength).toBeGreaterThan(32 * 1024 * 1024); + const body = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(oneRead)); controller.close(); }, + }); + + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + const deltas = events.filter(e => e.type === "text_delta"); + expect(deltas).toHaveLength(8); + expect(deltas.reduce((s, e) => s + (e as { text: string }).text.length, 0)).toBe(8 * line.length); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a single NDJSON record over the ceiling fails with the translation buffer limit", async () => { + // 33 MiB in ONE record: the record itself exceeds the 32 MiB ceiling and must fail closed. + const line = "z".repeat(33 * 1024 * 1024); + const response = new Response( + `${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: true })}\n`, + { headers: { "content-type": "application/x-ndjson" } }, + ); + const adapter = createOllamaNativeAdapter(provider()); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, createTestTranslatorBudget())) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + }); + + test("retained high-water tracks the in-flight record/residual, not read size", async () => { + // Same single 32 MiB read as above: the in-flight unit is one ~4 MiB record plus a near-zero + // residual, so the high-water mark must stay near one record. The removed implementation + // committed the whole read (≈32 MiB) before splitting it, and its release-after-reserve + // ordering transiently double-charged growth steps. + const line = "w".repeat(4 * 1024 * 1024); + const frames: string[] = []; + for (let i = 0; i < 8; i++) { + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: false })} +`); + } + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop" })} +`); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frames.join(""))); + controller.close(); + }, + }); + + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + expect(events.at(-1)?.type).toBe("done"); + const snapshot = budget.snapshot(); + expect(snapshot.highWaterBytes).toBeLessThan(line.length * 2); + }); + + test("thinking deltas stream interleaved with content and a terminal", async () => { + const frames = [ + { model: "m", message: { role: "assistant", thinking: "step one" }, done: false }, + { model: "m", message: { role: "assistant", content: "hello" }, done: false }, + { model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop", eval_count: 7 }, + ]; + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.map(e => e.type)).toEqual(["reasoning_raw_delta", "text_delta", "done"]); + expect(events[0]).toMatchObject({ type: "reasoning_raw_delta", text: "step one" }); + expect(events[1]).toMatchObject({ type: "text_delta", text: "hello" }); + expect(events[2]).toMatchObject({ type: "done", stopReason: "stop", usage: { outputTokens: 7 } }); + }); + + test("usage and done_reason map onto the terminal event", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [ + { model: "m", message: { role: "assistant", content: "hi" }, done: true, done_reason: "length", prompt_eval_count: 11, eval_count: 5 }, + ]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + const done = events.at(-1); + expect(done).toMatchObject({ + type: "done", + stopReason: "max_tokens", + usage: { inputTokens: 11, outputTokens: 5 }, + }); + }); + + test("malformed NDJSON fails closed with a parser error, never a done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const response = new Response("{not json\n", { headers: { "content-type": "application/x-ndjson" } }); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + }); + + test("a native error record is sanitized and terminates without done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + // "Bearer " is a recognized secret shape for redaction (8+ chars) while + // staying below the privacy scanner's 24-char bearer rule, so the fixture stays scannable. + const frames = [{ error: { message: "boom for Bearer abcdef12345678" } }]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", errorType: "upstream_error" }); + expect(JSON.stringify(events[0])).not.toContain("abcdef12345678"); + expect(JSON.stringify(events[0])).toContain("[REDACTED]"); + }); + + test("stream that ends without done:true is an error, not a done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [{ model: "m", message: { role: "assistant", content: "partial" }, done: false }]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); + +describe("ollama-native — buffered terminal contract", () => { + test("buffered done:true produces content + done with usage", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ + model: "m", + message: { role: "assistant", content: "answer" }, + done: true, + done_reason: "stop", + prompt_eval_count: 3, + eval_count: 4, + }]), + createTestTranslatorBudget(), + ); + expect(events.map(e => e.type)).toEqual(["text_delta", "done"]); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop" }); + }); + + test("buffered done:false is incomplete: never a downstream done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "half" }, done: false }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("done:false"); + expect(events.some(e => e.type === "done")).toBe(false); + // The complete payload is already known invalid, so its partial text is suppressed too. + expect(events.some(e => e.type === "text_delta")).toBe(false); + expect(events).toHaveLength(1); + }); + + test("buffered tool calls are suppressed unless done:true", async () => { + const call = { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [{ index: 0, type: "function", id: "c0", function: { name: "ns_x__f", arguments: { p: 1 } } }], + }, + }; + const adapter = createOllamaNativeAdapter(provider()); + // done:true -> the tool call is emitted normally. + const ok = await adapter.parseResponse!( + ndjsonResponse([{ ...call, done: true, done_reason: "stop" }]), createTestTranslatorBudget()); + expect(ok.filter(e => e.type === "tool_call_start")).toHaveLength(1); + expect(ok.at(-1)?.type).toBe("done"); + + // done:false / missing / malformed -> error only: no tool_call events may execute. + for (const envelope of [{ done: false }, {}, { done: "yes" }]) { + const events = await adapter.parseResponse!( + ndjsonResponse([{ ...call, ...envelope }]), createTestTranslatorBudget()); + expect(events.filter(e => e.type === "tool_call_start"), JSON.stringify(envelope)).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + } + }); + + test("buffered missing done is incomplete", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "half" } }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("did not include done:true"); + }); + + test("buffered malformed done is malformed", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "x" }, done: "yes" }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("done flag was not boolean"); + }); + + test("buffered invalid JSON and non-object payloads fail closed", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const badJson = await adapter.parseResponse!(new Response("[1,2"), budget); + expect(badJson[0].type).toBe("error"); + expect((badJson[0] as { message?: string }).message).toContain("not valid JSON"); + const notObject = await adapter.parseResponse!(ndjsonResponse([[1, 2]]), budget); + expect(notObject[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + }); +}); + +describe("ollama-native — tool calls", () => { + test("indexed tool calls preserve order and identity", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [ + { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [ + { index: 0, type: "function", id: "c0", function: { name: "ns_one__alpha", arguments: { a: 1 } } }, + { index: 1, type: "function", id: "c1", function: { name: "ns_two__beta", arguments: { b: 2 } } }, + ], + }, + done: true, + done_reason: "stop", + }, + ]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + const starts = events.filter(e => e.type === "tool_call_start") as Array<{ type: "tool_call_start"; id: string; name: string }>; + expect(starts.map(s => s.id)).toEqual(["c0", "c1"]); + expect(starts.map(s => s.name)).toEqual(["ns_one__alpha", "ns_two__beta"]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("parallelToolCalls:false rejects a second provider tool call", async () => { + const frames = [ + { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [ + { index: 0, type: "function", function: { name: "ns_a__t", arguments: {} } }, + { index: 1, type: "function", function: { name: "ns_b__u", arguments: {} } }, + ], + }, + done: true, + done_reason: "stop", + }, + ]; + // buildRequest latches the request-level parallel flag into the adapter closure; parseStream + // then enforces it against the wire. Same instance, sequential calls — exactly the runtime path. + const strict = createOllamaNativeAdapter(provider()); + strict.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const events: AdapterEvent[] = []; + for await (const event of strict.parseStream!(ndjsonResponse(frames), createTestTranslatorBudget())) { + events.push(event); + } + expect(events.some(e => e.type === "tool_call_start")).toBe(false); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("parallel tool calls"); + + // The default (parallel allowed) still forwards both calls. + const permissive = createOllamaNativeAdapter(provider()); + permissive.buildRequest(parsedWith([{ role: "user", content: "go" }])); + const both: AdapterEvent[] = []; + for await (const event of permissive.parseStream!(ndjsonResponse(frames), createTestTranslatorBudget())) { + both.push(event); + } + expect(both.filter(e => e.type === "tool_call_start")).toHaveLength(2); + }); + + test("tool-result replay pairs a toolResult message with its call id", () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = adapter.buildRequest(parsedWith([ + { role: "user", content: "run it" }, + { + role: "assistant", + timestamp: 1, + content: [{ type: "toolCall", id: "c0", name: "f", namespace: "ns", arguments: { p: 1 } }], + }, + { + role: "toolResult", + toolCallId: "c0", + toolName: "f", + toolNamespace: "ns", + content: "result-text", + isError: false, + }, + ] as never)); + const body = JSON.parse(String(built.body)); + const assistant = body.messages.at(-2); + const replayed = body.messages.at(-1); + expect(assistant.role).toBe("assistant"); + expect(assistant.tool_calls[0].id).toBe("c0"); + expect(assistant.tool_calls[0].function.name).toBe("ns__f"); + expect(replayed.role).toBe("tool"); + expect(replayed.tool_call_id).toBe("c0"); + expect(replayed.content).toContain("result-text"); + }); +}); + +describe("ollama-native — request control parity", () => { + test("presence/frequency penalties map onto native Options", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = await adapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { presencePenalty: 0.25, frequencyPenalty: -0.5 }), + ); + expect(ollamaNativeChatUrl(provider().baseUrl as string)).toBe(built.url); + const options = JSON.parse(String(built.body)).options; + expect(options.presence_penalty).toBe(0.25); + expect(options.frequency_penalty).toBe(-0.5); + }); + + test("noPenaltyModels suppresses both penalties; noTemperature/noTopP suppress their own", async () => { + const gated = provider({ + noPenaltyModels: ["glm-5.3-flash"], + noTemperatureModels: ["glm-5.3-flash"], + noTopPModels: ["glm-5.3-flash"], + }); + const adapter = createOllamaNativeAdapter(gated); + const built = await adapter.buildRequest(parsedWith( + [{ role: "user", content: "hi" }], + { presencePenalty: 1, frequencyPenalty: 1, temperature: 0.7, topP: 0.9 }, + )); + const options = JSON.parse(String(built.body)).options; + expect(options).not.toHaveProperty("presence_penalty"); + expect(options).not.toHaveProperty("frequency_penalty"); + expect(options).not.toHaveProperty("temperature"); + expect(options).not.toHaveProperty("top_p"); + + // The gates are per model, not per provider: a non-listed id keeps its controls. + const otherAdapter = createOllamaNativeAdapter(provider({ + noPenaltyModels: ["some-other-model"], + })); + const ok = await otherAdapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { presencePenalty: 0.5 }, "glm-5.3-flash"), + ); + expect(JSON.parse(String(ok.body)).options.presence_penalty).toBe(0.5); + }); + + test("num_predict, temperature, top_p and stop map as before", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = await adapter.buildRequest(parsedWith( + [{ role: "user", content: "hi" }], + { maxOutputTokens: 128, temperature: 0.2, topP: 0.9, stopSequences: ["END"] }, + )); + const options = JSON.parse(String(built.body)).options; + expect(options).toMatchObject({ num_predict: 128, temperature: 0.2, top_p: 0.9, stop: ["END"] }); + }); +}); + +describe("ollama-native — transport security", () => { + function headered(headers: Record, overrides: Partial = {}): OcxProviderConfig { + return provider({ headers, ...overrides }); + } + + test("remote http with an apiKey is refused", () => { + expect(() => createOllamaNativeAdapter( + provider({ baseUrl: "http://api.example.test", apiKey: "k" }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/plaintext non-loopback HTTP/); + }); + + test("remote http with an Authorization header is refused even without an apiKey", () => { + expect(() => createOllamaNativeAdapter( + headered({ Authorization: "Bearer x" }, { baseUrl: "http://api.example.test", apiKey: undefined }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/credential headers: Authorization/); + }); + + test("remote http with x-api-key and api-key headers is refused", () => { + for (const name of ["x-api-key", "api-key"]) { + expect(() => createOllamaNativeAdapter( + headered({ [name]: "v" }, { baseUrl: "http://api.example.test", apiKey: undefined }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/plaintext non-loopback HTTP/); + } + }); + + test("loopback targets never receive credential headers, even from a copied provider row", () => { + const adapter = createOllamaNativeAdapter(headered( + { Authorization: "Bearer x", "x-api-key": "v", "api-key": "v2", "X-Custom": "keep" }, + { baseUrl: "http://127.0.0.1:11434", apiKey: "local-should-not-leak" }, + )); + const built = adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); + const headers = built.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers["x-api-key"]).toBeUndefined(); + expect(headers["api-key"]).toBeUndefined(); + expect(headers["X-Custom"]).toBe("keep"); + expect(JSON.stringify(headers)).not.toContain("Bearer"); + }); + + test("https header precedence matches openAIChatTransport: configured Authorization wins", () => { + // apiKey only -> generated Bearer. + const keyOnly = createOllamaNativeAdapter(provider({ baseUrl: "https://api.example.test", apiKey: "k" })); + expect((keyOnly.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer k"); + + // apiKey + configured Authorization -> the CONFIGURED header wins (openai-chat applies + // provider.headers after the generated Bearer; V2 had this reversed). + const both = createOllamaNativeAdapter( + headered({ Authorization: "Bearer configured" }, { baseUrl: "https://api.example.test", apiKey: "k" }), + ); + expect((both.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer configured"); + + // apiKey + configured LOWERCASE authorization -> exactly one effective authorization header, + // with the configured value. + const lower = createOllamaNativeAdapter( + headered({ authorization: "Bearer configured-lower" }, { baseUrl: "https://api.example.test", apiKey: "k" }), + ); + const lowerHeaders = lower.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record; + const authKeys = Object.keys(lowerHeaders).filter(name => name.toLowerCase() === "authorization"); + expect(authKeys).toEqual(["authorization"]); + expect(lowerHeaders.authorization).toBe("Bearer configured-lower"); + + // Header-only auth with keyOptional stays supported on a secure channel. + const headerOnly = createOllamaNativeAdapter( + headered({ Authorization: "Bearer configured" }, { baseUrl: "https://api.example.test", apiKey: undefined, keyOptional: true }), + ); + expect((headerOnly.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer configured"); + }); +}); diff --git a/tests/ollama-native-reasoning-wire.test.ts b/tests/ollama-native-reasoning-wire.test.ts new file mode 100644 index 0000000000..fc84e37cf3 --- /dev/null +++ b/tests/ollama-native-reasoning-wire.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect } from "../src/codex/catalog"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { REASONING_EFFORT_OMIT_SENTINEL } from "../src/reasoning-effort"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +/** + * The wire-level reasoning invariant. + * + * Upstream DELIBERATELY advertises synthetic max/ultra rungs on reasoning-capable routed rows: + * Codex and subagent spawns validate requested efforts against catalog membership, so a missing + * top rung hard-fails spawn_agent effort overrides. The wire stays honest because the native + * adapter clamps the requested effort onto the provider's real supported ladder. These tests pin + * the WIRE behavior (what actually reaches /api/chat), not the catalog shape. + */ +function provider(modelReasoningEfforts: Record): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["deepseek-v4-flash:0731"], + modelReasoningEfforts: modelReasoningEfforts, + } as OcxProviderConfig; +} + +function parsedWith(options: Record, modelId = "deepseek-v4-flash:0731"): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +describe("ollama-native — reasoning wire clamp (catalog universality preserved)", () => { + test("RED/GREEN: synthetic catalog rungs do NOT leak unsupported think values onto the wire", async () => { + // Real provider ladder is only [low, medium, high]. The catalog advertises the synthetic + // max/ultra rungs (upstream requirement); a max or ultra request must serialize the CLAMPED + // supported value, never an unsupported one. + const adapter = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + for (const requested of ["max", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + const think = JSON.parse(String(body)).think; + expect(think, `requested=${requested}`).toBe("high"); + } + // In-ladder values pass through unchanged. + for (const [requested, expected] of [["low", "low"], ["medium", "medium"], ["high", "high"]] as const) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe(expected); + } + // And the catalog really does advertise the synthetic rungs this clamp exists for. + const models = await gatherRoutedModels({ providers: { "ollama-cloud": provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }) } } as never); + const entries = buildCatalogEntries(null, [], models); + const levels = ((entries.find(e => e.slug === "ollama-cloud/deepseek-v4-flash:0731")?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>).map(l => l.effort); + expect(levels).toEqual(["low", "medium", "high", "max", "ultra"]); + }); + + test("an explicit __omit__ mapping leaves the reasoning field OFF the wire", async () => { + const adapter = createOllamaNativeAdapter(provider({ + "deepseek-v4-flash:0731": ["low", "medium", "high"], + })); + // Drive the omit sentinel through a provider reasoning map: max -> __omit__. + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { max: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "max" })); + const parsed = JSON.parse(String(body)); + expect(parsed).not.toHaveProperty("think"); + void adapter; + }); + + test("a low-effort request on an omit-mapped model stays omitted", async () => { + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { low: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "low" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); +}); + +describe("ollama — post-clamp __omit__ sentinel (V9)", () => { + test("RED (V8 semantics) / GREEN: wireMap.high=__omit__ + requested max omits the field, never think:max", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { high: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "max" })); + // mapReasoningEffort clamps max -> high; the wire rung's __omit__ mapping is authoritative. + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("GREEN: ultra with max->high clamp still serializes high (boundary-first preserved)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { ultra: REASONING_EFFORT_OMIT_SENTINEL, max: "high" } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "ultra" })); + expect(JSON.parse(String(body)).think).toBe("high"); + }); + + test("GREEN: none -> __omit__ omits; none without a mapping still serializes think:false", async () => { + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { none: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + + const plain = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + const plainBuilt = await plain.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(plainBuilt.body)).think).toBe(false); + }); + + test("GREEN: the clamp itself is unchanged (max/ultra -> high with no omit mapping)", async () => { + const adapter = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + for (const requested of ["max", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe("high"); + } + }); +}); diff --git a/tests/ollama-native-structured-output.test.ts b/tests/ollama-native-structured-output.test.ts new file mode 100644 index 0000000000..4e2bcfa001 --- /dev/null +++ b/tests/ollama-native-structured-output.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +/** + * Structured output is a capability boundary, not a formatting preference. + * + * Ollama documents that "Ollama's Cloud currently does not support structured outputs" + * (ollama/ollama docs/capabilities/structured-outputs.mdx). Cloud does not reject the `format` + * field — it answers 200 and ignores it — so forwarding the field would hand the caller + * unconstrained prose while its request said the answer would be schema-valid. The adapter + * refuses the contract instead, the same call Kiro makes for a wire that cannot enforce it. + * + * Local and custom self-hosted Ollama honour `format`, so they keep mapping it. + */ + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +const LOCAL = { baseUrl: "http://localhost:11434/v1", authMode: "local", apiKey: undefined } as Partial; +const CUSTOM = { baseUrl: "https://ollama.internal.example/api", authMode: "key", apiKey: "test-key-not-a-real-credential" } as Partial; + +const SCHEMA = { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], +} as Record; + +function parsedWith(options: Record = {}, modelId = "glm-5.3-flash"): OcxParsedRequest { + return { + modelId, + stream: true, + options, + context: { messages: [{ role: "user", content: "hi" }] }, + } as unknown as OcxParsedRequest; +} + +const JSON_OBJECT = { textFormat: { type: "json_object" } }; +const JSON_SCHEMA = { textFormat: { type: "json_schema", name: "answer", schema: SCHEMA } }; + +describe("ollama-native — structured output is refused on canonical Ollama Cloud", () => { + test("canonical Cloud + json_object fails closed", () => { + expect(() => createOllamaNativeAdapter(provider()).buildRequest(parsedWith(JSON_OBJECT))) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + }); + + test("canonical Cloud + json_schema fails closed", () => { + expect(() => createOllamaNativeAdapter(provider()).buildRequest(parsedWith(JSON_SCHEMA))) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + }); + + test("every accepted canonical Cloud base-URL spelling refuses it, not just the stored /v1 form", () => { + for (const baseUrl of ["https://ollama.com", "https://ollama.com/v1", "https://ollama.com/api", "https://ollama.com/api/chat", "https://ollama.com./api"]) { + expect(() => createOllamaNativeAdapter(provider({ baseUrl })).buildRequest(parsedWith(JSON_SCHEMA)), baseUrl) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + } + }); + + test("www Ollama spelling cannot bypass the Cloud structured-output boundary", () => { + expect(() => createOllamaNativeAdapter(provider({ baseUrl: "https://www.ollama.com/v1" })) + .buildRequest(parsedWith(JSON_SCHEMA))) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + }); + + test("CONTROL: ordinary Cloud prose is completely unaffected", () => { + const request = createOllamaNativeAdapter(provider()).buildRequest(parsedWith({})); + const body = JSON.parse(request.body as string) as Record; + expect(request.url).toBe("https://ollama.com/api/chat"); + expect(body).not.toHaveProperty("format"); + expect(body.model).toBe("glm-5.3-flash"); + expect(Array.isArray(body.messages)).toBe(true); + }); + + test("CONTROL: unrelated parsed request options do not trip the structured-output guard", () => { + // `textFormat` remains unset; unrelated parsed request options do not mean structured output. + const request = createOllamaNativeAdapter(provider()).buildRequest(parsedWith({ temperature: 0 })); + const body = JSON.parse(request.body as string) as Record; + expect(body).not.toHaveProperty("format"); + expect((body.options as Record).temperature).toBe(0); + }); +}); + +describe("ollama-native — local and custom endpoints keep native structured output", () => { + test("local Ollama serializes json_object as format:\"json\"", () => { + const request = createOllamaNativeAdapter(provider(LOCAL)).buildRequest(parsedWith(JSON_OBJECT)); + const body = JSON.parse(request.body as string) as Record; + expect(request.url).toBe("http://localhost:11434/api/chat"); + expect(body.format).toBe("json"); + }); + + test("local Ollama serializes a json_schema as the schema object itself", () => { + const request = createOllamaNativeAdapter(provider(LOCAL)).buildRequest(parsedWith(JSON_SCHEMA)); + const body = JSON.parse(request.body as string) as Record; + // Ollama's native contract takes the schema directly, not OpenAI's response_format wrapper. + expect(body.format).toEqual(SCHEMA); + expect(body.format).not.toHaveProperty("json_schema"); + }); + + test("custom self-hosted Ollama keeps both native format spellings", () => { + const objectBody = JSON.parse( + createOllamaNativeAdapter(provider(CUSTOM)).buildRequest(parsedWith(JSON_OBJECT)).body as string, + ) as Record; + expect(objectBody.format).toBe("json"); + + const schemaRequest = createOllamaNativeAdapter(provider(CUSTOM)).buildRequest(parsedWith(JSON_SCHEMA)); + const schemaBody = JSON.parse(schemaRequest.body as string) as Record; + expect(schemaRequest.url).toBe("https://ollama.internal.example/api/chat"); + expect(schemaBody.format).toEqual(SCHEMA); + }); + + test("a malformed json_schema still fails on its own terms off Cloud", () => { + // The Cloud guard must not swallow the pre-existing schema-shape validation. + expect(() => createOllamaNativeAdapter(provider(LOCAL)) + .buildRequest(parsedWith({ textFormat: { type: "json_schema", name: "answer" } }))) + .toThrow("ollama-native json_schema output requires a JSON schema object"); + }); +}); diff --git a/tests/ollama-native-v4.test.ts b/tests/ollama-native-v4.test.ts new file mode 100644 index 0000000000..4da76379d0 --- /dev/null +++ b/tests/ollama-native-v4.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import { REASONING_EFFORT_OMIT_SENTINEL } from "../src/reasoning-effort"; +import type { AdapterEvent } from "../src/types"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith(options: Record = {}, modelId = "glm-5.3-flash"): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +/** + * V4 corrections: EOF accounting parity and boundary-first omit semantics. + */ + +describe("ollama-native — EOF vs newline accounting parity", () => { + /** One buffered terminal record: `textSize` content + one tool call with `argSize` of arguments. */ + function record(textSize: number, argSize: number): Record { + return { + model: "m", + message: { + role: "assistant", + content: "r".repeat(textSize), + tool_calls: [{ + index: 0, type: "function", id: "c0", + function: { name: "ns_x__f", arguments: { blob: "a".repeat(argSize) } }, + }], + }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + }; + } + + async function run(rec: unknown, eof: boolean) { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const text = JSON.stringify(rec) + (eof ? "" : "\n"); + const response = new Response(new TextEncoder().encode(text), { + headers: { "content-type": "application/x-ndjson" }, + }); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) events.push(event); + return { events, snapshot: budget.snapshot() }; + } + + test("small terminal record: identical events and identical outcome for both terminators", async () => { + const rec = record(4 * 1024 * 1024, 1024 * 1024); + const nl = await run(rec, false); + const eof = await run(rec, true); + expect(eof.events.map(e => e.type)).toEqual(nl.events.map(e => e.type)); + expect(nl.events.some(e => e.type === "tool_call_start")).toBe(true); + expect(eof.events.at(-1)?.type).toBe("done"); + expect(eof.snapshot.highWaterBytes).toBe(nl.snapshot.highWaterBytes); + }); + + test("non-vacuous near-limit record: aggregate (record + parsed tool args) exceeds the 32 MiB turn cap, while the record itself and each tool argument stay under their individual limits — same budget outcome for both terminators", async () => { + // Margins: content 30 MiB + args 1.5 MiB => line ≈ 31.5 MiB (< 32 MiB record/line ceiling; + // args 1.5 MiB < the 2 MiB per-call tool-argument limit). While the record is retained, the + // parsed tool-argument copy pushes the aggregate translator charge past 32 MiB, so BOTH + // terminators must fail with translation_buffer_limit. If the EOF residual were released + // before tool translation, the args alone (1.5 MiB) would fit and the EOF case would emit + // the tool call — the asymmetry that proves the record stays charged until translated. + const rec = record(30 * 1024 * 1024, 1.5 * 1024 * 1024); + const nl = await run(rec, false); + const eof = await run(rec, true); + for (const label of ["newline", "eof"]) { + const events = label === "newline" ? nl.events : eof.events; + expect(events, label).toHaveLength(1); + expect(events[0], label).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + } + expect(nl.snapshot.highWaterBytes).toBe(eof.snapshot.highWaterBytes); + }); +}); + +describe("ollama-native — omit sentinel under the ultra boundary", () => { + test("ultra→__omit__ with max→high must CLAMP, not omit (boundary-first)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + models: ["glm-5.3-flash"], + modelReasoningEfforts: { "glm-5.3-flash": ["low", "medium", "high"] }, + modelReasoningEffortMap: { + "glm-5.3-flash": { ultra: REASONING_EFFORT_OMIT_SENTINEL, max: "high" }, + }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "ultra" })); + expect(JSON.parse(String(body)).think).toBe("high"); + }); + + test("max→__omit__ is honoured (inverse control)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + modelReasoningEffortMap: { "glm-5.3-flash": { max: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "max" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("none→__omit__ omits: the explicit mapping outranks the native none=>false fallback", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + modelReasoningEffortMap: { "glm-5.3-flash": { none: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("none without an omit mapping still serializes think:false", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body)).think).toBe(false); + }); +}); diff --git a/tests/ollama-native.test.ts b/tests/ollama-native.test.ts new file mode 100644 index 0000000000..65c90c5848 --- /dev/null +++ b/tests/ollama-native.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { + ollamaNativeChatUrl, + ollamaNativeEndpointKind, +} from "../src/adapters/ollama-native-url"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect, upstreamNativeEntry } from "../src/codex/catalog"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +/** The four ids this transport is maintained against. */ +const TARGETS = ["glm-5.3-flash", "deepseek-v4-flash:0731", "glm-5.2", "kimi-k3"] as const; + +function ollamaProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: [...TARGETS], + modelReasoningEfforts: { "deepseek-v4-flash:0731": ["low", "medium", "high", "max"] }, + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith( + messages: unknown[], + options: Record = {}, + modelId = "glm-5.3-flash", +): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages } } as unknown as OcxParsedRequest; +} + +describe("ollama-native — URL policy", () => { + test("normalizes every accepted cloud spelling onto /api/chat", () => { + for (const base of [ + "https://ollama.com", + "https://ollama.com/", + "https://ollama.com/v1", + "https://ollama.com/v1/chat/completions", + "https://ollama.com/api", + "https://ollama.com/api/chat", + ]) { + expect(ollamaNativeChatUrl(base)).toBe("https://ollama.com/api/chat"); + } + }); + + test("live model discovery is origin-relative, so the stored /v1 base reaches /v1/models", () => { + // model-discovery resolves a leading-slash spec path against base.origin: + // path "/v1/models" on baseUrl https://ollama.com/v1 -> https://ollama.com/v1/models. + const base = new URL("https://ollama.com/v1/"); + expect(new URL("/v1/models", base.origin).toString()).toBe("https://ollama.com/v1/models"); + }); + + test("classifies endpoints and refuses unsafe cloud transports", () => { + expect(ollamaNativeEndpointKind("https://ollama.com/v1")).toBe("cloud"); + expect(ollamaNativeEndpointKind("http://localhost:11434")).toBe("local"); + expect(ollamaNativeEndpointKind("https://ollama.internal.example/api")).toBe("custom"); + expect(() => ollamaNativeChatUrl("http://ollama.com/v1")).toThrow(/HTTPS/); + expect(() => ollamaNativeChatUrl("https://ollama.com:8443/v1")).toThrow(/non-default ports/); + }); + + test("treats one terminal-dot Ollama Cloud hostname as canonical", () => { + expect(ollamaNativeEndpointKind("https://ollama.com./api")).toBe("cloud"); + expect(ollamaNativeChatUrl("https://ollama.com./api")).toBe("https://ollama.com/api/chat"); + }); + + test("rejects the www Ollama Cloud alias instead of treating it as custom", () => { + expect(() => ollamaNativeEndpointKind("https://www.ollama.com/v1")) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + expect(() => ollamaNativeChatUrl("https://www.ollama.com/v1")) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + }); + + test("never silently rewrites a /v1 path on an unrelated host", () => { + expect(() => ollamaNativeChatUrl("https://ollama.internal.example/v1")).toThrow(/refuses custom baseUrl path/); + expect(ollamaNativeChatUrl("https://ollama.internal.example/api")).toBe("https://ollama.internal.example/api/chat"); + }); + + test("rejects credential-bearing, query-bearing and non-http base URLs", () => { + expect(() => ollamaNativeChatUrl("https://user:pw@chatgpt.com/v1")).toThrow(/must not contain credentials/); + expect(() => ollamaNativeChatUrl("https://ollama.com/v1?k=v")).toThrow(/must not contain credentials/); + expect(() => ollamaNativeChatUrl("ftp://ollama.com")).toThrow(/only supports http/); + expect(() => ollamaNativeChatUrl(" ")).toThrow(/non-empty baseUrl/); + }); +}); + +describe("ollama-native — registry and discovery contract", () => { + test("the registry declares the native transport and origin-relative /v1/models discovery", () => { + const entry = getProviderRegistryEntry("ollama-cloud"); + expect(entry?.adapter).toBe("ollama-native"); + // The compat base URL is deliberately retained; the normalizer maps it to /api/chat. + expect(entry?.baseUrl).toBe("https://ollama.com/v1"); + // Discovery resolves the leading-slash path against the ORIGIN, giving + // https://ollama.com/v1/models — the standard data[] envelope the generic pipeline + // already understands, so no special-case envelope code ships with this adapter. + expect(entry?.modelDiscovery).toEqual({ path: "/v1/models" }); + expect(entry?.modelContextWindows).toMatchObject({ + "glm-5.3": 1_048_576, + "glm-5.3-flash": 1_048_576, + }); + }); +}); + +describe("ollama-native — truthful serialized catalog capabilities", () => { + test("no target advertises verbosity, a verbosity default, or a service/speed tier", async () => { + const models = await gatherRoutedModels({ providers: { "ollama-cloud": ollamaProvider() } } as never); + const entries = buildCatalogEntries(null, [], models); + + for (const id of TARGETS) { + const entry = entries.find(e => e.slug === `ollama-cloud/${id}`); + expect(entry).toBeDefined(); + // Serialized Codex spelling. `supports_verbosity` (plural) does not exist in this format, + // which is exactly how an earlier assertion passed while the rows advertised the control. + expect(entry).not.toHaveProperty("supports_verbosity"); + expect(entry?.support_verbosity).toBe(false); + // default_verbosity is owned by the generic catalog verbosity fix (#2799); on a dev tree + // without it the strict-fields backfill still emits "low" here. Not asserted in this PR. + expect(entry?.service_tiers).toBeUndefined(); + expect(entry?.default_service_tier).toBeUndefined(); + expect(entry?.additional_speed_tiers).toBeUndefined(); + expect(entry?.fast_tier_description).toBeUndefined(); + } + }); + + test("a live-discovered Ollama id inherits the provider-wide opt-out", async () => { + // The Ollama catalog is discovery-authoritative, so ids absent from the registry row still + // reach the catalog. A per-model map alone would let those re-advertise the control. + const models = await gatherRoutedModels({ + providers: { "ollama-cloud": ollamaProvider({ models: ["a-model-not-in-the-registry"] }) }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "ollama-cloud/a-model-not-in-the-registry"); + expect(entry?.support_verbosity).toBe(false); + }); + + test("CONTROL: a routed provider that never disowns verbosity keeps the permissive default", async () => { + const models = await gatherRoutedModels({ + providers: { + plain: { + adapter: "openai-responses", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["plain-model"], + }, + }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "plain/plain-model"); + expect(entry?.support_verbosity).toBe(true); + }); + + test("CONTROL: xAI's own opt-out is unchanged", async () => { + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-4.6"], + }, + }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "xai/grok-4.6"); + expect(entry?.support_verbosity).toBe(false); + }); + + test("CONTROL: a native OpenAI row keeps verbosity, which it genuinely supports", () => { + const template = upstreamNativeEntry("gpt-5.6-sol"); + expect(template).not.toBeNull(); + const entries = buildCatalogEntries(template, ["gpt-5.6-sol"], []); + const native = entries.find(e => e.slug === "gpt-5.6-sol"); + expect(native).toBeDefined(); + expect(native?.support_verbosity).toBe(true); + }); +}); + +describe("ollama-native — reasoning ladder", () => { + test("routed rows carry the upstream-required synthetic top rungs; the WIRE clamps", async () => { + const models = await gatherRoutedModels({ providers: { "ollama-cloud": ollamaProvider() } } as never); + const entries = buildCatalogEntries(null, [], models); + const efforts = (slug: string) => + ((entries.find(e => e.slug === slug)?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>) + .map(l => l.effort); + + // Catalog universality (upstream design): every reasoning-capable routed row advertises the + // synthetic top rungs so subagent effort overrides validate by catalog membership. The + // ollama-native adapter is responsible for keeping the WIRE honest (see the wire-clamp tests). + expect(efforts("ollama-cloud/deepseek-v4-flash:0731")).toEqual(["low", "medium", "high", "max", "ultra"]); + for (const id of ["glm-5.3-flash", "glm-5.2", "kimi-k3"]) { + expect(efforts(`ollama-cloud/${id}`)).toContain("max"); + expect(efforts(`ollama-cloud/${id}`)).toContain("ultra"); + } + }); + + test("every advertised rung maps into an allowed native wire value", async () => { + const provider = ollamaProvider(); + const adapter = createOllamaNativeAdapter(provider); + const allowed = new Set([undefined, true, false, "low", "medium", "high", "max"]); + for (const requested of ["minimal", "low", "medium", "high", "xhigh", "max", "ultra", "none"]) { + const { body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: requested })); + const think = JSON.parse(String(body)).think; + expect(allowed.has(think)).toBe(true); + } + // xhigh and ultra collapse onto Ollama's top rung rather than being sent through verbatim. + for (const requested of ["xhigh", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe("max"); + } + }); + + test("an unmappable effort fails the turn instead of degrading silently", () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => + adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: "turbo" })), + ).toThrow(/does not support reasoning level/); + }); +}); + +describe("ollama-native — request shape", () => { + test("posts to the native chat endpoint with the wire model id", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { url, method, body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); + expect(url).toBe("https://ollama.com/api/chat"); + expect(method).toBe("POST"); + expect(JSON.parse(String(body)).model).toBe("glm-5.3-flash"); + }); + + test("a caller-supplied verbosity never reaches /api/chat", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { body } = await adapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { verbosity: "high", text: { verbosity: "high" } }), + ); + const serialized = String(body); + expect(serialized).not.toContain("verbosity"); + const parsed = JSON.parse(serialized); + expect(parsed).not.toHaveProperty("verbosity"); + expect(parsed.options ?? {}).not.toHaveProperty("verbosity"); + }); + + test("images travel in the native images[] array, and video is refused", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const png = "data:image/png;base64,iVBORw0KGgo="; + const { body } = await adapter.buildRequest(parsedWith([ + { role: "user", content: [{ type: "text", text: "read it" }, { type: "image", imageUrl: png }] }, + ])); + const message = JSON.parse(String(body)).messages.at(-1); + expect(Array.isArray(message.images)).toBe(true); + expect(message.images[0]).toBe("iVBORw0KGgo="); + expect(message.content).toContain("read it"); + + expect(() => adapter.buildRequest(parsedWith([ + { role: "user", content: [{ type: "video", videoUrl: "data:video/mp4;base64,AAAA" }] }, + ]))).toThrow(/cannot send video/); + }); +}); diff --git a/tests/ollama-show-enrichment-v7.test.ts b/tests/ollama-show-enrichment-v7.test.ts new file mode 100644 index 0000000000..a82f8a336d --- /dev/null +++ b/tests/ollama-show-enrichment-v7.test.ts @@ -0,0 +1,502 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +afterEach(() => { + // The provider-model cache is keyed by provider name; without this, one test's gather would + // satisfy the next test's discovery from the previous test's cached rows. + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; +import { fetchOllamaShowEnrichment, ollamaShowMetadataFromPayload, showHeadersFromCaptured } from "../src/providers/ollama-show"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../src/types"; + +/** + * V7: auth/outbound-policy integration, aggregate fan-out bounds, and models-API precedence. + * The show request must reuse the discovery request's already-materialized captured headers and + * execute through the same outbound-policy transport as discovery — never manufacturing its own + * auth contract from apiKey, never using a raw fetch. + */ + +function jsonRes(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function ollamaShow(contextLength: number, capabilities: string[]): Response { + return jsonRes({ + model_info: { + "general.architecture": "testarch", + "testarch.context_length": contextLength, + }, + capabilities, + }); +} + +interface Call { url: string; body: string; init: RequestInit; auth: string | undefined } + +function stubFetch( + handler: (url: string, body: string) => Response, +): { calls: Array; uninstall: () => void } { + const calls: Array = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const headers = (init?.headers ?? {}) as Record; + calls.push({ + url, + body: typeof init?.body === "string" ? init.body : "", + init: init ?? {}, + auth: headers.Authorization ?? headers.authorization, + }); + return Promise.resolve(handler(url, calls.at(-1)!.body)); + }) as typeof fetch; + return { calls, uninstall: () => { globalThis.fetch = original; } }; +} + +const showCalls = (calls: Array) => calls.filter(c => c.url.endsWith("/api/show")); + +function providerConfig(headers?: Record): OcxConfig { + return { + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: true, + models: [], + ...(headers ? { headers } : {}), + }, + }, + } as never as OcxConfig; +} + +describe("ollama /api/show — auth and outbound-policy integration", () => { + test("1: apiKey-generated auth follows the captured provider request", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // The generated Bearer value is materialized from the provider credential; assert + // presence and shape without embedding a scanner-flagged bearer literal. + expect((show[0].auth ?? "").startsWith("Bearer ")).toBe(true); + expect(show[0].init.method).toBe("POST"); + } finally { + stub.uninstall(); + } + }); + + test("2: the show request's auth deterministically matches the captured discovery request (configured headers included)", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch( + providerConfig({ Authorization: "Bearer configured-value" }), + )); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // The captured discovery headers ARE the authority: the show request carries exactly the + // auth the /v1/models request carried (buildModelsRequest materialization governs both — + // discovery's generic tail writes a canonical-case Authorization after merging configured + // headers, so generated Bearer wins there; the show request introduces no separate + // contract and mirrors the result verbatim). + const modelsCall = stub.calls.find(c => c.url.endsWith("/v1/models")); + expect(show[0].auth).toBe(modelsCall?.auth); + } finally { + stub.uninstall(); + } + }); + + test("3: lowercase authorization is mirrored verbatim — the show request adds no spelling of its own", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch( + providerConfig({ authorization: "Bearer configured-lower" }), + )); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // Parity: the show headers contain exactly the authorization materialization the + // discovery request had — no extra credential spelling introduced by the show path. + const showInit = (show[0].init.headers ?? {}) as Record; + const modelsCall = stub.calls.find(c => c.url.endsWith("/v1/models")); + const modelsInit = (modelsCall?.init.headers ?? {}) as Record; + const showAuth = Object.entries(showInit).filter(([n]) => n.toLowerCase() === "authorization"); + const modelsAuth = Object.entries(modelsInit).filter(([n]) => n.toLowerCase() === "authorization"); + expect(showAuth).toEqual(modelsAuth); + expect(showAuth.length).toBeGreaterThan(0); + } finally { + stub.uninstall(); + } + }); + + test("4: the provider.fetch executor is invoked through the outbound-policy wrapper", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // providerOutboundPost forwards method + redirect:"manual" to the executor; a raw + // globalThis.fetch call from the enrichment would not carry these. + expect(show[0].init.method).toBe("POST"); + expect((show[0].init as { redirect?: string }).redirect).toBe("manual"); + const initHeaders = (show[0].init.headers ?? {}) as Record; + expect(initHeaders["Content-Type"]).toBe("application/json"); + } finally { + stub.uninstall(); + } + }); + + test("5: a redirecting /api/show is rejected without contacting the target", async () => { + let redirectTargetHits = 0; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) { + return new Response(null, { status: 301, headers: { Location: "https://evil.example.test/api/show" } }); + } + if (url.includes("evil.example.test")) { + redirectTargetHits += 1; + return jsonRes({ model_info: { "general.architecture": "evil", "evil.context_length": 1 } }); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const found = rowOf(models, "glm-5.3"); + expect(found).toBeDefined(); + expect(found?.contextWindow).toBe(1_048_576); + expect(redirectTargetHits).toBe(0); + } finally { + stub.uninstall(); + } + }); + + test("6: enrichment failures never leak header or credential values", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "boom-model" }] }); + if (url.endsWith("/api/show")) { + throw new Error("transport exploded with test-key-not-a-real-credential"); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const found = rowOf(models, "boom-model"); + expect(found).toBeDefined(); // fail-soft: the ID roster survives + const serialized = JSON.stringify(models); + expect(serialized).not.toContain("test-key-not-a-real-credential"); + expect(serialized).not.toContain("transport exploded"); + } finally { + stub.uninstall(); + } + }); + + test("7: discovered GLM-5.3 retains the static context fallback when /api/show fails", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return new Response("show unavailable", { status: 503 }); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(rowOf(models, "glm-5.3")?.contextWindow).toBe(1_048_576); + expect(showCalls(stub.calls)).toHaveLength(1); + } finally { + stub.uninstall(); + } + }); +}); + +function rowOf(models: Array<{ provider: string; id: string; contextWindow?: number }>, id: string) { + return models.find(m => m.provider === "ollama-cloud" && m.id === id); +} + +describe("ollama /api/show — aggregate fan-out bounds", () => { + test("1: a roster larger than the show-specific cap issues no more than the cap", async () => { + const ids = Array.from({ length: 80 }, (_, i) => `model-${i}`); + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: ids.map(id => ({ id })) }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(rowOf(models, "model-0")).toBeDefined(); + expect(showCalls(stub.calls).length).toBe(48); // SHOW_REQUEST_CAP, not the 80-id roster + } finally { + stub.uninstall(); + } + }); + + test("2: every roster ID survives even when only a bounded subset is enriched", async () => { + const ids = Array.from({ length: 80 }, (_, i) => `model-${i}`); + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: ids.map(id => ({ id })) }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(models.filter(m => m.provider === "ollama-cloud").length).toBe(80); + } finally { + stub.uninstall(); + } + }); + + test("3: hanging show workers settle at the aggregate deadline; the roster survives", async () => { + // Deterministic deadline seam: two /api/show requests hang (honouring abort signals like a + // real fetch), one completes. The aggregate deadline aborts the hang; partial metadata is + // returned; the whole phase stays bounded. The executor is INJECTED so nothing here touches + // the real network. + const hangingFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof (init as { body?: string }).body === "string" ? (init as { body: string }).body : ""; + if (body.includes("ok-3")) return Promise.resolve(ollamaShow(131_072, ["completion"])); + return new Promise((_resolve, reject) => { + (init as RequestInit).signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError"))); + }); + }) as typeof fetch; + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["hang-1", "hang-2", "ok-3"], + showRequestCap: 48, + deadlineMs: 300, + requestTimeoutMs: 60_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: hangingFetch }, + }); + expect(result.deadlineHit).toBe(true); + expect(result.metadata.has("ok-3")).toBe(true); // completed before the deadline + expect(result.metadata.has("hang-1")).toBe(false); + expect(result.showRequests).toBe(3); + }); + + test("4: completion before the deadline enriches normally", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "ok-3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["ok-3"], + deadlineMs: 5_000, + requestTimeoutMs: 5_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: globalThis.fetch }, + }); + expect(result.deadlineHit).toBe(false); + expect(result.metadata.get("ok-3")?.contextWindow).toBe(131_072); + } finally { + stub.uninstall(); + } + }); + + test("5: cache hits issue zero show calls (gather-level, TTL cache warm)", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }, { id: "glm-5.3-flash" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools"]); + return new Response("nf", { status: 404 }); + }); + try { + const cfg = providerConfig(); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + expect(showCalls(stub.calls)).toHaveLength(2); // one per distinct id — not per gather + } finally { + stub.uninstall(); + } + }); +}); + +describe("showHeadersFromCaptured — Content-Type forcing without disturbing precedence", () => { + test("forces Content-Type; keeps Authorization (any spelling) untouched", () => { + const out = showHeadersFromCaptured({ + Authorization: "Bearer generated", + "content-type": "text/plain", + "X-Custom": "keep", + }); + expect(out.Authorization).toBe("Bearer generated"); + expect(out["Content-Type"]).toBe("application/json"); + expect(out["content-type"]).toBeUndefined(); + expect(out["X-Custom"]).toBe("keep"); + }); +}); + +describe("ollama /api/show — payload extraction contract (input not mutated)", () => { + test("the parsed payload object is never mutated by extraction", () => { + const payload = { + model_info: { + "general.architecture": "glm_dsa_moe", + "glm_dsa_moe.context_length": 1_048_576, + "other.context_length": 4096, + }, + capabilities: ["completion", "thinking", "tools"], + }; + const before = JSON.stringify(payload); + const meta = ollamaShowMetadataFromPayload(payload); + expect(meta?.contextWindow).toBe(1_048_576); + expect(meta?.nativeVision).toBe(false); + expect(JSON.stringify(payload)).toBe(before); // input untouched + }); +}); +// The adapter-level third surface is exercised directly below via the real adapter factory, +// which is what the native /api/chat route uses. +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import type { OcxParsedRequest } from "../src/types"; + +function nativeParsed(modelId = "glm-5.3-flash"): OcxParsedRequest { + return { modelId, stream: true, options: {}, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +/** Observe the effective Authorization on all three Ollama request surfaces for one config. */ +describe("ollama — three-surface auth matrix (V8)", () => { + const CASES: Array<{ name: string; provider: Record; expectConfigured?: string }> = [ + { name: "apiKey only", provider: {} }, + { name: "apiKey + configured Authorization", provider: { headers: { Authorization: "Bearer configured-value" } }, expectConfigured: "Bearer configured-value" }, + { name: "apiKey + lowercase authorization", provider: { headers: { authorization: "Bearer configured-lower" } }, expectConfigured: "Bearer configured-lower" }, + ]; + + for (const c of CASES) { + test(`${c.name}: /v1/models, /api/show and /api/chat share ONE effective credential`, async () => { + const overrides = { ...c.provider }; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + ...c.provider, + }, + }, + } as never)); + const modelsRequest = stub.calls.find(k => k.url.endsWith("/v1/models")); + const showRequest = showCalls(stub.calls)[0]; + expect(modelsRequest).toBeDefined(); + expect(showRequest).toBeDefined(); + + // Exactly one case-insensitive Authorization header on EACH catalog surface. + const modelsAuth = Object.entries((modelsRequest.init.headers ?? {}) as Record) + .filter(([n]) => n.toLowerCase() === "authorization"); + const showInit = (showRequest.init.headers ?? {}) as Record; + const showAuth = Object.entries(showInit).filter(([n]) => n.toLowerCase() === "authorization"); + expect(modelsAuth).toHaveLength(1); + expect(showAuth).toHaveLength(1); + + // Third surface: native /api/chat request headers. + const adapter = createOllamaNativeAdapter({ + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", + ...c.provider, + } as never); + const chat = await adapter.buildRequest(nativeParsed()); + const chatHeaders = chat.headers as Record; + const chatAuth = Object.entries(chatHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(chatAuth).toHaveLength(1); + + // Every surface must carry the same effective credential, regardless of header spelling. + expect(showAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + + // ONE effective credential, and configured auth wins where supplied. + if (c.expectConfigured !== undefined) { + expect(modelsAuth[0][1]).toBe(c.expectConfigured); + expect(showAuth[0][1]).toBe(c.expectConfigured); + expect(chatAuth[0][1]).toBe(c.expectConfigured); + } else { + // apiKey only: assert presence + single header without embedding a scanner-flagged + // bearer literal; the generated value is materialized from the fixture credential. + expect(modelsAuth[0][1].startsWith("Bearer ")).toBe(true); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + } + } finally { + stub.uninstall(); + } + }); + } + + test("header-only HTTPS with keyOptional:true remains supported across all three surfaces", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools", "vision"]); + return new Response("nf", { status: 404 }); + }); + try { + const cfg = withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: undefined, keyOptional: true, liveModels: true, models: [], + headers: { Authorization: "Bearer header-only" }, + }, + }, + } as never); + const models = await gatherRoutedModels(cfg); + expect(models.find(m => m.provider === "ollama-cloud" && m.id === "glm-5.3")?.contextWindow).toBe(1_048_576); // enriched without apiKey + const modelsRequest = stub.calls.find(k => k.url.endsWith("/v1/models")); + expect(modelsRequest).toBeDefined(); + const modelsHeaders = (modelsRequest.init.headers ?? {}) as Record; + const modelsAuth = Object.entries(modelsHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(modelsAuth).toHaveLength(1); + const show = showCalls(stub.calls)[0]; + const showHeaders = (show.init.headers ?? {}) as Record; + const showAuth = Object.entries(showHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(showAuth).toHaveLength(1); + + // Third surface: the native /api/chat request from the same header-only provider. + const adapter = createOllamaNativeAdapter({ + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: undefined, keyOptional: true, liveModels: true, models: ["glm-5.3"], + headers: { Authorization: "Bearer header-only" }, + } as never); + const chat = await adapter.buildRequest({ + modelId: "glm-5.3", stream: true, options: {}, + context: { messages: [{ role: "user", content: "hi" }] }, + } as never); + const chatHeaders = chat.headers as Record; + const chatAuth = Object.entries(chatHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(chatAuth).toHaveLength(1); // exactly one case-insensitive Authorization header + expect(modelsAuth[0][1]).toBe("Bearer header-only"); // the configured header-only fixture value + expect(showAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chat.url).toBe("https://ollama.com/api/chat"); // native route accepted the request + } finally { + stub.uninstall(); + } + }); +}); diff --git a/tests/ollama-show-enrichment.test.ts b/tests/ollama-show-enrichment.test.ts new file mode 100644 index 0000000000..240a2665d6 --- /dev/null +++ b/tests/ollama-show-enrichment.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +afterEach(() => { + // The provider-model cache is keyed by provider name; without this, one test's gather would + // satisfy the next test's discovery without any outbound call at all. + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; +import { ollamaShowMetadataFromPayload } from "../src/providers/ollama-show"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../src/types"; + +/** + * Bounded /api/show metadata enrichment for Ollama Cloud live discovery. + * Every test drives the REAL discovery path (gatherRoutedModels) through a stubbed fetch, + * asserting the resulting CatalogModel rows — never internal call graphs alone. + */ + +interface Probe { + calls: Array<{ url: string; method: string; body: string }>; +} + +function jsonRes(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function stubFetch( + handler: (url: string, body: string) => Response, +): { probe: { calls: Array<{ url: string; body: string }>; maxShowActive: () => number }; uninstall: () => void } { + const calls: Array<{ url: string; body: string }> = []; + let active = 0; + let maxActive = 0; + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const body = typeof init?.body === "string" ? init.body : ""; + calls.push({ url, body }); + active += 1; + maxActive = Math.max(maxActive, active); + const res = handler(url, body); + return Promise.resolve(res).finally(() => { active -= 1; }); + }) as typeof fetch; + return { + probe: { calls, maxShowActive: () => maxActive }, + uninstall: () => { globalThis.fetch = original; }, + }; +} + +function ollamaShow(id: string, contextLength: number, capabilities: string[]): Response { + return jsonRes({ + model_info: { + "general.architecture": "testarch", + "testarch.context_length": contextLength, + }, + capabilities, + }); +} + +function config(overrides: Record = {}): OcxConfig { + return { + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: true, + models: [], + }, + ...overrides, + }, + } as never as OcxConfig; +} + +function row(models: Array<{ provider: string; id: string; contextWindow?: number; inputModalities?: string[] }>, id: string) { + return models.find(m => m.provider === "ollama-cloud" && m.id === id); +} + +function discoveryStub(ids: string[], showFor: (id: string) => Response) { + return stubFetch((url, body) => { + if (url.endsWith("/v1/models")) { + return jsonRes({ object: "list", data: ids.map(id => ({ id, object: "model" })) }); + } + if (url.endsWith("/api/show")) { + const parsed = JSON.parse(body || "{}") as { model?: string }; + return showFor(parsed.model ?? ""); + } + return new Response("nf", { status: 404 }); + }); +} + +describe("ollama /api/show — context enrichment", () => { + test("A: discovered glm-5.3 uses /api/show context_length (1,048,576) before any cap", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const glm = row(models, "glm-5.3"); + expect(glm).toBeDefined(); + expect(glm?.contextWindow).toBe(1_048_576); + const shows = stub.probe.calls.filter(c => c.url.endsWith("/api/show")); + expect(shows).toHaveLength(1); + expect(JSON.parse(shows[0].body)).toEqual({ model: "glm-5.3" }); + // /v1/models remains the roster call + expect(stub.probe.calls.some(c => c.url.endsWith("/v1/models"))).toBe(true); + } finally { + stub.uninstall(); + } + }); + + test("B: a configured context cap below the discovered window still wins", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providerContextCaps: { "ollama-cloud": 200000 }, + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + }, + }, + } as never)); + expect(row(models, "glm-5.3")?.contextWindow).toBe(200000); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — capability mapping", () => { + test("C: /api/show vision surfaces native image input for a newly discovered VLM", async () => { + const stub = discoveryStub(["brand-new-vlm"], () => ollamaShow("brand-new-vlm", 262_144, ["completion", "thinking", "tools", "vision"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(row(models, "brand-new-vlm")?.inputModalities).toEqual(["text", "image"]); + } finally { + stub.uninstall(); + } + }); + + test("D: /api/show no-vision for a noVisionModels id keeps the sidecar image contract", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + noVisionModels: ["glm-5.3"], + }, + }, + } as never)); + // Sidecar contract intact: the noVision row still advertises image input so Codex + // permits attachments and the sidecar can describe them before the model sees the turn. + expect(row(models, "glm-5.3")?.inputModalities).toEqual(["text", "image"]); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — failure behavior", () => { + test("E: missing/malformed context_length falls back safely; discovery still succeeds", async () => { + const stub = discoveryStub(["odd-model"], () => jsonRes({ + model_info: { "general.architecture": "weird", "weird.context_length": "not-a-number" }, + capabilities: ["completion"], + })); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "odd-model"); + expect(found).toBeDefined(); // the ID roster survives + expect(found?.contextWindow).toBeUndefined(); // no fabricated context + } finally { + stub.uninstall(); + } + }); + + test("F: one /api/show non-2xx degrades only that model; other rows stay enriched", async () => { + const stub = discoveryStub(["bad-model", "good-model"], id => + id === "bad-model" + ? new Response("nope", { status: 500 }) + : ollamaShow(id, 131_072, ["completion"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(row(models, "bad-model")?.contextWindow).toBeUndefined(); + expect(row(models, "good-model")?.contextWindow).toBe(131_072); + expect(row(models, "bad-model")).toBeDefined(); + } finally { + stub.uninstall(); + } + }); + + test("G: oversized /api/show response is bounded fail-soft", async () => { + const stub = stubFetch((url, _body) => { + if (url.endsWith("/v1/models")) { + return jsonRes({ object: "list", data: [{ id: "big-model" }] }); + } + if (url.endsWith("/api/show")) { + return new Response( + JSON.stringify({ + model_info: { "general.architecture": "arch", "arch.context_length": 1_048_576 }, + padding: "p".repeat(600 * 1024), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "big-model"); + expect(found).toBeDefined(); + // Bounded reader discarded the oversized payload: no fabricated context window. + expect(found?.contextWindow).toBeUndefined(); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — scoping, caching, bounds", () => { + test("H: unrelated providers never issue /api/show", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "plain-model" }] }); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "plain", + providers: { plain: { adapter: "openai-responses", baseUrl: "https://plain.example.test/v1", authMode: "key", apiKey: "test-key-not-a-real-credential", liveModels: true, models: [] } }, + } as never)); + expect(stub.probe.calls.filter(c => c.url.endsWith("/api/show"))).toHaveLength(0); + } finally { + stub.uninstall(); + } + }); + + test("I: cache hits within TTL do not reissue /api/show per row", async () => { + const stub = discoveryStub(["glm-5.3", "glm-5.3-flash"], () => ollamaShow("x", 1_048_576, ["completion", "thinking", "tools"])); + try { + const cfg = config(); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + const showCalls = stub.probe.calls.filter(c => c.url.endsWith("/api/show")); + // First gather enriches each id once; the second gather hits the provider-model cache + // (which already stores the enriched rows) and issues zero further /api/show requests. + expect(showCalls).toHaveLength(2); + } finally { + stub.uninstall(); + } + }); + + test("J: enrichment concurrency and model count are bounded", async () => { + const ids = Array.from({ length: 30 }, (_, i) => `model-${i}`); + const stub = discoveryStub(ids, () => ollamaShow("x", 131_072, ["completion"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(models.length).toBeGreaterThan(0); + // The fan-out is capped by the discovered roster itself (one show per id, capped again by + // discovery.maxModels upstream) — never more show calls than discovered rows. + expect(stub.probe.calls.filter(c => c.url.endsWith("/api/show")).length).toBeLessThanOrEqual(30); + // The concurrency bound is the load-bearing proof: at most 4 in flight regardless of roster. + expect(stub.probe.maxShowActive()).toBeLessThanOrEqual(4); + } finally { + stub.uninstall(); + } + }); + + test("K: a redirecting /api/show never sends the credential to another host", async () => { + let redirectTargetHits = 0; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) { + return new Response(null, { status: 301, headers: { Location: "https://evil.example.test/api/show" } }); + } + if (url.includes("evil.example.test")) { + redirectTargetHits += 1; + return jsonRes({ model_info: { "general.architecture": "evil", "evil.context_length": 1 } }); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "glm-5.3"); + expect(found).toBeDefined(); // the ID roster survives the failed enrichment + expect(found?.contextWindow).toBe(1_048_576); + // redirect: "manual" plus an explicit providerRedirectError check — the target was never contacted. + expect(redirectTargetHits).toBe(0); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — payload extraction contract", () => { + test("capabilities-only vision produces native vision metadata", () => { + expect(ollamaShowMetadataFromPayload({ capabilities: ["vision"] })) + .toEqual({ nativeVision: true }); + }); + + test("capabilities-only non-vision produces an explicit negative", () => { + expect(ollamaShowMetadataFromPayload({ capabilities: ["completion", "tools"] })) + .toEqual({ nativeVision: false }); + }); + + test("valid capabilities remain readable when model_info is absent or malformed", () => { + for (const payload of [ + { model_info: null, capabilities: ["vision"] }, + { model_info: "malformed", capabilities: ["vision"] }, + { model_info: [], capabilities: ["vision"] }, + ]) { + expect(ollamaShowMetadataFromPayload(payload)).toEqual({ nativeVision: true }); + } + }); + + test("valid model_info and capabilities retain context and vision metadata", () => { + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "arch.context_length": 262_144 }, + capabilities: ["completion", "vision"], + })).toEqual({ contextWindow: 262_144, nativeVision: true }); + }); + + test("architecture-named context_length is preferred; ambiguous fallback requires uniqueness", () => { + expect(ollamaShowMetadataFromPayload({ + model_info: { + "general.architecture": "glm_dsa_moe", + "glm_dsa_moe.context_length": 1_048_576, + "other.context_length": 4096, + }, + })?.contextWindow).toBe(1_048_576); + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "arch.context_length": 262_144 }, + capabilities: ["completion", "vision"], + })?.nativeVision).toBe(true); + // Ambiguous non-architecture fallback is refused rather than guessed. + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "a.context_length": 1, "b.context_length": 2 }, + })?.contextWindow).toBeUndefined(); + // Nonsense payloads yield nothing (no fabricated metadata). + expect(ollamaShowMetadataFromPayload(null)).toBeUndefined(); + expect(ollamaShowMetadataFromPayload("nope")).toBeUndefined(); + }); +}); diff --git a/tests/ollama-show-ignore-abort.test.ts b/tests/ollama-show-ignore-abort.test.ts new file mode 100644 index 0000000000..0a87075d59 --- /dev/null +++ b/tests/ollama-show-ignore-abort.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { fetchOllamaShowEnrichment } from "../src/providers/ollama-show"; + +/** + * V10: the aggregate deadline TIMER ITSELF is the return bound. + * + * This executor GENUINELY IGNORES abort: it records that a signal was supplied, installs NO + * abort listener that settles it, and never settles on its own until the test manually releases + * the deferred. Under V8/V9 semantics (timer only aborts; finish() reachable only via + * pump/worker settlement) this test HUNG the entire harness — the RED state. V10 GREEN: the + * enrichment returns AT the injected deadline with `deadlineHit: true`, the returned Map is the + * captured snapshot, and a late settlement after return can never mutate it. + */ + +function jsonShowResponse(contextLength: number): string { + return JSON.stringify({ + model_info: { "general.architecture": "testarch", "testarch.context_length": contextLength }, + capabilities: ["completion"], + }); +} + +interface Deferred { + resolve: (response: Response) => void; + reject: (e: unknown) => void; +} + +describe("ollama /api/show — aggregate deadline vs ignore-abort executor", () => { + test("returns at the injected deadline with a pending ignore-abort worker; late settlement is isolated", async () => { + const deferreds: Deferred[] = []; + let sawSignal = false; + let sawAbort = false; + let active = 0; + let maxActive = 0; + + // GENUINELY IGNORES abort: records the signal event but never settles on it. + const ignoreAbortFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const signal = (init as { signal?: AbortSignal }).signal; + expect(signal).toBeDefined(); // the outbound wrapper always supplies an AbortSignal + sawSignal = true; + active += 1; + maxActive = Math.max(maxActive, active); + signal!.addEventListener("abort", () => { sawAbort = true; }); + return new Promise((resolve, reject) => { + deferreds.push({ + resolve: (response) => resolve(response), + reject, + }); + }); + }) as typeof fetch; + + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["hang-a", "hang-b"], + deadlineMs: 200, // injected aggregate deadline; far below any real timeout + requestTimeoutMs: 60_000, // per-request timeout deliberately longer than the deadline + provider: { baseUrl: "https://ollama.com/v1", fetch: ignoreAbortFetch }, + }); + + expect(sawSignal).toBe(true); + expect(result.deadlineHit).toBe(true); // returned AT the deadline + expect(result.showRequests).toBe(2); + expect(result.metadata.size).toBe(0); // nothing settled before the deadline + expect(maxActive).toBeLessThanOrEqual(4); // outstanding detached work bounded + + // Capture the returned snapshot, then settle the still-pending deferred workers with valid + // metadata and let the detached workers finish. + const captured = JSON.stringify([...result.metadata.entries()]); + for (const d of deferreds) { + d.resolve(new Response( + jsonShowResponse(262_144), + { status: 200, headers: { "content-type": "application/json" } }, + )); + } + await new Promise(r => setTimeout(r, 20)); + + // The returned Map is unchanged: late settlement mutated only the internal map. + expect(result.metadata.size).toBe(0); + expect(JSON.stringify([...result.metadata.entries()])).toBe(captured); + void sawAbort; + }); + + test("control: an abort-respecting executor still completes before the deadline", async () => { + let active = 0; + let maxActive = 0; + const abortRespectingFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const signal = (init as { signal?: AbortSignal }).signal; + active += 1; + maxActive = Math.max(maxActive, active); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(new Response( + JSON.stringify({ + model_info: { "general.architecture": "arch", "arch.context_length": 131_072 }, + capabilities: ["completion"], + }), + { status: 200, headers: { "content-type": "application/json" } }, + )); + }, 30); + signal!.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("aborted", "AbortError")); }); + }); + }) as typeof fetch; + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["ok-1", "ok-2"], + deadlineMs: 5_000, + requestTimeoutMs: 5_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: abortRespectingFetch }, + }); + expect(result.deadlineHit).toBe(false); + expect(result.showRequests).toBe(2); + void active; void maxActive; + }); +}); From 5b573ac9d68b06e4cbf0502353e5666a633318b4 Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Sun, 30 Aug 2026 03:51:13 +0800 Subject: [PATCH 097/132] feat(grok): migrate managed block to model_providers inheritance (#2890) * feat(grok): migrate managed block to model_providers inheritance Grok 0.2.109 (2026-07-21) shipped working [model_providers.] inheritance: base_url, api_backend, api_key, and extra_headers declared on the provider are applied to inference requests. The managed block in ~/.grok/config.toml now declares one shared [model_providers.opencodex] table followed by one [model.*] table per model that references it via model_provider, instead of repeating the provider fields on every model table. - inject.ts: emit the provider block first, then per-model tables; extend the orphan sweep to adopt re-serialized provider blocks (extra_headers promoted to a sub-table or dotted keys, even when an unrelated user table sits between the parent and its child) and inheritance-shape model tables by following their model_provider reference, so entries left unfenced by a Grok rewrite are still swept; the fenced provider also counts as ownership evidence during teardown (Codex-side precedent: classifyCodexRouting) - status.ts: read base_url from the provider block, falling back to per-model base_url for legacy fences - tests: 6 new regression tests in tests/grok-orphan-adoption.test.ts covering the re-serialized, inheritance, and interleaved-child layouts; the other grok suites updated for the new block shape * docs(grok): sync the grok-build guide with the inherited provider block - rewrite the example as one provider entry plus per-model model_provider references in all 8 locales, and note the Grok Build 0.2.109+ requirement - rewrite the fr manual recipe, which still showed per-model provider fields - reword the env_key warning so it no longer implies a model_provider condition, and align the wire-protocol notes with Responses passthrough in the translated locales --- .../src/content/docs/fr/guides/grok-build.md | 58 +-- .../src/content/docs/guides/grok-build.md | 49 ++- .../src/content/docs/ja/guides/grok-build.md | 46 ++- .../src/content/docs/ko/guides/grok-build.md | 46 ++- .../src/content/docs/ru/guides/grok-build.md | 50 +-- .../src/content/docs/tr/guides/grok-build.md | 55 +-- .../content/docs/zh-cn/guides/grok-build.md | 44 ++- .../content/docs/zh-tw/guides/grok-build.md | 43 ++- src/grok/inject.ts | 220 +++++++++-- src/grok/status.ts | 29 +- tests/grok-attribution.test.ts | 8 +- tests/grok-config-inject.test.ts | 17 +- tests/grok-orphan-adoption.test.ts | 356 +++++++++++++++++- tests/grok-selection.test.ts | 3 +- tests/grok-status.test.ts | 25 ++ 15 files changed, 829 insertions(+), 220 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/grok-build.md b/docs-site/src/content/docs/fr/guides/grok-build.md index e1e8ccc977..1adeb25c59 100644 --- a/docs-site/src/content/docs/fr/guides/grok-build.md +++ b/docs-site/src/content/docs/fr/guides/grok-build.md @@ -15,13 +15,16 @@ en `~/.grok/config.toml` : ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,7 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... autres niveaux de ce modèle, puis une table [model.ocx-*] par modèle visible ... +# ... autres niveaux de ce modèle, puis une table [model.ocx-*] par modèle visible, chacun référençant model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -77,12 +80,12 @@ en amont fixes, et non les métadonnées configurées pour les modèles routés. `none` et `minimal`, sont conservés lorsqu’ils sont annoncés. Les niveaux non pris en charge ou en double, notamment `ultra`, propre à Codex, sont omis du fichier afin que chaque option générée reste sélectionnable. -Grok Build communique avec opencodex au moyen de Chat Completions et envoie `reasoning_effort` lorsque -l’échelle est annoncée. Dans ce cas, le traducteur Chat Completions entrant définit par défaut le champ Responses -`reasoning.summary` sur `auto` ; les traces de raisonnement parviennent donc à Grok sous la forme -`delta.reasoning_content` au lieu d’être masquées. Réglez `include_reasoning: false` (ou -`reasoning.summary: "none"`) si un client souhaite que le modèle réfléchisse sans renvoyer le -tracé. Une valeur explicite de `reasoning.summary` prévaut lorsque les deux options sont présentes. +Grok Build communique avec opencodex au moyen de l’API Responses. Lorsque la route annonce une +échelle de raisonnement, le relais Responses transmet `reasoning.summary` tel que configuré, si bien +que les traces de raisonnement parviennent à Grok nativement sous forme d’éléments de raisonnement +Responses. Réglez `reasoning.summary: "none"` si un client souhaite que le modèle réfléchisse sans +renvoyer le tracé. Une valeur explicite de `reasoning.summary` prévaut sur la valeur par défaut de la +route. ## Note d'authentification @@ -101,46 +104,49 @@ dehors des marqueurs gérés, où aucune opération opencodex ne peut les écras `base_url` (une adresse réellement accessible depuis l’endroit où vous exécutez `grok`) et `api_key` (votre `OPENCODEX_API_AUTH_TOKEN`). -Ne remplacez pas `api_key` par `env_key` ici. En l’absence de `model_provider`, un `env_key` qui ne peut pas être +Ne remplacez pas `api_key` par `env_key` ici. Un `env_key` qui ne peut pas être résolu n’interrompt pas la requête : Grok utilise alors votre jeton de session xAI et l’envoie à l’adresse `base_url` indiquée par l’entrée. Pour un déploiement sur le réseau local, cette adresse est un point de terminaison HTTP en clair qui n’appartient pas à xAI. -La valeur `api_key` injectée pour chaque modèle se trouve en tête de la chaîne d’identifiants de Grok. Les requêtes +La valeur `api_key` injectée sur l’entrée du fournisseur se trouve en tête de la chaîne d’identifiants de Grok. Les requêtes adressées à opencodex ne nécessitent donc aucune connexion Grok supplémentaire. Conservez votre configuration habituelle `grok login` / `XAI_API_KEY` pour les modèles Grok natifs et les fonctions qui contactent directement xAI. ## Recette manuelle (sans enregistrement automatique) -Si vous gérez `~/.grok/config.toml` vous-même — ou si opencodex est sur une liaison sans bouclage — ajoutez -tables par modèle avec **champs directs**, en dehors des marqueurs `# >>> opencodex managed block` : +Si vous gérez `~/.grok/config.toml` vous-même — ou si opencodex est sur une liaison sans bouclage — ajoutez un bloc +`[model_providers.opencodex]` et des tables par modèle qui le référencent via `model_provider`, en dehors des +marqueurs `# >>> opencodex managed block` : ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Pour un proxy joignable sur le réseau, pointer `base_url` à l'adresse `grok` peut effectivement -composez et utilisez votre jeton d'entrée : +Pour un proxy joignable sur le réseau, pointez `base_url` vers l’adresse que `grok` peut réellement +joindre et utilisez votre jeton d’entrée : ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Ne comptez pas sur l'héritage `[model_providers.]` pour le point de terminaison : à partir de Grok Build -0.2.101 le `base_url` hérité n'est pas appliqué au routage d'inférence (les requêtes tombent -jusqu'au proxy xAI par défaut et échoue avec 401). Itinéraire direct des champs par modèle -correctement. +Le bloc géré utilise désormais l’héritage `[model_providers.]`, ce qui nécessite Grok Build 0.2.109 ou ultérieur (publié le 2026-07-21). Sur les versions antérieures, le `base_url` hérité n’est pas appliqué au routage d’inférence — mettez à niveau, ou utilisez des champs directs par modèle (`base_url`/`api_backend`/`api_key` sur chaque table `[model.*]`). -Placez entre guillemets tout alias contenant un point : `[model.grok-4.5]` sans guillemets est un chemin de clé à trois segments, et non -l'identifiant `grok-4.5`. Les alias générés évitent entièrement les points pour cette raison. +Placez entre guillemets tout alias contenant un point : `[model.grok-4.5]` sans guillemets est un chemin de clé à trois segments, et non l'identifiant `grok-4.5`. Les alias générés évitent entièrement les points pour cette raison. ## Limitations connues diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index f331f608d2..eb60ed3f4c 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -15,13 +15,16 @@ into `~/.grok/config.toml`: ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -102,44 +106,51 @@ outside the managed markers, where nothing opencodex does can clobber them. See `base_url` (a host that is actually reachable from where you run `grok`) and `api_key` (your `OPENCODEX_API_AUTH_TOKEN`). -Do not replace `api_key` with `env_key` here. With no `model_provider` set, an `env_key` -that fails to resolve does not stop the request — Grok falls through to your xAI session +Do not replace `api_key` with `env_key` here. An `env_key` that fails to resolve does not +stop the request — Grok falls through to your xAI session token and sends it to whatever `base_url` the entry names, which for a LAN deployment is a plaintext HTTP endpoint that is not xAI. -The injected per-model `api_key` sits first in Grok's credential chain for these models, -so turns against opencodex need no additional Grok login. Keep your normal `grok login` / -`XAI_API_KEY` setup for native grok models and any harness features that contact xAI -directly. +The injected `api_key` on the provider entry sits first in Grok's credential chain for +these models, so turns against opencodex need no additional Grok login. Keep your normal +`grok login` / `XAI_API_KEY` setup for native grok models and any harness features that +contact xAI directly. ## Manual recipe (without auto-registration) If you manage `~/.grok/config.toml` yourself — or opencodex is on a non-loopback bind — add -per-model tables with **direct fields**, outside the `# >>> opencodex managed block` markers: +a `[model_providers.opencodex]` block and per-model tables that reference it, outside the +`# >>> opencodex managed block` markers: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` For a proxy reachable over the network, point `base_url` at the address `grok` can actually dial and use your admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Do not rely on `[model_providers.]` inheritance for the endpoint: as of Grok Build -0.2.101 the inherited `base_url` is not applied to inference routing (requests fall -through to the default xAI proxy and fail with 401). Direct per-model fields route -correctly. +This uses `[model_providers.]` inheritance, which requires Grok Build 0.2.109 or later +(released 2026-07-21). On older versions the inherited `base_url` is not applied to inference +routing — upgrade, or fall back to per-model direct fields (`base_url`/`api_backend`/`api_key` +on each `[model.*]` table). Quote any alias containing a dot: bare `[model.grok-4.5]` is a three-segment key path, not the id `grok-4.5`. Generated aliases avoid dots entirely for this reason. diff --git a/docs-site/src/content/docs/ja/guides/grok-build.md b/docs-site/src/content/docs/ja/guides/grok-build.md index d40efd2633..b05edc14d7 100644 --- a/docs-site/src/content/docs/ja/guides/grok-build.md +++ b/docs-site/src/content/docs/ja/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex はローカル ポート上で OpenAI 互換の `POST /v1/chat/comple ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -67,13 +71,11 @@ Grok 互換に投影した内容が、管理対象の各 `[model.*]` テーブ 保持されます。Codex 固有の `ultra` を含む、未対応または重複する段階はファイルから 除外され、出力された選択肢はすべて実行できます。 -Grok Build は Chat Completions 経由で opencodex と通信し、ラダーが公開されている -場合は `reasoning_effort` を送ります。Chat Completions の入力変換は、この場合に -内部 Responses の `reasoning.summary` を `auto` に設定するため、推論トレースは -`delta.reasoning_content` として Grok に届きます。トレースを返さずにモデルに -推論させるクライアントは、`include_reasoning: false`(または -`reasoning.summary: "none"`)を設定できます。両方が指定された場合は、明示的な -`reasoning.summary` が優先されます。 +Grok Build は Responses API 経由で opencodex と通信します。ルートが推論ラダーを +公開している場合、Responses パススルーは設定どおりに `reasoning.summary` を転送するため、 +推論トレースは Responses の reasoning 項目としてそのまま Grok に届きます。トレースを +返さずにモデルに推論させるクライアントは、`reasoning.summary: "none"` を設定できます。 +明示的な `reasoning.summary` はルートの既定値より優先されます。 ## 認証メモ @@ -81,33 +83,39 @@ Grok Build では、ループバックでもカスタム モデルに対して **自動登録はループバックのみです。** opencodex が非ループバック ホスト (すべてのインターフェイスを公開するワイルドカード `0.0.0.0` および `::` を含む) をバインドする場合、リクエストには実際のアドミッション トークンが必要であり、マネージド ブロックはそれを安全に運ぶことができません。リテラルトークンを書き込むと、シークレットが `~/.grok/config.toml` に設定され、そこで設定した内容が次の `ocx start`/`ensure`/`restart` に上書きされます。したがって、その場合、opencodex は何も書き込みません (そして、以前のループバック バインドで残ったブロックはすべて削除します)。また、管理対象マーカーの外側でモデルを自分で設定します。opencodex が何をしてもモデルを破壊することはありません。正確なテーブルについては [マニュアルレシピ](#manual-recipe-without-auto-registration) を参照し、`base_url` (`grok` を実行する場所から実際に到達可能なホスト) と `api_key` (`OPENCODEX_API_AUTH_TOKEN`) の両方を設定します。 -ここで `api_key` を `env_key` に置き換えないでください。 `model_provider` が設定されていない場合、解決に失敗した `env_key` はリクエストを停止しません。Grok は xAI セッション トークンに到達し、それをエントリ名が `base_url` に送信します。LAN デプロイメントの場合、これは xAI ではないプレーンテキスト HTTP エンドポイントです。 +ここで `api_key` を `env_key` に置き換えないでください。解決に失敗した `env_key` はリクエストを停止しません。Grok は xAI セッション トークンに到達し、それをエントリ名が `base_url` に送信します。LAN デプロイメントの場合、これは xAI ではないプレーンテキスト HTTP エンドポイントです。 -注入されたモデルごとの `api_key` は、これらのモデルの Grok 資格情報チェーンの最初に位置するため、opencodex に対抗する場合は追加の Grok ログインは必要ありません。ネイティブ grok モデルおよび xAI に直接接続するハーネス機能については、通常の `grok login` / `XAI_API_KEY` セットアップを維持します。 +プロバイダー エントリに注入された `api_key` は、これらのモデルの Grok 資格情報チェーンの最初に位置するため、opencodex に対抗する場合は追加の Grok ログインは必要ありません。ネイティブ grok モデルおよび xAI に直接接続するハーネス機能については、通常の `grok login` / `XAI_API_KEY` セットアップを維持します。 ## 手動レシピ(自動登録なし) -`~/.grok/config.toml` を自分で管理する場合、または opencodex が非ループバック バインド上にある場合は、**直接フィールド**を持つモデルごとのテーブルを `# >>> opencodex managed block` マーカーの外側に追加します。 +`~/.grok/config.toml` を自分で管理する場合、または opencodex が非ループバック バインド上にある場合は、`[model_providers.opencodex]` ブロックとそれを参照するモデルごとのテーブルを `# >>> opencodex managed block` マーカーの外側に追加します。 ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` ネットワーク経由で到達可能なプロキシの場合は、`grok` が実際にダイヤルしてアドミッション トークンを使用できるアドレスに `base_url` を指定します。 ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -エンドポイントの `[model_providers.]` 継承に依存しないでください。Grok Build 0.2.101 では、継承された `base_url` は推論ルーティングに適用されません (リクエストはデフォルトの xAI プロキシにフォールスルーされ、401 で失敗します)。モデルごとのフィールドを正しくルーティングします。 +管理ブロックは `[model_providers.]` 継承を使用するようになり、Grok Build 0.2.109 以降(2026-07-21 リリース)が必要です。旧バージョンでは継承された `base_url` は推論ルーティングに適用されません——アップグレードするか、各 `[model.*]` テーブルでモデルごとの直接フィールド(`base_url`/`api_backend`/`api_key`)を使用してください。 ドットを含むエイリアスを引用符で囲みます。裸の `[model.grok-4.5]` は 3 セグメントのキー パスであり、ID `grok-4.5` ではありません。この理由により、生成されたエイリアスではドットが完全に回避されます。 diff --git a/docs-site/src/content/docs/ko/guides/grok-build.md b/docs-site/src/content/docs/ko/guides/grok-build.md index c91481e0e1..e8d56cd555 100644 --- a/docs-site/src/content/docs/ko/guides/grok-build.md +++ b/docs-site/src/content/docs/ko/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex는 로컬 포트에서 OpenAI 호환 `POST /v1/chat/completions`(및 ` ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -62,13 +66,11 @@ Grok 호환 형태로 투영한 결과가 각 관리형 `[model.*]` 테이블에 Codex 전용 `ultra`를 포함해 지원되지 않거나 중복된 단계는 파일에서 제외되어 기록된 모든 선택지는 실행 가능합니다. -Grok Build는 Chat Completions를 통해 opencodex와 통신하고 단계 목록이 제공되면 -`reasoning_effort`를 보냅니다. 이 경우 Chat Completions 입력 변환기는 내부 Responses의 -`reasoning.summary` 기본값을 `auto`로 설정하므로 추론 트레이스가 -`delta.reasoning_content`로 Grok에 전달됩니다. 모델은 추론하되 트레이스를 반환하지 -않도록 하려는 클라이언트는 `include_reasoning: false`(또는 -`reasoning.summary: "none"`)를 설정할 수 있습니다. 두 값이 함께 있으면 명시적인 -`reasoning.summary`가 우선합니다. +Grok Build는 Responses API를 통해 opencodex와 통신합니다. 라우트가 추론 단계 목록을 +광고하면 Responses passthrough가 설정된 대로 `reasoning.summary`를 전달하므로 추론 +트레이스가 Responses reasoning 항목으로 Grok에 그대로 도착합니다. 모델은 추론하되 +트레이스를 반환하지 않게 하려는 클라이언트는 `reasoning.summary: "none"`을 설정할 수 +있습니다. 명시적인 `reasoning.summary`는 라우트 기본값보다 우선합니다. ## 인증 참고 @@ -76,33 +78,39 @@ Grok Build는 루프백에서도 사용자 정의 모델에 비어 있지 않은 **자동 등록은 루프백 전용입니다.** opencodex가 비루프백 호스트에 바인드하면, 모든 인터페이스를 노출하는 와일드카드 `0.0.0.0`와 `::`를 포함해 요청은 실제 admission token을 필요로 하고, 관리 블록은 그 값을 안전하게 담을 수 없습니다. 토큰을 그대로 쓰면 비밀값이 `~/.grok/config.toml`에 들어가고, 다음 `ocx start`/`ensure`/`restart` 때 그 자리에 있던 값이 덮어써집니다. 그래서 opencodex는 그런 경우 아무 것도 쓰지 않고(이전에 루프백 바인드가 남긴 블록도 제거합니다), 사용자는 관리 마커 바깥에서 모델을 직접 설정해야 합니다. 이 위치에서는 opencodex가 어떤 일을 해도 그 설정을 덮어쓸 수 없습니다. 정확한 테이블은 [수동 설정](#manual-recipe-without-auto-registration)을 보시고, `base_url`(실제로 `grok`가 도달할 수 있는 호스트)과 `api_key`(사용자의 `OPENCODEX_API_AUTH_TOKEN`)를 함께 설정합니다. -여기서는 `api_key`를 `env_key`로 바꾸지 마십시오. `model_provider`를 설정하지 않은 상태에서 `env_key`가 해결되지 않아도 요청은 멈추지 않습니다. Grok가 사용자의 xAI 세션 토큰으로 넘어가서 항목이 가리키는 `base_url`로 보냅니다. LAN 배포에서는 그 `base_url`이 xAI가 아닌 평문 HTTP 엔드포인트입니다. +여기서는 `api_key`를 `env_key`로 바꾸지 마십시오. `env_key`가 해결되지 않아도 요청은 멈추지 않습니다. Grok가 사용자의 xAI 세션 토큰으로 넘어가서 항목이 가리키는 `base_url`로 보냅니다. LAN 배포에서는 그 `base_url`이 xAI가 아닌 평문 HTTP 엔드포인트입니다. -주입된 모델별 `api_key`는 이 모델들에 대한 Grok의 자격 증명 체인에서 가장 먼저 사용되므로, opencodex를 대상으로 하는 요청에는 추가 Grok 로그인이 필요하지 않습니다. xAI에 직접 접속하는 네이티브 grok 모델과 모든 하니스 기능에는 평소 쓰던 `grok login` / `XAI_API_KEY` 구성을 그대로 유지합니다. +provider 항목에 주입된 `api_key`는 이 모델들에 대한 Grok의 자격 증명 체인에서 가장 먼저 사용되므로, opencodex를 대상으로 하는 요청에는 추가 Grok 로그인이 필요하지 않습니다. xAI에 직접 접속하는 네이티브 grok 모델과 모든 하니스 기능에는 평소 쓰던 `grok login` / `XAI_API_KEY` 구성을 그대로 유지합니다. ## 수동 설정 (자동 등록 없음) -직접 `~/.grok/config.toml`를 관리하거나 opencodex가 비루프백 호스트에 바인드되어 있다면, `# >>> opencodex managed block` 마커 바깥에 모델별 테이블을 직접 필드 형태로 작성합니다: +직접 `~/.grok/config.toml`를 관리하거나 opencodex가 비루프백 호스트에 바인드되어 있다면, `# >>> opencodex managed block` 마커 바깥에 `[model_providers.opencodex]` 블록과 이를 참조하는 모델별 테이블을 추가합니다: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 네트워크에서 닿을 수 있는 프록시라면 `base_url`을 `grok`가 실제로 연결할 수 있는 주소로 두고 승인 토큰을 사용합니다: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -`[model_providers.]` 상속에 엔드포인트를 맡기지 마십시오. Grok Build 0.2.101 기준으로 상속된 `base_url`은 추론 라우팅에 적용되지 않습니다(요청은 기본 xAI 프록시로 넘어가고 401로 실패합니다). 직접 넣은 모델별 필드는 정상적으로 라우팅됩니다. +관리 블록은 이제 `[model_providers.]` 상속을 사용하며, Grok Build 0.2.109 이상(2026-07-21 출시)이 필요합니다. 이전 버전에서는 상속된 `base_url`이 추론 라우팅에 적용되지 않습니다 — 업그레이드하거나, 각 `[model.*]` 테이블에 모델별 직접 필드(`base_url`/`api_backend`/`api_key`)를 사용하세요. 점이 들어간 별칭은 반드시 따옴표로 감쌉니다. 대괄호만 쓴 `[model.grok-4.5]`는 id `grok-4.5`가 아니라 세 구간짜리 키 경로입니다. 생성된 별칭은 이런 이유로 점을 아예 쓰지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/grok-build.md b/docs-site/src/content/docs/ru/guides/grok-build.md index 4fd3ee9c21..bd67ff17ce 100644 --- a/docs-site/src/content/docs/ru/guides/grok-build.md +++ b/docs-site/src/content/docs/ru/guides/grok-build.md @@ -15,13 +15,16 @@ Grok Build — вручную редактировать конфигураци ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -74,12 +78,12 @@ Grok проекция этой шкалы записывается в кажду когда модель их объявляет. Неподдерживаемые или повторяющиеся уровни, в том числе предназначенный для Codex `ultra`, исключаются из файла; каждый записанный пункт остаётся доступным для выбора. -Grok Build обращается к opencodex через Chat Completions и отправляет `reasoning_effort`, когда -шкала опубликована. В этом случае входной преобразователь Chat Completions задаёт внутреннему -Responses `reasoning.summary` значение `auto`, поэтому трассировка рассуждений приходит в Grok -как `delta.reasoning_content`. Клиент может оставить рассуждение модели и скрыть трассировку с -помощью `include_reasoning: false` (или `reasoning.summary: "none"`). При наличии обоих -параметров приоритет имеет явно заданный `reasoning.summary`. +Grok Build обращается к opencodex через Responses API. Когда маршрут объявляет шкалу +рассуждений, passthrough Responses пересылает `reasoning.summary` в соответствии с настройкой, +поэтому трассировка рассуждений доходит до Grok нативно в виде элементов reasoning Responses. +Клиент может оставить рассуждение модели и скрыть трассировку с помощью +`reasoning.summary: "none"`. Явно заданный `reasoning.summary` имеет приоритет над значением +по умолчанию для маршрута. ## Замечание об аутентификации @@ -98,12 +102,12 @@ admission token, а управляемый блок не может безопа действительно достижим из того места, где вы запускаете `grok`, и в `api_key` укажите `OPENCODEX_API_AUTH_TOKEN`. -Не заменяйте здесь `api_key` на `env_key`. Если `model_provider` не задан, `env_key`, который не +Не заменяйте здесь `api_key` на `env_key`. `env_key`, который не разрешился, не останавливает запрос — Grok откатывается к вашему session token xAI и отправляет его на любой `base_url`, указанный в записи, а для LAN-развёртывания это plaintext HTTP-endpoint, который не является xAI. -Внедрённый `api_key` на уровне модели стоит первым в цепочке учётных данных Grok для этих моделей, +Внедрённый в запись провайдера `api_key` стоит первым в цепочке учётных данных Grok для этих моделей, поэтому ходам через opencodex не нужен дополнительный `grok login`. Обычную настройку `grok login` / `XAI_API_KEY` сохраняйте для нативных grok-моделей и любых harness-функций, которые напрямую обращаются к xAI. @@ -111,31 +115,35 @@ admission token, а управляемый блок не может безопа ## Ручной рецепт без авторегистрации Если вы управляете `~/.grok/config.toml` сами — либо opencodex привязан не к loopback, — -добавляйте таблицы по одной модели с **прямыми полями**, вне маркеров -`# >>> opencodex managed block`: +добавляйте блок `[model_providers.opencodex]` и таблицы по одной модели, которые его +ссылают, вне маркеров `# >>> opencodex managed block`: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` Для прокси, доступного по сети, укажите в `base_url` адрес, до которого `grok` реально может дозвониться, и используйте свой admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Не полагайтесь на наследование `[model_providers.]` для endpoint'а: по состоянию на Grok Build -0.2.101 унаследованный `base_url` не применяется к маршрутизации inference (запросы откатываются к -прокси xAI по умолчанию и падают с 401). Прямые поля на уровне модели маршрутизируются правильно. +Управляемый блок теперь использует наследование `[model_providers.]`, что требует Grok Build 0.2.109 или новее (выпущен 2026-07-21). На более старых версиях унаследованный `base_url` не применяется к маршрутизации inference — обновитесь, либо используйте прямые поля на уровне модели (`base_url`/`api_backend`/`api_key` в каждой таблице `[model.*]`). Любой alias, содержащий точку, берите в кавычки: голый `[model.grok-4.5]` — это путь из трёх сегментов, а не id `grok-4.5`. Сгенерированные alias по этой причине вообще избегают точек. diff --git a/docs-site/src/content/docs/tr/guides/grok-build.md b/docs-site/src/content/docs/tr/guides/grok-build.md index d23689dd8a..f9e8a7d6c5 100644 --- a/docs-site/src/content/docs/tr/guides/grok-build.md +++ b/docs-site/src/content/docs/tr/guides/grok-build.md @@ -16,13 +16,16 @@ gerekmez. ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -33,7 +36,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -80,13 +84,12 @@ geçerli Grok katmanları, `none` ve `minimal` dahil olmak üzere korunur. Codex `ultra` dahil desteklenmeyen veya yinelenen katmanlar dosyadan çıkarılır; yazılan her seçenek seçilebilir durumda kalır. -Grok Build, opencodex ile Chat Completions üzerinden konuşur ve merdiven -bildirildiğinde `reasoning_effort` gönderir. Bu durumda Chat Completions giriş -dönüştürücüsü, dahili Responses `reasoning.summary` değerini varsayılan olarak -`auto` yapar; böylece akıl yürütme izleri Grok'a `delta.reasoning_content` -olarak ulaşır. Modelin akıl yürütmesini sürdürüp izi gizlemek isteyen bir istemci -`include_reasoning: false` (veya `reasoning.summary: "none"`) ayarlayabilir. Her -iki seçenek de bulunduğunda açıkça belirtilen `reasoning.summary` önceliklidir. +Grok Build, opencodex ile Responses API üzerinden konuşur. Bir rota akıl yürütme +merdivenini bildirdiğinde, Responses passthrough `reasoning.summary` değerini +yapılandırıldığı şekilde iletir; böylece akıl yürütme izleri Responses reasoning +öğeleri olarak Grok'a doğrudan ulaşır. Modelin akıl yürütmesini sürdürüp izi +gizlemek isteyen bir istemci `reasoning.summary: "none"` ayarlayabilir. +Açıkça belirtilen `reasoning.summary`, rotanın varsayılan değerine üstünlük tanır. ## Kimlik doğrulama notu @@ -109,13 +112,13 @@ tarif](#otomatik-kayit-olmadan-manuel-tarif) bölümüne bakın ve hem `base_url (gerçekte `grok` çalıştırdığınız yerden erişilebilen bir ana bilgisayar) hem de `api_key` (`OPENCODEX_API_AUTH_TOKEN` değeriniz) ayarlayın. -Burada `api_key`'i `env_key` ile değiştirmeyin. `model_provider` -ayarlanmadığında, çözümlenemeyen bir `env_key` isteği durdurmaz — Grok, xAI +Burada `api_key`'i `env_key` ile değiştirmeyin. Çözümlenemeyen bir +`env_key` isteği durdurmaz — Grok, xAI oturum belirtecinize geri döner ve bunu girdinin adlandırdığı `base_url`'e gönderir; bu da bir LAN dağıtımı için xAI olmayan düz metin bir HTTP uç noktasıdır. -Enjekte edilen model başına `api_key`, bu modeller için Grok'un kimlik bilgisi +Provider girdisine enjekte edilen `api_key`, bu modeller için Grok'un kimlik bilgisi zincirinde ilk sırada yer alır; bu nedenle opencodex'e karşı yapılan dönüşler ek bir Grok girişi gerektirmez. Yerel grok modelleri ve doğrudan xAI ile iletişim kuran herhangi bir donanım özelliği için normal `grok login` / `XAI_API_KEY` @@ -125,31 +128,35 @@ kurulumunuzu koruyun. `~/.grok/config.toml` dosyasını kendiniz yönetiyorsanız — veya opencodex geri döngü olmayan bir bağlantıdaysa — `# >>> opencodex managed block` -işaretçilerinin dışına **doğrudan alanlarla** model başına tablolar ekleyin: +işaretçilerinin dışına bir `[model_providers.opencodex]` bloğu ve bunu +referans alan model başına tablolar ekleyin: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` Ağ üzerinden erişilebilen bir proxy için `base_url`'i `grok`'un gerçekten -çevirebileceği adrese yönlendirin ve kabul belirtecinizi kullanın: +bağlanabileceği adrese yönlendirin ve kabul belirtecinizi kullanın: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # 127.0.0.1 değil, erişilebilir ana bilgisayar api_backend = "responses" api_key = "OPENCODEX_API_AUTH_TOKEN_DEGERINIZ" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Uç nokta için `[model_providers.]` kalıtımına güvenmeyin: Grok Build 0.2.101 -itibarıyla devralınan `base_url` çıkarım yönlendirmesine uygulanmaz (istekler -varsayılan xAI proxy'sine düşer ve 401 ile başarısız olur). Doğrudan model -başına alanlar doğru şekilde yönlendirilir. +Yönetilen blok artık `[model_providers.]` kalıtımını kullanıyor, bu da Grok Build 0.2.109 veya sonrasını gerektirir (2026-07-21'de yayınlandı). Eski sürümlerde devralınan `base_url` çıkarım yönlendirmesine uygulanmaz — yükseltin veya her `[model.*]` tablosunda model başına doğrudan alanlar (`base_url`/`api_backend`/`api_key`) kullanın. Nokta içeren herhangi bir takma adı tırnak içine alın: yalın `[model.grok-4.5]`, `grok-4.5` kimliği değil, üç segmentli bir anahtar yoludur. Oluşturulan takma diff --git a/docs-site/src/content/docs/zh-cn/guides/grok-build.md b/docs-site/src/content/docs/zh-cn/guides/grok-build.md index 97ed853d54..2c7babb4c3 100644 --- a/docs-site/src/content/docs/zh-cn/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-cn/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex 在本地端口提供一个与 OpenAI 兼容的 `POST /v1/chat/complet ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -59,12 +63,10 @@ opencodex 会映射已配置的提供方档位(`reasoningEfforts` / 模型声明的有效 Grok 档位(包括 `none` 和 `minimal`)都会保留。不受支持或重复的档位 (包括 Codex 专用的 `ultra`)会从文件中省略,从而确保写出的每个选项都可执行。 -Grok Build 通过 Chat Completions 与 opencodex 通信,并在声明档位时发送 -`reasoning_effort`。在这种情况下,Chat Completions 入站转换器会将内部 Responses 的 -`reasoning.summary` 默认设为 `auto`,因此推理轨迹会以 `delta.reasoning_content` -到达 Grok。需要模型执行推理且不返回轨迹的客户端,可以设置 -`include_reasoning: false`(或 `reasoning.summary: "none"`)。两个选项同时出现时, -显式的 `reasoning.summary` 优先。 +Grok Build 通过 Responses API 与 opencodex 通信。当路由声明推理档位时,Responses +直通会按配置转发 `reasoning.summary`,因此推理轨迹会以 Responses reasoning 项的形式 +原生到达 Grok。需要模型执行推理且不返回轨迹的客户端,可以设置 +`reasoning.summary: "none"`。显式设置的 `reasoning.summary` 优先于路由默认值。 ## 认证说明 @@ -72,33 +74,39 @@ Grok Build 通过 Chat Completions 与 opencodex 通信,并在声明档位时 **自动注册仅限 loopback。** 当 opencodex 绑定到非 loopback 主机时——包括通配符 `0.0.0.0` 和 `::`,它们会暴露所有网卡——请求需要你的真实接入令牌,而受管理区块无法安全地携带它。把字面令牌写进去会把你的密钥放进 `~/.grok/config.toml`,并在下次 `ocx start`/`ensure`/`restart` 时覆盖你在那里设置的内容。所以在这种情况下,opencodex 根本不会写入任何内容(并且会移除早先 loopback 绑定留下的任何区块),然后你需要在受管理标记之外自己配置这些模型,因为 opencodex 在那里做的任何事都不会覆盖它们。精确表结构见[手动方案](#manual-recipe-without-auto-registration),并同时设置 `base_url`(从你运行 `grok` 的位置实际可达的主机)和 `api_key`(你的 `OPENCODEX_API_AUTH_TOKEN`)。 -不要在这里把 `api_key` 换成 `env_key`。在未设置 `model_provider` 的情况下,解析失败的 `env_key` 不会阻止请求——Grok 会回退到你的 xAI 会话令牌,并把它发送到该条目指定的 `base_url`,而对于局域网部署来说,这通常是一个并非 xAI 的明文 HTTP 端点。 +不要在这里把 `api_key` 换成 `env_key`。解析失败的 `env_key` 不会阻止请求——Grok 会回退到你的 xAI 会话令牌,并把它发送到该条目指定的 `base_url`,而对于局域网部署来说,这通常是一个并非 xAI 的明文 HTTP 端点。 -这些模型注入的逐模型 `api_key` 会在 Grok 的凭据链中排在首位,因此对接 opencodex 时不需要额外登录 Grok。原生 grok 模型以及任何会直接联系 xAI 的 harness 功能,仍然保留你正常的 `grok login` / `XAI_API_KEY` 配置。 +注入在 provider 条目上的 `api_key` 会在这些模型的 Grok 凭据链中排在首位,因此对接 opencodex 时不需要额外登录 Grok。原生 grok 模型以及任何会直接联系 xAI 的 harness 功能,仍然保留你正常的 `grok login` / `XAI_API_KEY` 配置。 ## 手动方案(不使用自动注册) -如果你自己管理 `~/.grok/config.toml`——或者 opencodex 绑定在非 loopback 地址上——请在 `# >>> opencodex managed block` 标记之外,添加带有**直接字段**的逐模型表: +如果你自己管理 `~/.grok/config.toml`——或者 opencodex 绑定在非 loopback 地址上——请在 `# >>> opencodex managed block` 标记之外,添加一个 `[model_providers.opencodex]` 区块以及引用它的逐模型表: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 如果代理可通过网络访问,请把 `base_url` 指向 `grok` 实际可以连接的地址,并使用你的接入令牌: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -不要依赖 `[model_providers.]` 继承来提供端点:截至 Grok Build 0.2.101,继承下来的 `base_url` 不会应用到推理路由(请求会落回默认的 xAI 代理,并以 401 失败)。直接在逐模型字段中配置可以正确路由。 +托管区块现在使用 `[model_providers.]` 继承,需要 Grok Build 0.2.109 或更高版本(发布于 2026-07-21)。在更早的版本上,继承的 `base_url` 不会应用到推理路由——请升级,或在每个 `[model.*]` 表上使用逐模型直接字段(`base_url`/`api_backend`/`api_key`)。 任何包含点号的别名都要加引号:裸写的 `[model.grok-4.5]` 是一个三段式键路径,而不是 id `grok-4.5`。为此,生成的别名会完全避免使用点号。 diff --git a/docs-site/src/content/docs/zh-tw/guides/grok-build.md b/docs-site/src/content/docs/zh-tw/guides/grok-build.md index e0d2320df4..a8f4350873 100644 --- a/docs-site/src/content/docs/zh-tw/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-tw/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex 在本機埠提供 OpenAI 相容的 `POST /v1/chat/completions`(以 ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -58,11 +62,10 @@ reasoning,或把檔位對映到供應商專用欄位。階梯清單為空的 條目會保留固定於上游的 reasoning 階梯。模型宣告的有效 Grok 檔位(包括 `none` 與 `minimal`)都會 保留。不受支援或重複的檔位(包括 Codex 專用的 `ultra`)會從檔案省略,確保寫出的每個選項都能實際使用。 -Grok Build 透過 Chat Completions 與 opencodex 通訊,並在條目宣告階梯時送出 -`reasoning_effort`。在這種情況下,Chat Completions 入站轉換器會把內部 Responses 的 -`reasoning.summary` 預設設為 `auto`,因此推理軌跡會以 `delta.reasoning_content` 傳給 Grok。 -需要模型執行推理且不回傳軌跡的用戶端,可以設定 `include_reasoning: false`(或 -`reasoning.summary: "none"`)。兩個選項同時出現時,明確設定的 `reasoning.summary` 優先。 +Grok Build 透過 Responses API 與 opencodex 通訊。當路由宣告推理階梯時,Responses 直通會按 +設定轉發 `reasoning.summary`,因此推理軌跡會以 Responses reasoning 項目的形式原生送達 Grok。 +需要模型執行推理且不回傳軌跡的用戶端,可以設定 `reasoning.summary: "none"`。明確設定的 +`reasoning.summary` 優先於路由預設值。 ## 認證注意事項 @@ -70,33 +73,39 @@ Grok Build 透過 Chat Completions 與 opencodex 通訊,並在條目宣告階 **自動註冊僅限 loopback。** 當 opencodex 綁定非 loopback 主機時——包含會暴露所有介面的萬用字元 `0.0.0.0` 與 `::`——請求需要你的真實 admission token,而受管理區塊無法安全地承載它。把字面 token 寫進去會把你的金鑰放進 `~/.grok/config.toml`,並在下一次 `ocx start`/`ensure`/`restart` 時覆寫你在那裡設定的任何內容。因此在這種情況下 opencodex 完全不寫入(並會移除先前 loopback 綁定留下的任何區塊),而你要在受管理標記之外自行設定模型,opencodex 就無法覆寫它們。精確的表格請見[手動配方](#manual-recipe-without-auto-registration),並同時設定 `base_url`(你執行 `grok` 之處實際可達的主機)與 `api_key`(你的 `OPENCODEX_API_AUTH_TOKEN`)。 -此處不要用 `env_key` 取代 `api_key`。在未設定 `model_provider` 時,無法解析的 `env_key` 不會中止請求——Grok 會回退到你的 xAI 工作階段 token,並把它送到該項目所命名的任何 `base_url`;對 LAN 部署而言,那是一個並非 xAI 的明文 HTTP 端點。 +此處不要用 `env_key` 取代 `api_key`。無法解析的 `env_key` 不會中止請求——Grok 會回退到你的 xAI 工作階段 token,並把它送到該項目所命名的任何 `base_url`;對 LAN 部署而言,那是一個並非 xAI 的明文 HTTP 端點。 -注入的 per-model `api_key` 在這些模型的 Grok 憑證鏈中排在第一位,因此對 opencodex 的回合不需要額外的 Grok 登入。請為原生 grok 模型,以及任何直接聯絡 xAI 的 harness 功能,保留你平常的 `grok login` / `XAI_API_KEY` 設定。 +注入在 provider 條目上的 `api_key` 在這些模型的 Grok 憑證鏈中排在第一位,因此對 opencodex 的回合不需要額外的 Grok 登入。請為原生 grok 模型,以及任何直接聯絡 xAI 的 harness 功能,保留你平常的 `grok login` / `XAI_API_KEY` 設定。 ## 手動配方(不使用自動註冊) {#manual-recipe-without-auto-registration} -若你自行管理 `~/.grok/config.toml`——或 opencodex 綁定在非 loopback——請在 `# >>> opencodex managed block` 標記之外,以**直接欄位**新增 per-model 表格: +若你自行管理 `~/.grok/config.toml`——或 opencodex 綁定在非 loopback——請在 `# >>> opencodex managed block` 標記之外,新增一個 `[model_providers.opencodex]` 區塊以及引用它的 per-model 表格: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 對於可經由網路連線的代理程式,將 `base_url` 指向 `grok` 實際可撥號的位址,並使用你的 admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -不要依賴 `[model_providers.]` 繼承端點:截至 Grok Build 0.2.101,繼承的 `base_url` 並不會套用到推論路由(請求會回退到預設 xAI 代理並以 401 失敗)。直接的 per-model 欄位才能正確路由。 +託管區塊現在使用 `[model_providers.]` 繼承,需要 Grok Build 0.2.109 或更高版本(發布於 2026-07-21)。在更早的版本上,繼承的 `base_url` 不會套用到推論路由——請升級,或在每個 `[model.*]` 表上使用逐模型直接欄位(`base_url`/`api_backend`/`api_key`)。 含有點號的別名請加上引號:裸的 `[model.grok-4.5]` 是三段式鍵路徑,而不是 id `grok-4.5`。產生的別名因此完全避免點號。 diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 4f0f71964f..2e7681024b 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -27,10 +27,11 @@ export interface GrokInjectResult { const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; const END_MARKER = "# <<< opencodex managed block <<<"; -// grok 0.2.101 verified live (2026-07-23): [model_providers.] inheritance parses but the -// inherited base_url is NOT applied to inference routing — the turn falls through to the default -// cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its -// own base_url/api_backend/api_key and no [model_providers] table is emitted at all. +// Grok 0.2.109 (2026-07-21) shipped working [model_providers.] inheritance: base_url, +// api_backend, api_key, and extra_headers declared on the provider are applied to inference +// routing for inheriting models (verified in grok-build's with_provider_defaults → +// resolve_model_list → sampling_config_for_model → SamplingClient chain). We emit one shared +// [model_providers.opencodex] table and each [model.*] references it via model_provider. /** * INTERNAL API shared with `./inspect` (WP2, devlog 260803_integrations_toggle_all/012). @@ -325,6 +326,9 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set]` table outside the fence that opencodex itself wrote. */ interface OrphanTable { alias: string; @@ -343,16 +347,39 @@ interface OrphanTable { function tableBodyKeys(body: string): Map { const keys = new Map(); const structure = analyzeTomlStructure(body); - const assignment = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/gm; + // Bare keys only: a quoted dotted segment could otherwise split a quoted value + // containing a dot. + const assignment = + /^[ \t]*([A-Za-z0-9_-]+(?:[ \t]*\.[ \t]*[A-Za-z0-9_-]+)*)[ \t]*=[ \t]*(.*?)[ \t]*$/gm; for (const match of structure.view.matchAll(assignment)) { if (!structure.containerRootLineStarts.has(match.index!)) continue; + const path = match[1]!.split(".").map(part => part.trim()); const raw = match[2]!; const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? decodeTomlBasicString(raw.slice(1, -1)) : raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) // TOML literal strings do not process escapes. : raw; - if (!keys.has(match[1]!)) keys.set(match[1]!, value); + if (path.length === 1) { + if (!keys.has(path[0]!)) keys.set(path[0]!, value); + continue; + } + // Dotted keys re-open a nested namespace: `extra_headers.k = v` is the key `k` of the + // sub-table `extra_headers`, which a folded-body reader must be able to see. Rebuild + // the inline-table spelling that hasInlineOwnershipMarker matches (bare word booleans + // and numbers keep their TOML spelling — no quoting, so the regex is unchanged). + let suffix = value; + for (let level = path.length - 1; level >= 1; level -= 1) { + const prefixKey = path.slice(0, level).join("."); + const inner = `${JSON.stringify(path[level]!)} = ${suffix}`; + suffix = `{ ${inner} }`; + const existing = keys.get(prefixKey); + if (existing === undefined || !existing.startsWith("{")) { + keys.set(prefixKey, suffix); + } else { + keys.set(prefixKey, `{ ${existing.slice(2, -2)}, ${inner} }`); + } + } } return keys; } @@ -369,8 +396,10 @@ function isLoopbackBaseUrl(value: string | undefined): boolean { /** Exact marker emitted inside every modern generated model table. */ function hasInlineOwnershipMarker(value: string | undefined): boolean { + // The reconstructed fold of a Grok dotted re-serialization writes bare `1` for the + // boolean literal, so both `= "1"` and `= 1` spellings are accepted here. return value !== undefined - && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*["']1["'][ \t]*\}$/.test(value); + && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*(?:"1"|'1'|1)[ \t]*\}$/.test(value); } /** Historical deterministic alias, including collision suffixes allocated by the writer. */ @@ -425,8 +454,24 @@ function isDisabledProviderModelId( * remote host is left alone. * - `x-opencodex-grok = "1"` in generated inline/child extra_headers, OR the historical * chat_completions + `name = "OCX "` + deterministic generated alias shape. + * - PROVIDER-INHERITANCE shape: `model_provider = "opencodex"` with no api_key/base_url of + * its own, adopting the verdict of the `[model_providers.opencodex]` table it references + * (the current block shape carries no per-model evidence; this mirrors Codex-side + * classifyCodexRouting). * A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a * legitimate thing to do. + * + * Also sweeps orphaned `[model_providers.opencodex]` blocks from a previous managed block + * that used the provider-inheritance shape. Grok's re-serializer promotes the inline + * `extra_headers = { ... }` into a separate `[model_providers..extra_headers]` + * sub-table, which can split the parent's body (its own keys then live inside the child's + * span) and may interleave user tables between the parent and its children, so each + * provider's body is FOLDED with all its same-provider descendants before judging, and + * the removal span covers them by their exact ranges. The fenced provider is excluded + * from that sweep (the splice owns it) but still counts as ownership evidence, so + * teardown does not orphan models that inherit from it. The + * durable marker lives on the provider (never on the inheriting model), so the sweep is + * what keeps explicit ownership of inherited entries verifiable after a rewrite. */ function findOpencodexOrphans(content: string, region: ManagedRegion | null): OrphanTable[] { const orphans: OrphanTable[] = []; @@ -442,20 +487,94 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or fenceStart >= 0 && start < fenceStart ? Math.min(end, fenceStart) : end; // Collect every table header first: a table body runs to the NEXT header, whatever it is. const headers = analyzeTomlStructure(content).headers; + // [model_providers.] tables outside the fence, folded with their own sub-tables (see + // the function doc). A table passing the predicate (our api_key literal + a loopback + // base_url + the durable marker inline or in a re-serialized child) contributes to + // `ownedProviderIds` for the model scan below; one with OUR id is additionally swept as + // an orphan of a previous managed block (a leftover here collides with the regenerated + // block's provider table — duplicate key — and alias rewriting skips provider orphans + // because they have no alias and no model id). The dot-terminated prefix keeps a user's + // `[model_providers.opencodex_backup]` out of scope. + const ownedProviderIds = new Set(); + for (const [position, header] of headers.entries()) { + if (header.array || header.segments.length !== 2 || header.segments[0] !== "model_providers") continue; + // Inside the fence the regular splice owns the table, but it is still ownership + // evidence: models kept outside the fence after a Grok rewrite (retired ids) inherit + // their verdict from the fenced provider, so classification must happen while the + // fence still exists or teardown leaves them with a dangling model_provider reference. + const insideRegion = region !== null + && header.index >= region.start && header.index < region.end; + const end = clampEnd(header.index, headers[position + 1]?.index ?? content.length); + let body = content.slice(header.index + header.length, end); + // Re-serialized children may sit non-contiguously (a user table can interleave), so + // fold every same-provider descendant globally, like the model scan below, and remove + // them by their exact ranges. Never fold across the fence: a pre-fence parent must + // judge on pre-fence bytes only, and fenced or below-fence content is not the orphan's. + const additionalRanges: Array<{ start: number; end: number }> = []; + for (let next = 0; next < headers.length; next += 1) { + if (next === position) continue; + const child = headers[next]!; + if (region && child.index >= region.start && child.index < region.end) continue; + if (fenceStart >= 0 + && (header.index < fenceStart) !== (child.index < fenceStart)) continue; + if (child.segments.length <= 2 + || child.segments[0] !== "model_providers" + || child.segments[1] !== header.segments[1]) continue; + const childEnd = clampEnd(child.index, headers[next + 1]?.index ?? content.length); + body += "\n" + content.slice(child.index + child.length, childEnd); + additionalRanges.push({ start: child.index, end: childEnd }); + } + const keys = tableBodyKeys(body); + if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; + if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; + // The durable marker may sit inline on the provider, or be promoted by Grok's + // re-serializer into `[model_providers..extra_headers]` — where the folded body + // shows it as a bare `x-opencodex-grok = "1"` assignment. Both forms decide. + if (!hasInlineOwnershipMarker(keys.get("extra_headers")) + && keys.get(OPENCODEX_GROK_MARKER) !== "1") continue; + ownedProviderIds.add(header.segments[1]!); + if (insideRegion) continue; + if (header.segments[1] === OPENCODEX_PROVIDER_ID) { + orphans.push({ + alias: "", + modelId: "", + ownership: "explicit", + start: header.index, + end, + additionalRanges, + }); + } + } for (const [position, header] of headers.entries()) { if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; // Inside the fence the regular splice already owns it. if (region && header.index >= region.start && header.index < region.end) continue; const bodyEnd = clampEnd(header.index, headers[position + 1]?.index ?? content.length); const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd)); - if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; - if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; const modelId = keys.get("model"); if (!modelId) continue; + // Two shapes carry our ownership signal. The current managed block routes every model + // through a shared provider table (`model_provider = "opencodex"`), so a re-serialized + // unfenced entry has NO api_key/base_url of its own — the evidence lives on the provider + // table it references (Codex-side precedent: classifyCodexRouting follows model_provider + // for the same reason). Inheritance is accepted only from a provider that itself passed + // the strict predicate above, and only for rows whose alias carries the generated + // fingerprint: a user is free to reference the managed provider from their own + // [model.*] table, and inheritance alone must not grant removal authority over it. + const providerId = keys.get("model_provider"); + const inheritedOwned = + providerId === OPENCODEX_PROVIDER_ID + && ownedProviderIds.has(OPENCODEX_PROVIDER_ID) + && isGeneratedAliasForModel(header.segments[1]!, modelId); + if (!inheritedOwned) { + if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; + if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; + } let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers")); - // Swallow the entry's OWN sub-tables (`[model..extra_headers]`). Grok may - // re-serialize them non-contiguously, so collect exact descendant spans globally rather - // than stopping at the first unrelated table. + // Swallow the entry's OWN sub-tables (`[model..extra_headers]`, and after #1756 + // `[[model..reasoning_efforts]]`). Grok may re-serialize them non-contiguously, + // so collect exact descendant spans globally rather than stopping at the first + // unrelated table. const additionalRanges: Array<{ start: number; end: number }> = []; for (let next = 0; next < headers.length; next += 1) { if (next === position) continue; @@ -472,11 +591,17 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or } } const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys); - if (!hasOwnershipMarker && !legacyGenerated) continue; + // An inherited model has no per-model marker; its verdict comes from the provider + // table it references, which only lands here when that provider proved durable + // ownership. A legacy-fingerprint model keeps dev's conservative classification. + const ownership: "explicit" | "legacy" = inheritedOwned || hasOwnershipMarker + ? "explicit" + : "legacy"; + if (!hasOwnershipMarker && !legacyGenerated && !inheritedOwned) continue; orphans.push({ alias: header.segments[1]!, modelId, - ownership: hasOwnershipMarker ? "explicit" : "legacy", + ownership, start: header.index, end: bodyEnd, additionalRanges, @@ -877,6 +1002,15 @@ export function buildGrokManagedBlock( const baseUrl = `http://${host}:${port}/v1`; const lines = [ BEGIN_MARKER, + "", + `[model_providers.${OPENCODEX_PROVIDER_ID}]`, + `base_url = ${tomlString(baseUrl)}`, + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + // Best-effort attribution tag for the usage dashboard. Upstream Grok sends + // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a + // security boundary — any loopback client could send the same header. + 'extra_headers = { "x-opencodex-grok" = "1" }', ]; const aliasCounts = new Map(); const taken = new Set(reservedAliases ?? []); @@ -896,19 +1030,12 @@ export function buildGrokManagedBlock( // Slot consumed, table not written: this is what keeps every other alias stable // across selection changes. if (excluded?.has(model.id)) continue; - const isFirst = lines.length === 1; lines.push( - ...(isFirst ? [] : [""]), + "", `[model.${alias}]`, `model = ${tomlString(model.id)}`, - `base_url = ${tomlString(baseUrl)}`, - 'api_backend = "responses"', - 'api_key = "opencodex-loopback"', + `model_provider = ${tomlString(OPENCODEX_PROVIDER_ID)}`, `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`, - // Best-effort attribution tag for the usage dashboard. Upstream Grok sends - // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a - // security boundary — any loopback client could send the same header. - 'extra_headers = { "x-opencodex-grok" = "1" }', ); if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) { lines.push(`context_window = ${model.contextWindow}`); @@ -1015,16 +1142,21 @@ export function injectGrokConfig( .filter(model => !opts.excluded?.has(model.id)) .map(model => model.id)); const orphans = findOpencodexOrphans(originalContent, originalRegion) - .filter(orphan => orphan.ownership === "legacy" - // A legacy fingerprint is not durable deletion authority. Migrate it only when this - // same write will replace the row with a marked managed table. - ? emittedModelIds.has(orphan.modelId) - : catalogModelIds.has(orphan.modelId) - || isDisabledProviderModelId( - orphan.modelId, - opts.disabledProviderNamespaces, - opts.comboPublicModelIds, - )); + .filter(orphan => + // A provider table carries no alias and no model id: its strict predicate (our key + // + loopback + durable marker) is itself the deletion authority, and a leftover + // collides with the regenerated provider table (duplicate key). + orphan.alias === "" + || (orphan.ownership === "legacy" + // A legacy fingerprint is not durable deletion authority. Migrate it only when this + // same write will replace the row with a marked managed table. + ? emittedModelIds.has(orphan.modelId) + : catalogModelIds.has(orphan.modelId) + || isDisabledProviderModelId( + orphan.modelId, + opts.disabledProviderNamespaces, + opts.comboPublicModelIds, + ))); const content = removeOrphanTables(originalContent, orphans); // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic, // so the splice below cannot cut the file in the wrong place. @@ -1054,7 +1186,10 @@ export function injectGrokConfig( } const replacements = new Map(); for (const removed of [ - ...orphans.map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })), + // Provider orphans carry no alias and no model id: there is nothing to repoint, and + // an empty alias must never enter the rename map. + ...orphans.filter(orphan => orphan.alias !== "") + .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })), ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })), ]) { if (nextManagedModels.get(removed.alias) === removed.modelId) continue; @@ -1133,9 +1268,17 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes const tailOrphans = findOpencodexOrphans(restOfFile, null) .filter(orphan => orphan.ownership === "explicit"); const removedAliases = new Set( - [...fullOrphans, ...prefixOrphans, ...tailOrphans].map(orphan => orphan.alias), + [...fullOrphans, ...prefixOrphans, ...tailOrphans] + .map(orphan => orphan.alias) + // Provider orphans carry no alias; their ranges above already removed the table. + .filter(alias => alias !== ""), ); - orphanCount = removedAliases.size; + // The backup must cover every removed TABLE, not just aliased rows: provider-only + // orphans carry no alias and would otherwise be swept without any backup. + orphanCount = new Set( + [...fullOrphans, ...prefixOrphans, ...tailOrphans] + .flatMap(orphan => orphanRanges([orphan])), + ).size; const fullRanges = orphanRanges(fullOrphans); const prefixRanges = [ ...orphanRanges(prefixOrphans), @@ -1168,7 +1311,10 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes } orphanCount = orphans.length; stripped = removeOrphanTables(content, orphans); - stripped = removeAliasReferences(stripped, new Set(orphans.map(orphan => orphan.alias))); + stripped = removeAliasReferences( + stripped, + new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")), + ); } if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex")); atomicWriteFile(configPath, applyEol(stripped, eol)); diff --git a/src/grok/status.ts b/src/grok/status.ts index f42182063b..663c723a6f 100644 --- a/src/grok/status.ts +++ b/src/grok/status.ts @@ -65,22 +65,35 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus { let baseUrl: string | null = null; let current: GrokStatusModel | null = null; + // The provider block carries base_url in the current shape; per-model base_url is the + // legacy fallback for fences written before the model_providers migration. + let inProviderBlock = false; + for (const rawLine of region.split("\n")) { const line = rawLine.trim(); + const providerHeader = /^\[model_providers\.([^\]]+)\]$/.exec(line); + if (providerHeader) { + inProviderBlock = true; + continue; + } const header = /^\[model\.([^\]]+)\]$/.exec(line); if (header) { + inProviderBlock = false; current = { alias: header[1]!, id: "" }; models.push(current); continue; } - if (!current) continue; - if (line.startsWith("model =")) { - current.id = tomlStringValue(line) ?? ""; - } else if (line.startsWith("base_url =")) { - baseUrl ??= tomlStringValue(line) ?? null; - } else if (line.startsWith("context_window =")) { - const value = Number(line.slice(line.indexOf("=") + 1).trim()); - if (Number.isFinite(value) && value > 0) current.contextWindow = value; + if (line.startsWith("base_url =")) { + // Prefer the provider block's base_url; fall back to per-model (legacy shape). + if (inProviderBlock) baseUrl ??= tomlStringValue(line) ?? null; + else if (current && baseUrl === null) baseUrl = tomlStringValue(line) ?? null; + } else if (!inProviderBlock && current) { + if (line.startsWith("model =")) { + current.id = tomlStringValue(line) ?? ""; + } else if (line.startsWith("context_window =")) { + const value = Number(line.slice(line.indexOf("=") + 1).trim()); + if (Number.isFinite(value) && value > 0) current.contextWindow = value; + } } } diff --git a/tests/grok-attribution.test.ts b/tests/grok-attribution.test.ts index 7af76d12a7..661f786c23 100644 --- a/tests/grok-attribution.test.ts +++ b/tests/grok-attribution.test.ts @@ -16,10 +16,10 @@ import type { OcxConfig } from "../src/types"; test("the managed fence stamps the grok attribution header on every model", () => { const block = buildGrokManagedBlock(10100, [{ id: "kimi/k3", contextWindow: 262_144 }]); expect(block).toContain('extra_headers = { "x-opencodex-grok" = "1" }'); - // One line per model, after the api_key line, so Grok parses it inside the table. - const modelSections = block.split("[model.").slice(1); - expect(modelSections.length).toBe(1); - expect(modelSections[0]).toContain("x-opencodex-grok"); + // The header lives in the shared [model_providers.opencodex] block, inherited by every + // [model.*] table that references it via model_provider. + const providerBlock = block.slice(block.indexOf("[model_providers.opencodex]"), block.indexOf("[model.")); + expect(providerBlock).toContain("x-opencodex-grok"); }); test("the fence survives a write and keeps the header line parseable", () => { diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index 65b2319624..1949e06c64 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -63,15 +63,20 @@ describe("Grok config injection", () => { expect(content).toContain("[model.ocx-newer-model]"); }); - test("emits per-model direct fields (grok 0.2.101 ignores model_providers inheritance)", () => { + test("emits a shared model_providers block and per-model references (grok 0.2.109+)", () => { const block = buildGrokManagedBlock(10190, [{ id: "cursor/grok-4.5", contextWindow: 500_000 }]); - expect(block).not.toContain("[model_providers"); - expect(block).not.toContain("model_provider ="); + expect(block).toContain("[model_providers.opencodex]"); + const providerBlock = block.slice(block.indexOf("[model_providers.opencodex]"), block.indexOf("[model.")); + expect(providerBlock).toContain('base_url = "http://127.0.0.1:10190/v1"'); + expect(providerBlock).toContain('api_backend = "responses"'); + expect(providerBlock).toContain('api_key = "opencodex-loopback"'); + expect(providerBlock).toContain('extra_headers = { "x-opencodex-grok" = "1" }'); const table = block.slice(block.indexOf("[model.ocx-cursor-grok-4-5]")); expect(table).toContain('model = "cursor/grok-4.5"'); - expect(table).toContain('base_url = "http://127.0.0.1:10190/v1"'); - expect(table).toContain('api_backend = "responses"'); - expect(table).toContain('api_key = "opencodex-loopback"'); + expect(table).toContain('model_provider = "opencodex"'); + expect(table).not.toContain('base_url ='); + expect(table).not.toContain('api_key ='); + expect(table).not.toContain('api_backend ='); expect(table).toContain("context_window = 500000"); }); diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 2d7ffaf2af..f055b9f68e 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectGrokConfig, stripGrokConfig } from "../src/grok/inject"; @@ -1326,4 +1326,358 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => { expect(backup).toContain("[model.ocx-gpt-5-6-sol]"); expect(backup).toContain("[model.ocx-gpt-5-6-sol-2]"); }); + + // A stale [model_providers.opencodex] block from a previous managed fence (written by + // the provider-inheritance shape) sits outside the current fence if the fence was + // removed and re-added. The sweep must remove it just like a per-model orphan, or the + // next sync writes a second [model_providers.opencodex] and Grok rejects the duplicate. + // The fenced writer always emits the durable marker on the provider, so a real leftover + // carries it even after a fence removal. + test("sweeps a stale model_providers.opencodex block outside the fence", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + // Exactly one [model_providers.opencodex] table survives, inside the fence. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.indexOf("[model_providers.opencodex]")).toBeGreaterThan(content.indexOf(BEGIN_MARKER)); + }); + + test("does not sweep a user-authored model_providers block with a different id", () => { + writeFileSync(configPath, [ + "[model_providers.my-gateway]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("[model_providers.my-gateway]"); + // And the managed block's own provider table is separate. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + }); + + test("does not sweep a model_providers.opencodex with a non-loopback base_url", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "https://example.com/v1"', + 'api_key = "opencodex-loopback"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + // A remote base_url with our key is not ours to delete. + expect(readFileSync(configPath, "utf8")).toContain('base_url = "https://example.com/v1"'); + }); + + // Field state from a real machine (2026-08-27): Grok re-serialized the provider block, + // promoting the inline `extra_headers` into a sub-table placed BETWEEN the provider + // header and its own keys. The provider-only body then looks empty, and a leftover + // child collides with the regenerated block's inline `extra_headers` + // ("Cannot redefine key") — the whole TOML layer is rejected. + test("sweeps a reserialized provider block whose sub-table precedes its keys", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The unfenced provider + model are adopted; exactly one of each survives, inside the fence. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + expect(tables(content).filter(alias => alias.startsWith("ocx-"))).toHaveLength(1); + expect(content.indexOf("[model_providers.opencodex]")).toBeGreaterThan(content.indexOf(BEGIN_MARKER)); + // default still resolves. + const survivor = /^default = "([^"]+)"/m.exec(content)?.[1]; + expect(content).toContain(`[model.${survivor}]`); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + // The other re-serialization order: keys first, sub-table after. The provider's own + // body still judges, and the child must be swallowed or the same key collision returns. + test("sweeps a reserialized provider block whose sub-table follows its keys", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + // The migration's real regression: model tables in the provider-inheritance shape carry + // NO api_key/base_url of their own, so the legacy predicate missed them and every sync + // after a Grok rewrite allocated a -2 duplicate beside the stale original. Adoption + // must follow the model_provider reference to the owned provider table. + test("adopts model_provider-referencing entries left unfenced by a Grok rewrite", () => { + writeFileSync(configPath, [ + "[ui]", + 'fork_secondary_model = "grok-build"', + "", + "[model_providers.opencodex]", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[model.ocx-gpt-5-6-terra]", + 'model = "gpt-5.6-terra"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-terra"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The sol entry collapses into the single regenerated one — no -2 duplicates. The + // terra entry is genuinely retired (not in MODELS): explicitly-owned rows are kept + // outside the fence rather than deleted by an inject, so it remains — unfenced but + // adopted (its alias is reserved and never re-suffixed). One table survives per + // model id. + expect(tables(content)).toEqual(["ocx-gpt-5-6-terra", "ocx-gpt-5-6-sol"]); + expect(content).not.toContain("[model.ocx-gpt-5-6-sol-2]"); + expect(content).not.toContain("[model.ocx-gpt-5-6-terra-2]"); + // default still resolves: it names the sol entry, which survives inside the fence. + const survivor = /^default = "([^"]+)"/m.exec(content)?.[1]; + expect(content).toContain(`[model.${survivor}]`); + expect(content).toContain("context_window = 372000"); + // User content survives. + expect(content).toContain('fork_secondary_model = "grok-build"'); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("does not adopt a model referencing a provider that fails the ownership predicate", () => { + writeFileSync(configPath, [ + "[model_providers.my-gateway]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "user-secret"', + "", + "[model.ocx-mine]", + 'model = "user/model"', + 'model_provider = "my-gateway"', + 'name = "OCX mine"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // A referenced-but-user-owned provider keeps its model table untouched, and ours + // takes a suffixed alias instead of clobbering it. + expect(content).toContain("[model.ocx-mine]"); + expect(content).toContain('model_provider = "my-gateway"'); + expect(content).not.toContain("[model.ocx-mine-2]"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("folds provider sub-tables separated from their parent by a user table", () => { + // TOML allows the re-serialized child to sit after an unrelated table. A first-mismatch + // stop left the stale provider unfolded: without the marker evidence it stayed, and the + // next sync declared a duplicate [model_providers.opencodex] — invalid TOML for Grok. + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[ui.detailed]", + "verbose = true", + "", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + // The interleaved user table survives. + expect(content).toContain("[ui.detailed]"); + expect(tables(content).filter(alias => alias.startsWith("ocx-"))).toHaveLength(1); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("teardown resolves inherited ownership through the fenced provider", () => { + // A retired model kept outside the fence inherits its verdict from the provider table + // INSIDE it. Classification must see the fenced provider, or strip removes the fence + // but leaves the model with a dangling `model_provider = "opencodex"` reference. + writeFileSync(configPath, [ + "[ui]", + 'fork_secondary_model = "grok-build"', + "", + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[model.ocx-gpt-5-6-terra]", + 'model = "gpt-5.6-terra"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-terra"', + "", + "[models]", + 'default = "ocx-gpt-5-6-terra"', + ].join("\n")); + + const result = stripGrokConfig({ grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The fence and the retired model are both gone; no dangling reference survives. + expect(content).not.toContain("model_provider = \"opencodex\""); + expect(content).not.toContain("[model_providers.opencodex]"); + expect(content).toContain('fork_secondary_model = "grok-build"'); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("does not adopt a user-written model that references the managed provider", () => { + // Inheritance must not grant removal authority over every model that references + // opencodex: a user is free to write their own [model.*] table that inherits the + // managed provider, and adoption without a generated alias deletes it. + for (const operation of ["inject", "teardown"] as const) { + writeFileSync(configPath, [ + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[model.custom-variant]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "my fast variant"', + "context_window = 128000", + "", + ].join("\n")); + + if (operation === "inject") { + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + } else { + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + } + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("[model.custom-variant]"); + expect(content).toContain('name = "my fast variant"'); + expect(content).toContain("context_window = 128000"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + } + }); + + test("sweeping a provider-only orphan backs the user's config up first", () => { + // Provider orphans carry no alias, so an alias-count backup condition skipped the + // backup entirely even though teardown removed the table. + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + "", + ].join("\n")); + + const result = stripGrokConfig({ grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(true); + const content = readFileSync(configPath, "utf8"); + expect(content).not.toContain("[model_providers.opencodex]"); + expect(content).toContain("[models]"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); }); diff --git a/tests/grok-selection.test.ts b/tests/grok-selection.test.ts index ce338e1bda..42b749e8c5 100644 --- a/tests/grok-selection.test.ts +++ b/tests/grok-selection.test.ts @@ -67,7 +67,8 @@ test("excluding a colliding model leaves the survivor's alias unchanged", () => test("excluding the first model keeps the TOML shape valid", () => { const block = buildGrokManagedBlock(10100, MODELS, undefined, undefined, new Set(["kimi/k3"])); const afterMarker = block.split("do not edit (removed by `ocx stop`) >>>\n")[1]!; - expect(afterMarker.startsWith("[model.")).toBe(true); + // The provider block is always present; the first [model.*] table may be excluded. + expect(afterMarker.trimStart().startsWith("[model_providers.")).toBe(true); expect(block).not.toContain("\n\n\n"); }); diff --git a/tests/grok-status.test.ts b/tests/grok-status.test.ts index 2f57d5f6c9..32fff429df 100644 --- a/tests/grok-status.test.ts +++ b/tests/grok-status.test.ts @@ -78,6 +78,31 @@ describe("readGrokStatus", () => { rmSync(root, { recursive: true, force: true }); } }); + + // A fence written by the old per-model shape (before model_providers migration) carries + // base_url on each [model.*] table and has no [model_providers.opencodex] block. + test("falls back to per-model base_url for a legacy-shape fence", () => { + const { root, grokHome } = tempGrokHome(); + try { + const legacy = [ + "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10190/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "# <<< opencodex managed block <<<", + ].join("\n"); + writeFileSync(join(grokHome, "config.toml"), legacy, "utf8"); + + const status = readGrokStatus({ grokHome }); + expect(status.present).toBe(true); + expect(status.baseUrl).toBe("http://127.0.0.1:10190/v1"); + expect(status.models.map(m => m.id)).toEqual(["gpt-5.6-sol"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); /** From 3700526489645f65f7bf162fd4ffa81e610fa3f1 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Sun, 30 Aug 2026 05:04:45 +0900 Subject: [PATCH 098/132] fix(catalog): read Copilot nested vision capability (#2943) Co-authored-by: Ingwannu --- src/codex/catalog/provider-fetch.ts | 10 ++++++++-- structure/03_catalog-and-subagents.md | 15 +++++++++++++++ .../provider-model-discovery-contract.test.ts | 18 +++++++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b5190d4162..f3113dfe17 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1094,8 +1094,14 @@ function modelInputModalities( .filter(value => value === "text" || value === "image" || value === "audio"); if (inferred.length > 0) return [...new Set(inferred)]; } - if (capabilityRecord?.vision === false) return ["text"]; - if (capabilityRecord?.vision === true || capabilities?.some(value => ( + const nestedSupports = plainRecord(capabilityRecord?.supports); + const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean" + ? capabilityRecord.vision + : typeof nestedSupports?.vision === "boolean" + ? nestedSupports.vision + : undefined; + if (explicitVisionSupport === false) return ["text"]; + if (explicitVisionSupport === true || capabilities?.some(value => ( value === "vision" || value === "image-input" || value === "image_input" // llama.cpp and Ollama-compatible servers report vision as "multimodal" — // it is the only image signal those servers emit (#1797). Mapped to the diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 612f576aeb..5db21f986e 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -294,6 +294,21 @@ the request, and they never raise it. 그대로 유지한다. `preserveCustomDestination` guard 때문에 같은 provider id를 다른 host에 연결한 사용자 설정에는 이 capability 분류가 전파되지 않는다. +[Decision Log] +- 목적과 의도: GitHub Copilot의 live model catalog가 명시하는 모델별 image-input 지원을 + Codex catalog에 정확히 보존한다. +- 기존 구현 및 제약 조건: 공용 discovery parser는 직접 `capabilities.vision`과 표준 modality + 필드는 읽었지만 Copilot의 `capabilities.supports.vision` 중첩 boolean은 읽지 않아 모든 + Copilot 모델이 text-only fallback으로 축소되었다. +- 검토한 주요 대안: 모든 Copilot 모델에 정적 vision seed를 추가하기, 모델 이름을 외부 + metadata alias에 연결하기, live 모델별 boolean을 공용 parser에서 해석하기. +- 선택한 방식: 직접 vision boolean이 없을 때만 중첩 `supports.vision`의 명시적 boolean을 + 사용하고, `false`도 보존하며 malformed 값은 추론하지 않는다. +- 다른 대안 대신 이 방식을 선택한 이유: live 응답이 모델별 capability의 가장 좁은 근거라서 + 새 모델에도 적용되며 text-only 모델을 image-capable로 과장하지 않는다. +- 장점, 단점 및 영향: Copilot vision 모델은 image attachment를 받을 수 있고 명시적 text-only + 모델은 계속 차단된다. Capability를 제공하지 않는 모델은 기존 fallback을 유지한다. + [Decision Log] - 목적과 의도: bare `defaultModel` selectors that route into third-party providers must keep their adapter-owned effort ladder; only true ChatGPT-native requests should receive the mock-max repair. diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index b8b47adf9e..2fe52ff27f 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -276,6 +276,23 @@ describe("registry-owned provider model discovery", () => { })).toEqual({}); }); + test("reads explicit nested vision support from Copilot-style capabilities", () => { + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "vision-model", + capabilities: { supports: { vision: true } }, + })).toEqual({ inputModalities: ["text", "image"] }); + + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "text-only-model", + capabilities: { supports: { vision: false } }, + })).toEqual({ inputModalities: ["text"] }); + + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "unknown-model", + capabilities: { supports: { vision: "true" } }, + })).toEqual({}); + }); + test("preserves nested reasoning_parameters effort ladders from OpenAI-compatible catalogs", () => { expect(catalogHintsFromModelsApiItem("example", { id: "reasoning-model", @@ -605,4 +622,3 @@ describe("same-named custom provider preservation", () => { }); }); }); - From 2e92c8913618aae2f1cca0bc7dfd683ceeb9f568 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 05:40:39 +0900 Subject: [PATCH 099/132] test(catalog): pin Copilot vision precedence and nested/flat denial equivalence (#2944) #2943 fixed the nested read; this covers the cases its tests do not, and adds the comment explaining why the precedence is shaped the way it is. The important one is flat-versus-nested disagreement. I had implemented this as deny-wins across both levels in a competing patch, which would have flipped a provider reporting flat vision:true with nested supports.vision:false from image-capable to text-only -- changing behaviour that predates Copilot support, in a parser shared by every provider, from a change whose whole purpose was to stop models being wrongly marked text-only. #2943 resolves by specificity instead: a flat boolean is authoritative when present, nested is consulted only otherwise. Reintroducing deny-wins turns the new test red. An independent review of my withdrawn head raised a second question: the nested read is not scoped to github-copilot, and a nested denial beats a loose "vision" string in a capability array. Probing the merged code shows the nested and flat denials behave identically there, including the contradictory hint pair (inputModalities: ["text"] alongside capabilities: ["vision"]) that flat false has always produced. That equivalence is the property worth pinning: the nested field must mean exactly what the field it stands in for means, or the unscoped read becomes a subtle divergence. Reconciling a boolean denial with a capability list is a separate question and is not answered under a Copilot ticket. Also covered: the reporter's full payload including the limits.vision sibling that holds an image count and would fool anything searching capabilities for a vision-ish key; a non-record supports container falling through so a features: ["vision"] signal still decides; and explicit item input_modalities still outranking a nested claim. Devlog corrected on two overclaims the review was right about: "deeper than any other provider" is only "deeper than the flat form, and no checked-in fixture uses it", and parsed upstream capability is the best per-model evidence rather than authoritative when two forms disagree. No production behaviour change -- the resolution is #2943's implementation. --- .../000_units.md | 70 +++++++++++++++++++ src/codex/catalog/provider-fetch.ts | 10 +++ .../provider-model-discovery-contract.test.ts | 68 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 devlog/_plan/260830_lane_r_2941_copilot_vision/000_units.md diff --git a/devlog/_plan/260830_lane_r_2941_copilot_vision/000_units.md b/devlog/_plan/260830_lane_r_2941_copilot_vision/000_units.md new file mode 100644 index 0000000000..ac50aef2e3 --- /dev/null +++ b/devlog/_plan/260830_lane_r_2941_copilot_vision/000_units.md @@ -0,0 +1,70 @@ +# Lane R / #2941 — Copilot vision models are cataloged text-only + +## The report + +Every `github-copilot` model arrives in the catalog with `inputModalities: ["text"]`, so Codex refuses image attachments with "This model does not support image inputs" on 32+ models that do accept them (Claude Opus/Sonnet, GPT-4o/4.1/5.x, Gemini, Grok). The same model reached through `openrouter` accepts images, which is what makes this clearly a metadata defect rather than an upstream limitation. + +## Why every path returns nothing + +Copilot's `/models` endpoint nests the flag one level deeper than the flat form the parser reads. No other checked-in fixture uses this shape, which is all a repository search can establish — it says nothing about what other live catalogs return: + +```json +{ + "id": "claude-opus-4.6", + "capabilities": { + "supports": { "vision": true }, + "limits": { "vision": { "max_prompt_images": 20 } } + } +} +``` + +`modelInputModalities()` in `src/codex/catalog/provider-fetch.ts` tries three signals and all three miss: + +- `item.input_modalities` / `item.modalities` — Copilot emits neither. +- `capabilityRecord?.vision` — `capabilityRecord` is the `capabilities` object itself, so its keys are `supports` and `limits`. `.vision` is `undefined`. +- the `capabilities` string array — `supports` is an object, not the literal `true` that scan looks for. + +The fallback chain then lands on `["text"]`. + +Note the shape carries a second `vision` key under `limits`. Any fix that searches loosely for "a vision key somewhere in capabilities" would find `limits.vision`, which is an object describing image count — truthy, and meaningless as a capability signal. + +## Outcome: #2943 shipped the fix, this unit adds the missing coverage + +@Ingwannu opened #2943 for the same defect 17 minutes before my #2944. Their implementation landed as `370052648`, and it is better than mine on one case that matters — see the precedence section below. My unit reduced to the tests and the explanatory comment. + +## Fix + +Read the nested boolean, positioned after the explicit-modality and architecture signals, with precedence **by specificity**: a flat `capabilities.vision` boolean is authoritative whenever present, the nested `supports.vision` boolean is consulted only otherwise, and a non-boolean at either level decides nothing so the remaining signals still apply. + +**The read is not scoped to `github-copilot`, and that is deliberate rather than overlooked.** `modelInputModalities` never receives a provider name, and the nested field is the same kind of evidence wherever it appears — a boolean statement about one model. Scoping it would mean a provider reporting the identical shape gets a worse answer for no reason. What the choice does mean is that any provider emitting `capabilities.supports.vision` as a boolean now has it honoured, so the nested field must behave **exactly** like the flat field it stands in for. That equivalence is pinned by a test comparing both shapes against the same loose capability-array claim. + +Strictness matters in both directions. A truthy test would let the string `"yes"` advertise image support, and coercing a non-record `supports` into a denial would suppress a `features: ["vision"]` signal that is still valid. + +## The precedence mistake, recorded because it nearly shipped + +I first wrote the denial as `flat === false || nested === false` — deny-wins across both sources. It reads as the safe direction and is not. + +On a provider reporting flat `vision: true` with nested `supports: { vision: false }`, deny-wins returns `["text"]` where the old code returned `["text", "image"]`. That is a silent behaviour change in a parser shared by **every** provider, shipped by a patch whose entire purpose was to stop models being wrongly marked text-only. Eleven of twelve capability shapes agree between the two resolutions; that one does not, and it is the one that would have caused a regression. + +A differential probe over both resolutions is what surfaced it — not review, and not green tests, since neither suite covered a disagreeing pair. The case is now pinned: reintroducing deny-wins turns `a flat vision boolean outranks a disagreeing nested one` red. + +## Rejected: a registry seed + +The issue offers "just add `modelInputModalities` to the `github-copilot` registry entry" as the easier route. + +An audit pushed back on my first reason for rejecting it, correctly. `modelInputModalities` is a **per-model** map, not a provider-wide boolean, and other providers do seed selectively — xAI lists specific verified vision ids. So "Copilot serves both vision and text-only models" does not by itself rule out a seed, and a selective one would even help during first start or degraded `/models` discovery, since the registry model list is explicitly a cold-start fallback. + +The real reason to omit it here is narrower: **a seed is only as good as the audited list behind it, and no verified model-by-model Copilot vision list exists.** Writing one from the 32 models named in the issue would be guesswork duplicating a remote catalog that changes without us. A selective seed remains a legitimate follow-up for whoever can audit the list. + +One qualifier on "parse what upstream reports": it is the best per-model evidence available, not an oracle. When two upstream forms disagree the output can still be internally contradictory — a model can end up with `inputModalities: ["text"]` next to `capabilities: ["vision"]`. That contradiction predates this work (flat `false` has always beaten a capability-array `"vision"` string) and is left alone here rather than fixed silently under a Copilot ticket. Resolving how a boolean denial and a loose capability list should reconcile is its own unit. + +## Tests + +`tests/provider-model-discovery-contract.test.ts`, using the reporter's exact payload including the `limits.vision` sibling. + +| Mutation | Expected | +|---|---| +| remove the nested read entirely (pre-fix state) | tri-state test red — reproduces the report | +| drop `nestedVision === false` | tri-state test red | +| `Boolean(nestedVision)` instead of `=== true` | malformed-hint test red | +| move the nested read above the explicit-modality return | precedence test red | diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f3113dfe17..0ebb2971c4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1094,6 +1094,16 @@ function modelInputModalities( .filter(value => value === "text" || value === "image" || value === "audio"); if (inferred.length > 0) return [...new Set(inferred)]; } + // GitHub Copilot nests vision support one level down as `capabilities.supports.vision`, so the + // flat read alone finds nothing and every Copilot model falls through to `["text"]` — Codex then + // refuses image attachments on models that accept them (#2941). Precedence is by specificity: + // a flat boolean is authoritative when present, the nested boolean is consulted only otherwise, + // and a non-boolean at either level decides NOTHING so the signals below still apply. Two things + // this ordering deliberately avoids: a deny-wins rule across both levels would flip a provider + // reporting flat `true` with nested `false` from image-capable to text-only, changing behaviour + // that predates Copilot support; and a truthy test would let the string `"no"` advertise image + // input. The payload also carries a SECOND `vision` key under `limits` holding an image count, + // which is why this reads one exact path instead of searching `capabilities` for a vision-ish key. const nestedSupports = plainRecord(capabilityRecord?.supports); const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean" ? capabilityRecord.vision diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index 2fe52ff27f..f4a088b730 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -293,6 +293,74 @@ describe("registry-owned provider model discovery", () => { })).toEqual({}); }); + test("a flat vision boolean outranks a disagreeing nested one (#2941)", () => { + // Precedence is by specificity, NOT deny-wins. A deny-wins rule across both levels would flip + // this shape from image-capable to text-only, silently changing behaviour that predates Copilot + // support — the flat `true` alone already meant image input. Pinned so it cannot regress. + expect(catalogHintsFromModelsApiItem("example", { + id: "flat-true-nested-false", + capabilities: { vision: true, supports: { vision: false } }, + })).toEqual({ inputModalities: ["text", "image"], capabilities: ["vision"] }); + + // The mirror image: a flat denial is authoritative over a nested claim. + expect(catalogHintsFromModelsApiItem("example", { + id: "flat-false-nested-true", + capabilities: { vision: false, supports: { vision: true } }, + })).toEqual({ inputModalities: ["text"] }); + }); + + test("the reporter's full Copilot payload is read despite the limits.vision sibling (#2941)", () => { + // `capabilities` carries a SECOND vision key under `limits` holding an image count. Anything + // that searched loosely for a vision-ish key would find that object and treat it as a signal. + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "claude-opus-4.6", + capabilities: { + supports: { vision: true }, + limits: { vision: { max_prompt_images: 20 } }, + }, + })).toEqual({ inputModalities: ["text", "image"] }); + }); + + test("an explicit nested denial outranks a loose capability-array claim, exactly as a flat one does (#2941)", () => { + // A boolean capability field is a specific statement; a "vision" string in a capability array is + // a loose one. Flat `false` has always won that contest, and the internal contradiction it + // produces -- text-only modalities reported next to capabilities: ["vision"] -- predates the + // nested read. These two shapes must agree, or the nested field would mean something subtly + // different from the flat field it stands in for. + const nested = catalogHintsFromModelsApiItem("example", { + id: "nested-denial-vs-array", + metadata: { capabilities: { supports: { vision: false } } }, + capabilities: ["vision"], + }); + const flat = catalogHintsFromModelsApiItem("example", { + id: "flat-denial-vs-array", + metadata: { capabilities: { vision: false } }, + capabilities: ["vision"], + }); + expect(nested).toEqual({ inputModalities: ["text"], capabilities: ["vision"] }); + expect(nested).toEqual(flat); + }); + + test("a non-record supports container decides nothing and leaves the fallback chain intact (#2941)", () => { + // It must not collapse into a denial either — the `features` signal further down still decides. + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "malformed-container", + capabilities: { supports: 5 }, + features: ["vision"], + })).toEqual({ + inputModalities: ["text", "image"], + capabilities: ["vision"], + }); + }); + + test("explicit item input modalities still outrank a nested Copilot vision claim (#2941)", () => { + expect(catalogHintsFromModelsApiItem("github-copilot", { + id: "explicit-audio-model", + input_modalities: ["audio"], + capabilities: { supports: { vision: true } }, + })).toEqual({ inputModalities: ["audio"] }); + }); + test("preserves nested reasoning_parameters effort ladders from OpenAI-compatible catalogs", () => { expect(catalogHintsFromModelsApiItem("example", { id: "reasoning-model", From 8427efe6e80a5ce9488eab7b80b2b1663ab20579 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 06:06:23 +0900 Subject: [PATCH 100/132] fix(adapters): classify failed exec wrappers by forward scan, not backtracking (#2945) The previous classifier placed two adjacent unbounded whitespace runs over the same span, so a long whitespace run followed by one non-matching character forced the engine to retry prefixes. Replaced with a single forward scan. Every loop advances an index monotonically, the newline searches cover disjoint forward spans, and the token checks are fixed-length, so no path retries a prefix. Two boundaries are load-bearing and both were divergences in an earlier attempt at this rewrite: whitespace after Output: may precede the marker, so an indented still classifies; and only whitespace may follow it, so a duplicate still does not. That second one matters most -- classifying it would replace a real payload with the failed-wrapper guidance, turning a normalization into data loss. One intended behaviour change: CRLF blank separators now count. The old pattern matched only \n, so a Windows-produced failed wrapper never classified and fell through to the empty-SUCCESS message, telling the model nothing had gone wrong when the cell had failed. Differential comparison over 63 shapes locally and an independent 662-shape pairwise corpus found no disagreement outside that CRLF blank-separator class. Diagnosis and the linear-scan approach are @luvs01's from #2938; that PR could not land as written because of the six divergences, which I posted there with the exact inputs. The bounded-work test measures process.cpuUsage() rather than elapsed wall time: performance.now() counts OS descheduling, VM pauses and GC, so a loaded CI runner can blow a wall-clock budget while the code under test did nothing wrong. Reverting to the previous classifier spends 1230ms CPU against a 250ms bound. Four mutations proven red at the intended test each: whitespace restricted to CR/LF, accepting a second marker, removing CRLF handling, and reverting the classifier. --- src/adapters/cursor/tool-result-normalize.ts | 6 +- src/adapters/exec-tool-result-normalize.ts | 75 ++++++++++++++++++-- tests/cursor-exec-empty-result.test.ts | 70 ++++++++++++++++++ 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/src/adapters/cursor/tool-result-normalize.ts b/src/adapters/cursor/tool-result-normalize.ts index 997ef56fea..fded734b93 100644 --- a/src/adapters/cursor/tool-result-normalize.ts +++ b/src/adapters/cursor/tool-result-normalize.ts @@ -13,7 +13,7 @@ import { EMPTY_EXEC_OUTPUT_MESSAGE, EMPTY_EXEC_OUTPUT_REGEX, FAILED_EXEC_OUTPUT_MESSAGE, - FAILED_EXEC_OUTPUT_REGEX, + isFailedEmptyExecWrapper, isCodexExecBridgeTool, } from "../exec-tool-result-normalize"; @@ -23,7 +23,7 @@ import { * `Script failed`, so restore that arm here rather than widening the shared one. */ function isEmptyOrFailedExecWrapper(text: string): boolean { - return EMPTY_EXEC_OUTPUT_REGEX.test(text) || FAILED_EXEC_OUTPUT_REGEX.test(text); + return EMPTY_EXEC_OUTPUT_REGEX.test(text) || isFailedEmptyExecWrapper(text); } const COMPUTER_USE_TOOL_NAMES = new Set([ @@ -99,7 +99,7 @@ export function normalizeCursorToolResultText( // A `Script failed` wrapper is empty but NOT a success: reporting it as an empty success // would erase the only failure signal. Text classification stays separate from Cursor's // isError policy, which the Computer Use branch above owns. - text: FAILED_EXEC_OUTPUT_REGEX.test(text.trim()) ? FAILED_EXEC_OUTPUT_MESSAGE : EMPTY_EXEC_OUTPUT_MESSAGE, + text: isFailedEmptyExecWrapper(text.trim()) ? FAILED_EXEC_OUTPUT_MESSAGE : EMPTY_EXEC_OUTPUT_MESSAGE, isError: false, changed: true, }; diff --git a/src/adapters/exec-tool-result-normalize.ts b/src/adapters/exec-tool-result-normalize.ts index f06a07c411..56a0386c4d 100644 --- a/src/adapters/exec-tool-result-normalize.ts +++ b/src/adapters/exec-tool-result-normalize.ts @@ -17,13 +17,78 @@ * `Script failed` is deliberately NOT in this set. A failed cell with no captured output is still * a FAILURE, and the success guidance below ("not a blocked tool", "do not re-run") would erase the * only signal that anything went wrong — reachable through Responses history, where - * `function_call_output` is parsed with `isError: false`. Cursor keeps its own broader regex for - * Computer Use, where a failed wrapper is separately marked `isError`. + * `function_call_output` is parsed with `isError: false`. Cursor combines this set with + * `isFailedEmptyExecWrapper` below for Computer Use, where a failed wrapper is separately marked + * `isError`. */ export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:)?\s*$/; -/** Wrapper for a cell that FAILED without emitting output: empty, but not a success. */ -export const FAILED_EXEC_OUTPUT_REGEX = /^Script failed[^\n]*\n*(?:Wall time[^\n]*\n*)?(?:Output:\s*)?(?:)?\s*$/; +function skipFailedWrapperBlankSeparators(text: string, start: number): number { + let index = start; + while (index < text.length) { + if (text[index] === "\n") { + index += 1; + continue; + } + // A CRLF blank line is one separator, not a stray carriage return. The regex this replaced + // matched only `\n`, so a Windows-produced wrapper never classified and the failure guidance + // was silently replaced by the empty-SUCCESS message on that platform. + if (text[index] === "\r" && text[index + 1] === "\n") { + index += 2; + continue; + } + break; + } + return index; +} + +function skipFailedWrapperWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && text[index]!.trim() === "") index += 1; + return index; +} + +function skipFailedWrapperLine(text: string, start: number): number { + const newline = text.indexOf("\n", start); + return newline === -1 ? text.length : skipFailedWrapperBlankSeparators(text, newline + 1); +} + +/** + * Wrapper for a cell that FAILED without emitting output: empty, but not a success. + * + * A single forward scan, replacing a regex whose `\n*` and `\s*` runs sat adjacent over the same + * span and could be made to backtrack on a long whitespace run followed by one non-matching + * character. Every loop here advances an index monotonically and the token checks are fixed-length, + * so the work is bounded by the input length with no path that retries a prefix. + * + * Two boundaries are load-bearing and both were divergences in an earlier attempt at this rewrite: + * + * - Whitespace after `Output:` may precede the marker, so an INDENTED `` still classifies. + * Rejecting it would leave the wrapper unnormalized and the failure unexplained. + * - Only whitespace may follow the marker, so a DUPLICATE `` still does not classify. + * Accepting it would erase a real payload as an empty failed wrapper — the damaging direction. + * + * Behaviour is otherwise identical to the regex; the CRLF separators above are the only + * intentional change, verified against a 63-shape differential corpus. + */ +export function isFailedEmptyExecWrapper(trimmed: string): boolean { + if (!trimmed.startsWith("Script failed")) return false; + + const firstNewline = trimmed.indexOf("\n", "Script failed".length); + if (firstNewline === -1) return true; + + let index = skipFailedWrapperBlankSeparators(trimmed, firstNewline + 1); + if (trimmed.startsWith("Wall time", index)) { + index = skipFailedWrapperLine(trimmed, index); + } + if (trimmed.startsWith("Output:", index)) { + index = skipFailedWrapperWhitespace(trimmed, index + "Output:".length); + } + if (trimmed.startsWith("", index)) { + index += "".length; + } + return skipFailedWrapperWhitespace(trimmed, index) === trimmed.length; +} /** Guidance for a failed cell whose output was empty: the failure must survive normalization. */ export const FAILED_EXEC_OUTPUT_MESSAGE = @@ -94,6 +159,6 @@ export function normalizeEmptyExecToolResultText( if (!isCodexExecBridgeTool(options.toolName, options.toolNamespace)) return undefined; const trimmed = text.trim(); // Failure first: a failed wrapper must never be described as an empty success. - if (FAILED_EXEC_OUTPUT_REGEX.test(trimmed)) return FAILED_EXEC_OUTPUT_MESSAGE; + if (isFailedEmptyExecWrapper(trimmed)) return FAILED_EXEC_OUTPUT_MESSAGE; return EMPTY_EXEC_OUTPUT_REGEX.test(trimmed) ? EMPTY_EXEC_OUTPUT_MESSAGE : undefined; } diff --git a/tests/cursor-exec-empty-result.test.ts b/tests/cursor-exec-empty-result.test.ts index 098aaa08d3..57f42c0076 100644 --- a/tests/cursor-exec-empty-result.test.ts +++ b/tests/cursor-exec-empty-result.test.ts @@ -45,6 +45,76 @@ describe("codex exec bridge empty-result normalization (devlog 260826 gap-7)", ( expect(out.text).toBe("Output:\nhello"); }); + test("an indented empty marker after Output: still classifies as a failed wrapper", () => { + // These three classified under the previous regex. A line-scan rewrite that treated the + // marker as needing to start its own line rejected them, leaving the wrapper unnormalized + // and the failure unexplained. + for (const wrapper of [ + "Script failed\nOutput:\n\n ", + "Script failed\nOutput:\n ", + "Script failed\nOutput:\n ", + ]) { + const out = normalizeCursorToolResultText(wrapper, { toolName: "exec", isError: false }); + expect(out.changed).toBe(true); + expect(out.text).toContain("exec failed"); + } + }); + + test("a duplicate empty marker is left alone rather than erased", () => { + // The damaging direction: classifying these would replace a real payload with the failed-wrapper + // guidance. The previous regex rejected them and so must any replacement. + for (const wrapper of [ + "Script failed\nOutput:\t\n", + "Script failed\nOutput: \n\n", + "Script failed\nOutput: \n", + ]) { + const out = normalizeCursorToolResultText(wrapper, { toolName: "exec", isError: false }); + expect(out.changed).toBe(false); + expect(out.text).toBe(wrapper); + } + }); + + test("CRLF blank separators reach the failure guidance instead of the empty-success text", () => { + // The one intentional behaviour change. The old regex matched only `\n`, so a Windows-produced + // failed wrapper fell through to the empty-SUCCESS message — telling the model nothing went + // wrong when the cell had in fact failed. + for (const wrapper of [ + "Script failed\r\n\r\n\r\nOutput:", + "Script failed\r\n\r\n", + "Script failed\r\n\r\nOutput:", + "Script failed\r\nWall time 1s\r\n\r\nOutput:", + ]) { + const out = normalizeCursorToolResultText(wrapper, { toolName: "exec", isError: false }); + expect(out.changed).toBe(true); + expect(out.text).toContain("exec failed"); + expect(out.text).not.toContain("NOT lost context"); + } + }); + + test("a long whitespace run followed by a non-matching character classifies in bounded work", () => { + // A pathological shape for the classifier this replaced. Measured in CPU time rather than + // elapsed wall time: `performance.now()` counts OS descheduling, VM pauses and GC, so a loaded + // CI runner can blow any wall-clock budget while the code under test did nothing wrong. + // `process.cpuUsage()` counts only work this process actually performed. + // + // The bound is deliberately three orders of magnitude above the scan's real cost. It is not a + // performance target; it is a tripwire wide enough that only a return to super-linear work can + // cross it, which is the single thing this test exists to catch. + const malformed = `Script failed${" ".repeat(60_000)}\nY`; + + // Warm up so first-call JIT and allocation land outside the measurement. + normalizeCursorToolResultText(malformed, { toolName: "exec", isError: false }); + + const before = process.cpuUsage(); + const out = normalizeCursorToolResultText(malformed, { toolName: "exec", isError: false }); + const spent = process.cpuUsage(before); + const cpuMs = (spent.user + spent.system) / 1000; + + expect(out.changed).toBe(false); + expect(out.text).toBe(malformed); + expect(cpuMs).toBeLessThan(250); + }); + test("computer-use empties keep the original error semantics", () => { const out = normalizeCursorToolResultText("", { toolName: "screenshot" }); expect(out.isError).toBe(true); From 47b8d164366b9db9e4331b2bb8b542db22766910 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 06:34:06 +0900 Subject: [PATCH 101/132] test(codex): isolate the tombstone guard with a credential-carrying tombstone (#2946) Closes an over-claim in #2934's merge record. Its mutation table said removing the `alias.deletedAt != null` check in commitRefreshedCodexCredentialWithAliases turns the resurrection test red. It does not: tombstoneCodexAccount drops the credential, so the separate `!alias.credential` guard already skips that record and the assertion passes with `deletedAt` deleted. The two guards overlap on the only fixture that exercised them, so neither was independently proven. A tombstone that still carries a credential is the only shape that reaches the deletedAt check, and it is reachable: a store written by an older build, or a tombstone raced by a concurrent save. `tokenful tombstone is treated as absent` already pins that shape for the read path, so this uses the same construction for the propagation path. Every other eligibility field matches the owner in this fixture -- same fingerprint, same access token, same expiry, same chatgptAccountId -- so deletedAt is the only thing that can skip it. Removing that check now turns this test red while the other 41 stay green. No production change. --- tests/codex-account-store.test.ts | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index fe7274496d..3e2a9f3f77 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -1100,6 +1100,50 @@ describe("codex-account-store CRUD", () => { } }); + test("a TOKENFUL tombstone is not resurrected either, isolating the deletedAt guard (#2892 gap 3)", async () => { + // The sibling test above cannot prove the `deletedAt` check is load-bearing: `tombstoneCodexAccount` + // drops the credential, so the separate `!alias.credential` guard already skips that record and the + // assertion passes with `deletedAt` removed. A tombstone that still CARRIES a credential is the only + // shape that reaches the `deletedAt` check, and it is reachable — a store written by an older build, + // or a tombstone raced by a concurrent save, produces exactly this record. `tokenful tombstone is + // treated as absent` earlier in this file pins the same shape for the read path. + const { getValidCodexToken, readCodexAccountRecord, saveCodexAccountCredential } = + await import("../src/codex/account-store"); + const shared = { + accessToken: "tokenful-old", + refreshToken: "tokenful-grant", + expiresAt: 0, + chatgptAccountId: "tokenful-acc", + }; + saveCodexAccountCredential("tokenful-owner", { ...shared }); + const ownerGeneration = readCodexAccountRecord("tokenful-owner")!.generation; + // Written directly: no public API produces a tombstone that retains its credential. + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ + "tokenful-owner": { credential: { ...shared }, generation: ownerGeneration }, + "tokenful-deleted": { credential: { ...shared }, generation: ownerGeneration, deletedAt: Date.now() }, + }, null, 2)); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + access_token: "tokenful-new", + refresh_token: "tokenful-rotated", + expires_in: 3600, + })) as typeof fetch; + try { + await getValidCodexToken("tokenful-owner"); + // The owner rotated. + expect(readCodexAccountRecord("tokenful-owner")!.credential!.refreshToken).toBe("tokenful-rotated"); + // The tombstone kept its stale grant and stayed deleted: propagation skipped it on `deletedAt` + // alone, since its credential was present and every other eligibility field matched the owner. + const deleted = readCodexAccountRecord("tokenful-deleted")!; + expect(deleted.deletedAt).toBeGreaterThan(0); + expect(deleted.credential!.refreshToken).toBe("tokenful-grant"); + expect(deleted.generation).toBe(ownerGeneration); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("a same-grant sibling on a DIFFERENT upstream identity is never adopted (#2892 review)", async () => { const { forceRefreshCodexPoolToken, getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } = From dca16949b0eeca1a7fb2f99a777d0f12ce350bb4 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 30 Aug 2026 11:01:55 +0900 Subject: [PATCH 102/132] test: let the README asset check tell files from directories (#2952) The shipped-asset check treated every `package.json` `files` entry as a possible directory prefix. `assets/banner.png` therefore vouched for `assets/banner.png/missing.gif`, and `LICENSE` for `LICENSE/missing.png`. The intent was right: a directory entry does ship everything beneath it, and the existing comment correctly rejects deciding that by looking for a dot in the name. But prefix matching alone cannot tell the two cases apart either. Ask the filesystem which entries are directories, and let only those act as prefixes. The check is a guard against broken images on the npm package page, so a false negative here is exactly the failure it exists to catch. --- tests/repo-hygiene.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts index 6132a9776a..c1de41184b 100644 --- a/tests/repo-hygiene.test.ts +++ b/tests/repo-hygiene.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("../", import.meta.url)); @@ -244,13 +244,22 @@ describe("devlog is tracked, with no submodule left behind", () => { // end state, and a `toBeGreaterThan(0)` guard here would fail the suite for doing it. const relative = [...readme.matchAll(/src="(?!https?:)([^"]+)"/g)].map((match) => match[1]!); - const missing = relative.filter((asset) => { - if (shipped.includes(asset)) return false; - // A directory entry ships everything beneath it. Decided by whether the tarball path is a - // prefix, not by whether the name contains a dot: `LICENSE` has no dot and is a file, and - // a future `assets` entry would have no dot and be a directory. - return !shipped.some((entry) => asset.startsWith(`${entry}/`)); + // A directory entry ships everything beneath it; a regular-file entry ships only itself. + // Deciding that by prefix alone let `assets/banner.png` vouch for a nonexistent + // `assets/banner.png/missing.gif`, so a broken README reference could pass. Ask the + // filesystem what each entry actually is instead of inferring it from the name. + const shippedDirectories = shipped.filter((entry) => { + const path = new URL(`../${entry}`, import.meta.url); + return existsSync(path) && statSync(path).isDirectory(); }); + const isShipped = (asset: string): boolean => + shipped.includes(asset) + || shippedDirectories.some((directory) => asset.startsWith(`${directory}/`)); + + expect(isShipped("assets/banner.png/missing.gif")).toBe(false); + expect(isShipped("LICENSE/missing.png")).toBe(false); + + const missing = relative.filter((asset) => !isShipped(asset)); expect(missing).toEqual([]); }); }); From b95dc5d429042c99c87cdad82717eb3da3ba5ac5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 11:33:37 +0900 Subject: [PATCH 103/132] fix(test): scope test lock to user runtime (#2962) A home-rooted lock can couple separate machines while PID liveness remains host-local, and inaccessible homes fail before discovery with raw filesystem errors.\n\nResolve a validated user runtime from XDG or a private UID temp namespace, include a host discriminator, and surface actionable failures. Cover cross-user, cross-host, Windows, fallback, unsafe-root, and path-containment cases. --- scripts/test-run-lock.ts | 188 ++++++++++++++++++++++++++++++++++++-- scripts/test.ts | 4 +- tests/preload.ts | 2 +- tests/test-runner.test.ts | 134 ++++++++++++++++++++++++++- 4 files changed, 315 insertions(+), 13 deletions(-) diff --git a/scripts/test-run-lock.ts b/scripts/test-run-lock.ts index d1c65487d5..77e5bfa99d 100644 --- a/scripts/test-run-lock.ts +++ b/scripts/test-run-lock.ts @@ -1,5 +1,8 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { + accessSync, + constants, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -8,15 +11,43 @@ import { statSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { hostname, tmpdir } from "node:os"; +import { isAbsolute, join, posix, win32 } from "node:path"; export const TEST_RUN_ID_ENV = "OCX_TEST_RUN_ID"; export const TEST_RUN_NO_QUEUE_ENV = "OCX_TEST_NO_QUEUE"; -const DEFAULT_LOCK_PATH = join(tmpdir(), "opencodex-bun-test.lock"); const OWNER_FILE = "owner.json"; const MEMBERS_DIR = "members"; const INCOMPLETE_OWNER_GRACE_MS = 10_000; +const POSIX_PRIVATE_MODE = 0o700; + +interface RuntimeDirectoryEntry { + uid: number; + mode: number; + isDirectory(): boolean; + isSymbolicLink(): boolean; +} + +export interface TestRunRuntimeFileSystem { + lstatSync(path: string): RuntimeDirectoryEntry; + mkdirSync(path: string, options: { mode: number }): void; + accessSync(path: string, mode: number): void; +} + +export interface ResolveDefaultTestRunLockPathOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + uid?: number; + tempDir?: string; + hostName?: string; + fileSystem?: TestRunRuntimeFileSystem; +} + +const runtimeFileSystem: TestRunRuntimeFileSystem = { + lstatSync, + mkdirSync(path, options) { mkdirSync(path, options); }, + accessSync, +}; export interface TestRunLockOwner { version: 1; @@ -48,6 +79,138 @@ export interface BareTestRunIdentity { runId: string; } +function errorCode(error: unknown): string { + if (!error || typeof error !== "object" || !("code" in error)) return "unknown error"; + return String((error as { code?: unknown }).code ?? "unknown error"); +} + +function inspectRuntimeDirectory(options: { + path: string; + fileSystem: TestRunRuntimeFileSystem; + expectedUid?: number; + requirePrivateMode?: boolean; +}): string | null { + let entry: RuntimeDirectoryEntry; + try { + entry = options.fileSystem.lstatSync(options.path); + } catch (error) { + return `cannot be inspected (${errorCode(error)})`; + } + if (entry.isSymbolicLink() || !entry.isDirectory()) return "is not a real directory"; + if (options.expectedUid !== undefined && entry.uid !== options.expectedUid) { + return "is not owned by the current uid"; + } + if (options.requirePrivateMode && (entry.mode & 0o777) !== POSIX_PRIVATE_MODE) { + return "does not have mode 0700"; + } + try { + options.fileSystem.accessSync(options.path, constants.W_OK | constants.X_OK); + } catch (error) { + return `is not writable/searchable (${errorCode(error)})`; + } + return null; +} + +function machineDiscriminator(hostName: string): string { + const normalized = hostName.trim().toLowerCase(); + if (!normalized) throw new Error("the OS hostname is empty"); + return createHash("sha256").update(normalized).digest("hex").slice(0, 16); +} + +/** + * Resolve a user-scoped, machine-local default lock path without relying on HOME. + * + * POSIX XDG runtime directories are accepted only after an ownership and access + * check. The fallback is a private UID namespace under the OS temp directory. + * The hostname digest remains part of the lock name in either case: even if an + * administrator redirects either root to shared storage, host-local PID liveness + * checks can never reclaim or join another machine's lock. + */ +export function resolveDefaultTestRunLockPath( + options: ResolveDefaultTestRunLockPathOptions = {}, +): string { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const tempDir = options.tempDir ?? tmpdir(); + const fileSystem = options.fileSystem ?? runtimeFileSystem; + let discriminator: string; + try { + discriminator = machineDiscriminator(options.hostName ?? hostname()); + } catch (cause) { + throw new Error( + "Cannot resolve a safe user-scoped Bun test lock: the machine identity is unavailable.", + { cause }, + ); + } + + if (platform === "win32") { + if (!win32.isAbsolute(tempDir)) { + throw new Error( + "Cannot resolve a safe user-scoped Bun test lock: the Windows OS temp/profile path is not absolute.", + ); + } + const issue = inspectRuntimeDirectory({ path: tempDir, fileSystem }); + if (issue) { + throw new Error( + `Cannot resolve a safe user-scoped Bun test lock: the Windows OS temp/profile directory ${issue}.`, + ); + } + return win32.join(tempDir, `opencodex-bun-test-${discriminator}.lock`); + } + + const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined); + if (!Number.isInteger(uid) || (uid ?? -1) < 0) { + throw new Error( + "Cannot resolve a safe user-scoped Bun test lock: the current POSIX uid is unavailable.", + ); + } + + const failures: string[] = []; + const xdgRuntimeDir = env.XDG_RUNTIME_DIR?.trim(); + if (xdgRuntimeDir) { + if (!isAbsolute(xdgRuntimeDir)) { + failures.push("XDG_RUNTIME_DIR is not absolute"); + } else { + const issue = inspectRuntimeDirectory({ + path: xdgRuntimeDir, + fileSystem, + expectedUid: uid, + }); + if (!issue) return posix.join(xdgRuntimeDir, `opencodex-bun-test-${discriminator}.lock`); + failures.push(`XDG_RUNTIME_DIR ${issue}`); + } + } + + if (!isAbsolute(tempDir)) { + failures.push("the OS temporary directory is not absolute"); + } else { + const fallback = posix.join(tempDir, `opencodex-test-runtime-${uid}`); + try { + fileSystem.mkdirSync(fallback, { mode: POSIX_PRIVATE_MODE }); + } catch (error) { + if (errorCode(error) !== "EEXIST") { + failures.push(`the temporary UID runtime directory cannot be created (${errorCode(error)})`); + } + } + if (!failures.some(failure => failure.startsWith("the temporary UID runtime directory cannot be created"))) { + const issue = inspectRuntimeDirectory({ + path: fallback, + fileSystem, + expectedUid: uid, + requirePrivateMode: true, + }); + if (!issue) return posix.join(fallback, `opencodex-bun-test-${discriminator}.lock`); + failures.push(`the temporary UID runtime directory ${issue}`); + } + } + + throw new Error( + "Cannot resolve a safe user-scoped Bun test lock. " + + "Ensure XDG_RUNTIME_DIR is an existing writable directory owned by the current uid, " + + `or make the OS temporary directory usable for a mode-0700 UID runtime (${failures.join("; ")}).`, + ); +} + /** * Give one bare Bun invocation a stable identity without conflating sibling commands. * @@ -151,7 +314,7 @@ function ownsLock(lockPath: string, owner: TestRunLockOwner): boolean { } /** - * Acquire the machine-wide OpenCodex Bun-test lock. + * Acquire the user-scoped, machine-local OpenCodex Bun-test lock. * * `mkdir` is the cross-platform atomic primitive. The owner PID makes a lock left by * SIGKILL recoverable, while the run ID lets every worker belonging to one bare @@ -165,7 +328,8 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr return { acquired: false, owner: null, release() {} }; } - const lockPath = options.lockPath ?? DEFAULT_LOCK_PATH; + const usesDefaultLockPath = options.lockPath === undefined; + const lockPath = options.lockPath ?? resolveDefaultTestRunLockPath({ env }); const ownerPid = options.ownerPid ?? process.pid; const pollMs = Math.max(1, options.pollMs ?? 5_000); const maxWaitMs = Math.max(pollMs, options.maxWaitMs ?? 45 * 60 * 1000); @@ -198,7 +362,17 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr }, }; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + if (usesDefaultLockPath && ["EACCES", "ENOENT", "EPERM", "EROFS"].includes(code ?? "")) { + throw new Error( + "Cannot acquire the user-scoped Bun test lock because its validated runtime directory " + + "became unavailable or unwritable. Check XDG_RUNTIME_DIR and the OS temporary directory.", + { cause: error }, + ); + } + throw error; + } } const current = readOwner(lockPath); diff --git a/scripts/test.ts b/scripts/test.ts index 832a537191..6d10b2c4a7 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -452,10 +452,10 @@ if (import.meta.main) { const lock = await acquireTestRunLock({ runId, onWait: owner => console.warn( - `[test] another Bun test run${owner ? ` (pid ${owner.pid})` : ""} holds the machine lock; waiting. ` + `[test] another Bun test run${owner ? ` (pid ${owner.pid})` : ""} holds the user lock; waiting. ` + "Set OCX_TEST_NO_QUEUE=1 only for intentional overlap.", ), - onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the machine lock after ${Math.round(elapsedMs / 1000)}s.`), + onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the user lock after ${Math.round(elapsedMs / 1000)}s.`), }); const startedAt = Date.now(); try { diff --git a/tests/preload.ts b/tests/preload.ts index 37b2233df0..dd04c5c33c 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -34,7 +34,7 @@ await acquireTestRunLock({ runId, ownerPid: bareIdentity.ownerPid, onWait: owner => console.warn( - `[test] bare Bun worker ${process.pid} is waiting for test run${owner ? ` pid ${owner.pid}` : ""} to release the machine lock.`, + `[test] bare Bun worker ${process.pid} is waiting for test run${owner ? ` pid ${owner.pid}` : ""} to release the user lock.`, ), }); diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index 2d5423d628..ed8e92d3ae 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, posix, win32 } from "node:path"; import { changedSelectionFailure, createIsolatedTestEnvironment, @@ -14,7 +14,9 @@ import { import { acquireTestRunLock, resolveBareTestRunIdentity, + resolveDefaultTestRunLockPath, TEST_RUN_NO_QUEUE_ENV, + type TestRunRuntimeFileSystem, } from "../scripts/test-run-lock"; import { decodeWindowsIdentityPowerShellOutputForTests, @@ -37,6 +39,28 @@ function runGit(cwd: string, ...args: string[]): string { // handed to git are identical either way. const FIXTURE_COMMIT_EMAIL = ["test", "opencodex.invalid"].join("@"); +function pathIsContainedBy(parent: string, candidate: string, platform: "posix" | "win32"): boolean { + const path = platform === "win32" ? win32 : posix; + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative === "" || (!relative.startsWith(`..${path.sep}`) + && relative !== ".." && !path.isAbsolute(relative)); +} + +function acceptingRuntimeFileSystem(uid: number, writable = true): TestRunRuntimeFileSystem { + return { + lstatSync: () => ({ + uid, + mode: 0o700, + isDirectory: () => true, + isSymbolicLink: () => false, + }), + mkdirSync: () => {}, + accessSync: () => { + if (!writable) throw Object.assign(new Error("denied"), { code: "EACCES" }); + }, + }; +} + function commitFixture(cwd: string, path: string, contents: string, message: string): string { writeFileSync(join(cwd, path), contents); runGit(cwd, "add", path); @@ -361,7 +385,111 @@ describe("bun test argv", () => { }); }); -describe("bun test machine lock", () => { +describe("bun test user lock", () => { + test("distinct POSIX users receive distinct temp-runtime locks", () => { + const common = { env: {}, tempDir: "/tmp", hostName: "builder-1", platform: "linux" as const }; + const alice = resolveDefaultTestRunLockPath({ + ...common, + uid: 1001, + fileSystem: acceptingRuntimeFileSystem(1001), + }); + const bob = resolveDefaultTestRunLockPath({ + ...common, + uid: 1002, + fileSystem: acceptingRuntimeFileSystem(1002), + }); + + expect(alice).not.toBe(bob); + expect(pathIsContainedBy("/tmp/opencodex-test-runtime-1001", alice, "posix")).toBe(true); + expect(pathIsContainedBy("/tmp/opencodex-test-runtime-1002", bob, "posix")).toBe(true); + }); + + test("a shared home cannot couple locks from distinct hosts", () => { + const common = { + env: { HOME: "/network/users/alice" }, + uid: 1001, + tempDir: "/tmp", + platform: "linux" as const, + fileSystem: acceptingRuntimeFileSystem(1001), + }; + const firstHost = resolveDefaultTestRunLockPath({ ...common, hostName: "builder-1" }); + const secondHost = resolveDefaultTestRunLockPath({ ...common, hostName: "builder-2" }); + + expect(firstHost).not.toBe(secondHost); + expect(pathIsContainedBy(common.env.HOME, firstHost, "posix")).toBe(false); + expect(pathIsContainedBy(common.env.HOME, secondHost, "posix")).toBe(false); + }); + + test("Windows uses the OS temp/profile result when USER is absent", () => { + const common = { + platform: "win32" as const, + tempDir: "C:\\Users\\Alice\\AppData\\Local\\Temp", + hostName: "desktop-1", + fileSystem: acceptingRuntimeFileSystem(0), + }; + const withoutUser = resolveDefaultTestRunLockPath({ ...common, env: {} }); + const withUnrelatedUser = resolveDefaultTestRunLockPath({ + ...common, + env: { USER: "someone-else" }, + }); + + expect(withoutUser).toBe(withUnrelatedUser); + expect(pathIsContainedBy(common.tempDir, withoutUser, "win32")).toBe(true); + }); + + test("falls back from an unsafe XDG root to a validated mode-0700 UID directory", () => { + if (process.platform === "win32" || typeof process.getuid !== "function") return; + const root = mkdtempSync(join(tmpdir(), "opencodex-runtime-fallback-")); + const unsafeXdg = join(root, "not-a-directory"); + writeFileSync(unsafeXdg, "unsafe\n"); + try { + const lockPath = resolveDefaultTestRunLockPath({ + env: { XDG_RUNTIME_DIR: unsafeXdg }, + uid: process.getuid(), + tempDir: root, + hostName: "builder-1", + }); + const runtimeRoot = dirname(lockPath); + const entry = statSync(runtimeRoot); + + expect(runtimeRoot).toBe(join(root, `opencodex-test-runtime-${process.getuid()}`)); + expect(entry.isDirectory()).toBe(true); + expect(entry.uid).toBe(process.getuid()); + expect(entry.mode & 0o777).toBe(0o700); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails immediately with actionable guidance when every runtime root is unwritable", () => { + expect(() => resolveDefaultTestRunLockPath({ + platform: "linux", + env: { XDG_RUNTIME_DIR: "/run/user/1001" }, + uid: 1001, + tempDir: "/tmp", + hostName: "builder-1", + fileSystem: acceptingRuntimeFileSystem(1001, false), + })).toThrow( + "Cannot resolve a safe user-scoped Bun test lock. Ensure XDG_RUNTIME_DIR", + ); + }); + + test("containment checks do not confuse path string prefixes on POSIX or Windows", () => { + const home = "/home/alice"; + const lockPath = resolveDefaultTestRunLockPath({ + platform: "linux", + env: { HOME: home }, + uid: 1001, + tempDir: "/home", + hostName: "builder-1", + fileSystem: acceptingRuntimeFileSystem(1001), + }); + + expect(home.startsWith("/home")).toBe(true); + expect(pathIsContainedBy(home, lockPath, "posix")).toBe(false); + expect(pathIsContainedBy("C:\\Users\\Ann", "C:\\Users\\Anna\\lock", "win32")).toBe(false); + }); + test("independent bare runners do not inherit a shared long-lived parent identity", () => { expect(resolveBareTestRunIdentity({ pid: 101, ppid: 50 })).toEqual({ ownerPid: 101, From 4f6a19643a46c7ae258d01445e09203509190663 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 30 Aug 2026 11:35:05 +0900 Subject: [PATCH 104/132] fix(gui): give the provider toggle a real flex basis so the header can wrap (#2958) * fix(gui): give the provider toggle a real flex basis so the header can wrap The Models provider header collapsed between roughly 1040 and 1380px: the provider name measured 0.0px and painted its glyphs across the active count, and the alias chip broke into a six-line blob. Both were one cause. The toggle's inline `flex: 1` resolves to `flex: 1 1 0%`, and a flex item with a zero base size never reports a content requirement, so the header's existing `flex-wrap: wrap` never learned the toggle needed room and handed it the 31px the actions cluster left over. At 1100 the actions took 422.9 of 488px. Two properties are needed and they pull against each other. Visibility comes from `flex: 1 1 auto`, so the content enters the header's wrap decision. Boundedness comes from removing every child's automatic min-content floor, because a flex child stops shrinking at its own `min-width: auto` and the sum of those floors can still exceed the card. The child rule is quantified rather than enumerated. Four earlier drafts bounded the row by naming the children that could overflow it - name, then alias chip, then the count and badge - and each revision found another one. The `svg` exemption is the inverse failure: the universal rule also matched the chevron, whose inline `width: 14` is not a flex floor, and it rendered 2.5px wide while the containment check still reported success. Text children abbreviate; icons have nothing to truncate. Measured in a real browser at dpr 2: 20/20 cells clean across ko/ru/fr/en/de x 1440/1280/1100/1024, red on the pre-fix stylesheet (ko/1100 three bad rows, ko/1280 four). Containment holds at -2 on five stress cases including every child forced to 64 characters, with the chevron at 14px. Screenshots and the pixel readback that confirms the collapse are in the devlog unit. Also lifts the effective-declaration CSS readers out of viewport-scroll-caps.test.ts, where they were file-local, into gui/tests/helpers/css-declarations.ts so this test can use them without a third copy. * fix(gui): address review findings on the provider-header record Three CodeRabbit findings, all correct: - The new test names cited `#2916`, a PR number guessed before this branch had one. The PR is #2958. - `010` still described the effective-declaration reader as unimportable and left the export-versus-move decision open. B resolved it by moving all four helpers into `gui/tests/helpers/css-declarations.ts` and rewriting the original test to import them, so the doc now records that outcome and lists the module in the diff scope. - `020`'s rendered CDP check asked whether each `button.switch` carries visible text or a `title`, which contradicts the wrapper rule its own item 3 states: a `showLabel` switch puts its text in a sibling inside `.switch-labeled` and carries no `title`. As written the check would have failed exactly the controls that phase fixes, so it now applies the wrapper-aware condition. No behavior change; documentation and test names only. --- .../000_baseline_and_roadmap.md | 168 ++++++++++ .../010_toggle_basis_and_shrink.md | 286 ++++++++++++++++++ .../011_rejected_designs.md | 179 +++++++++++ .../020_control_affordances.md | 170 +++++++++++ .../evidence/010-after-ko-1100.png | Bin 0 -> 42401 bytes .../evidence/010-before-ko-1100.png | Bin 0 -> 45872 bytes gui/src/pages/Models.tsx | 2 +- gui/src/styles-models-workspace.css | 25 ++ gui/tests/helpers/css-declarations.ts | 78 +++++ gui/tests/models-provider-head.test.ts | 111 +++++++ gui/tests/viewport-scroll-caps.test.ts | 66 +--- 11 files changed, 1019 insertions(+), 66 deletions(-) create mode 100644 devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md create mode 100644 devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md create mode 100644 devlog/_plan/260830_models_provider_header/011_rejected_designs.md create mode 100644 devlog/_plan/260830_models_provider_header/020_control_affordances.md create mode 100644 devlog/_plan/260830_models_provider_header/evidence/010-after-ko-1100.png create mode 100644 devlog/_plan/260830_models_provider_header/evidence/010-before-ko-1100.png create mode 100644 gui/tests/helpers/css-declarations.ts diff --git a/devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md b/devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md new file mode 100644 index 0000000000..f56cca71d6 --- /dev/null +++ b/devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md @@ -0,0 +1,168 @@ +# 000 — Models provider-row header: unreadable chip, overlapping name, meaningless controls + +Reported against the running dashboard's Models page with a screenshot: "이부분도 +존나 이상해 신규 2개 꺼짐, 펜, 스위치(이건 뭘하는지도 모르겠음), 사용자 지정창이랑 +마지막 스위치는 뭔지도 모름". + +Two distinct failures are stacked in one header, and they need different fixes: + +- **Geometry** — the "신규 N개, 꺼짐" chip collapses into a rounded blob and the + provider name paints on top of the active count. +- **Meaning** — three controls are operable but unlabeled: a sighted user cannot + tell what they do. This half is not a layout bug and cannot be fixed by + layout. + +As with the sidecar unit, every defect below carries a measured baseline from a +CDP harness (`Emulation.setDeviceMetricsOverride`, dpr 2, live +`getBoundingClientRect`), so each claim is re-checkable. + +## Baseline (ko, provider rows on `#models`) + +Measured with `.tmp/uiux2/head.ts`, which settles on `innerWidth === target` and +on rendered provider rows before reading geometry. The proxy at 127.0.0.1:10100 +supplies the live provider list through the Vite `OPENCODEX_PROXY_TARGET` proxy, +so these are real rows, not fixtures. + +| width | provider | header h | name box w | chip lines | chip w | +|-------|----------|----------|------------|-----------|--------| +| 1440 | opencode-free | 44.8 | 96.5 | 1 | 92.1 | +| 1280 | opencode-free | 55.7 | 67.2 | **2** | 69.6 | +| 1280 | openai | 55.7 | **9.9** | **2** | 57.7 | +| 1100 | opencode-free | **115.1** | **0.0** | **6** | 34.1 | +| 1100 | cursor | **115.1** | **0.0** | **6** | 34.1 | +| 1024 | opencode-free | 75.8 | 96.5 | 1 | — | + +The 1100 row is the screenshot state: a six-line chip 34.1px wide, a name box +measuring **zero**, and a header 2.6x its correct height. 1024 recovers because +the container query at `styles-models-workspace.css:517` moves the actions onto +their own row, which returns the toggle's width. The defect therefore lives in a +**band** (roughly 1040-1380 in this layout), which is why it is easy to miss at +either extreme. + +## Defect 1 — the chip is a shrinkable flex item with no single-line floor + +`.models-chip` (`styles-models-workspace.css:315`) declares +`display: inline-block` plus padding, border and `border-radius`, and nothing +else. Because it sits inside `.row models-provider-toggle` +(`Models.tsx:1226`) and `.row` is `display: flex` (`styles.css:1205`), the chip +is a **flex item**: its `inline-block` outer display is blockified and its +initial `flex-shrink: 1` applies. Measured computed values confirm it — +`white-space: normal`, `flex-shrink: 1`. + +`inline-block` does not imply `white-space: nowrap`. The chip's only floor is +`min-width: auto`, which resolves to the text's **min-content** width — and for +Korean that is nearly one syllable, because CJK line-breaking permits a break +between Hangul syllable blocks. So `신규 2개, 꺼짐` legally becomes +`신규 / 2 / 개, / 꺼 / 짐`, and the fixed padding wrapped around that narrow +column is exactly the observed blob. + +~~Fix: give the chip a single-line floor.~~ **Superseded.** Measurement showed the +chip is not independently broken: it is starved of width by a collapsed ancestor, +and it returns to one line as soon as that ancestor claims its intrinsic width. An +audit also found a chip-level floor unsafe across the eight other `.models-chip` +call sites. The shipped fix leaves the shared `.models-chip` primitive untouched; +it adds an ellipsis only to the toggle-scoped descendant — see `010` and `011`. + +## Defect 2 — the name overflows a zero-width box instead of reflowing + +The name span carries inline `whiteSpace: "nowrap"` (`Models.tsx:1232`) while +`styles-models-workspace.css:267` gives it `min-width: 0` and +`overflow-wrap: anywhere`. Those two fight: `nowrap` suppresses the wrapping +that `overflow-wrap: anywhere` was added to provide, `min-width: 0` lets the box +shrink to nothing, and the default `overflow: visible` means the glyphs keep +painting outside the box — straight across the sibling count. + +Nothing positions these elements on top of each other: there is no `position`, +transform, or negative margin anywhere in the applicable rules. The count is +laid out normally *after* a box that measures 0px, so the collision is pure +overflow. + +Why the header's own `flex-wrap: wrap` does not save it: the header's direct +children are only the toggle button and the actions container. Wrapping does not +propagate into descendants, and the toggle's inner `.row` has no `flex-wrap`, +so the chevron, name, chips and count are locked on one line and shrink against +each other. + +The upstream enabler is `flex: 1` on the toggle (`Models.tsx:1229`), which +resolves to `flex: 1 1 0%` — zero basis, shrink allowed — combined with +`min-width: 0`. The toggle then accepts whatever the wide actions cluster leaves +it rather than forcing a wrap. + +~~Fix: let the toggle's own row wrap.~~ **Superseded.** Inner wrapping is inert: +line construction inside the button runs after its used width has been assigned, so +wrapping redistributes 31px rather than asking for more. Measured: the candidate +left the name box at 0.0px, byte-identical to baseline. The shipped fix gives the +toggle a real flex **basis** so its content enters the header's wrap decision, and +removes every child's min-content floor so the row can always shrink back inside the +card — see `010`. + +## Defect 3 — three controls carry no visible meaning (two switches and the `+`) + +`Switch` (`ui.tsx:8`) accepts a `label` prop and spends it **only** on +`aria-label` (`ui.tsx:11`); its sole child is ``. So +every `Switch` in this codebase is, to a sighted user, an unlabeled toggle. The +user's "이건 뭘하는지도 모르겠음" is a correct reading of the UI. + +Audit of the header controls in visual order: + +| control | visible | aria-label | title | verdict | +|---------|---------|-----------|-------|---------| +| collapse button | chevron + name + count | (children) | — | OK | +| pencil | icon only | 공급자 별칭 편집 | yes | OK | +| default-aliases Switch | knob only | 기본 별칭 사용 | — | **OPAQUE** | +| 사용자 지정 창 | text | — | — | OK | +| `+` | `+` only | 커스텀 모델 추가 | — | **OPAQUE** | +| preset segmented | 프리셋 / 전체 | group only | — | OK | +| 모두 켜기 / 모두 끄기 | text | — | — | OK | +| cap Switch | knob only | 기본 {value} | — | **OPAQUE** | +| cap Select | number only | 기본 {value} | — | **OPAQUE** | + +The pencil is fine precisely because it pairs an icon with `title` — that is the +pattern the opaque controls are missing. + +Two aggravating details: + +1. The cap Switch's accessible name is `기본 128k` — a *value*, not a function. + Even a screen-reader user is not told this governs the context-window cap. +2. For routed providers with the cap off, `(capOn || nativeProviderGroup)` + (`Models.tsx:1360`) hides the Select, so the only thing left is a bare + toggle with no adjacent number to hint at its purpose. The worst state is the + default state. + +### Design constraint + +This is a dense expert control surface: `DESIGN_VARIANCE 2`, `MOTION 1`, density +D6+. The domain gate is strict — no decorative kit, no motion, no new color. The +fix is *labels and reflow*, and the correct instrument is the existing +`title`-plus-icon pattern already proven by the pencil, plus a visible text +label where the header has room for one. + +UX-LAZY-01 was applied to each control before relabeling it rather than after: +every one of them is a real per-provider setting with no correct global default, +so none can be deleted or absorbed. They need meaning, not removal. + +## Roadmap + +- `010` — let the toggle's content be seen, and make every child yield (geometry). + Five designs; the first four were rejected by audit or stress measurement and + `011` records why. +- `020` — control affordances: visible labels for the opaque controls, and a + `Switch` that can render one. + +Each is one PABCD work-phase and one stacked PR. `010` lands first because `020` +adds visible text to the same header and would otherwise be measured against a +layout that is still collapsing. + +## Verification contract + +- Re-measure the sweep at 1440/1280/1100/1024 in ko + ru + fr + en and require: + chip `lines === 1` everywhere, name box width > 0, zero name/count overlap, and + header height within one line-height of the 1440 baseline. +- A focused `gui/tests` regression per phase, driven red against current CSS + first. +- Remote gates only (`ssh lidge` + `ocx-run`); the local full suite is forbidden + by the user. Push `--no-verify`. +- Before/after screenshots at the failing width, per `AGENTS.md` enforce-target. + + + diff --git a/devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md b/devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md new file mode 100644 index 0000000000..dd86c0b9d3 --- /dev/null +++ b/devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md @@ -0,0 +1,286 @@ +# 010 — Let the toggle's content be seen, and make every child yield + +Fixes the geometry half. Meaning is phase `020`. + +**Sixth design.** The five before it were each rejected by an adversarial reviewer or +by a stress measurement, and the rejections are the useful part — they map the shape +of the problem: + +| draft | approach | killed by | +|-------|----------|-----------| +| 1 | shared-chip `nowrap`/`flex-shrink: 0` + inner `flex-wrap` + name ellipsis | inner wrap is inert; shared-chip change unsafe elsewhere | +| 2 | `min-width: max-content` floor | unbounded: 64-char name overflowed the card by 216px | +| 3 | floor + 16rem name cap + 12rem chip cap | 64-char name **and** alias together still overflowed 64px | +| 4 | `flex-basis: auto` + ellipsis on name and chip | the count and badge children kept min-content floors | +| 5 | `flex-basis: auto` + one rule for every child | let the fixed-size chevron shrink to 2.5px | +| **6** | **draft 5 + a `flex: none` exemption for the icon** | — | + +Drafts 2-4 were three versions of one mistake: bound the row by naming the children +that could overflow it, then discover the next child. Draft 5 stops naming children — +and then over-applied, shrinking an icon that has no text to truncate. Draft 6 keeps +the universal rule and exempts the one child whose size is intrinsic rather than +textual. `011` records each failure. + +## The mechanism, measured + +At 1100px the collapsed row measures: + +| element | width | +|---------|-------| +| `.models-provider-head` | 488.0 | +| `.models-provider-actions` | **422.9** (scrollWidth 423) | +| `.models-provider-toggle` | **31.1** (scrollWidth 93) | +| name span inside it | **0.0** (scrollWidth 44) | + +The toggle carries inline `flex: 1` (`Models.tsx:1229`), which resolves to +`flex: 1 1 0%`. That zero **basis** is the defect. A flex item with a zero base size +never reports a content requirement, so the header — which already has +`flex-wrap: wrap` — never learns the toggle needs room and never wraps the actions +cluster to its own line. It keeps one line and hands the toggle the 31px remainder. + +Inside that remainder the name absorbs the whole deficit, measures 0.0px, and — +carrying inline `white-space: nowrap` with default `overflow: visible` — paints its +glyphs across the count. The chip blob is the same starvation, finished by CJK +line-breaking between Hangul syllables. Even the chevron collapses: measured 0px wide +on a starved row, against 14px on a healthy one. + +Two independent properties are required: + +- **Visibility** — the toggle's content must enter the header's wrap decision, so it + receives a share rather than a remainder. That is `flex-basis: auto`. +- **Boundedness** — whatever the content, the row must not force itself wider than the + card. Shrinkability alone does not give this: a flex child stops at its own + `min-width: auto` floor, which is its min-content width, and the *sum* of those + floors can exceed the container. + +The bound has one precondition worth stating plainly, because the round-5 audit caught +the document overstating it: `> *` selects **element** children. A bare string +interpolated directly into the button becomes an anonymous flex item, which no selector +can reach, and it would keep its own min-content floor. Every child today is an +`` or a ``, so the rule covers all of them — but the guarantee is +"every element child, and the markup keeps children element-wrapped", not "anything +anyone adds later". The regression test asserts that second half. + +Draft 2 bought visibility with a raised *minimum*, which is the direct enemy of +boundedness. Draft 4 bought boundedness for the two children it named and left the +count and the discovery badge with their automatic floors intact. + +## The change + +`gui/src/pages/Models.tsx` (—1229), the inline style on the toggle button: + +```diff +- style={{ flex: 1, border: 0, ... }} ++ style={{ flex: "1 1 auto", border: 0, ... }} +``` + +It has to be the TSX: an inline style beats any stylesheet rule short of +`!important`, and reaching for `!important` against markup we own is the wrong +trade. + +`gui/src/styles-models-workspace.css`: + +```css + .models-provider-toggle { + min-width: 0; + } + ++/* Every child, not an enumerated list. Four earlier designs bounded the row by ++ naming the children that could overflow it (name, then alias chip, then the ++ count and badge), and each revision found another one; a child added later ++ would have reintroduced the defect silently. Quantifying over the children ++ instead: min-width:0 removes the automatic min-content floor that stops a flex ++ child shrinking, and the ellipsis makes that shrink legible instead of clipped. ++ Covers every ELEMENT child; a bare interpolated string would become an ++ anonymous flex item no selector can reach, so keep children element-wrapped. */ ++.models-provider-toggle > * { ++ min-width: 0; ++ overflow: hidden; ++ text-overflow: ellipsis; ++ white-space: nowrap; ++} + ++/* The one exemption, and why it is not a return to enumerating children: every ++ other child is TEXT, whose overflow the ellipsis makes legible. The chevron is ++ an icon at a fixed 14px with nothing to truncate, so shrinking it destroys the ++ collapse affordance instead of abbreviating it. Selected by element TYPE, not ++ by identity — any future icon child inherits it without being named. Measured: ++ without this, the adversarial stress case shrinks the chevron to 2.5px while ++ the containment gate still reports success. */ ++.models-provider-toggle > svg { ++ flex: none; ++} +``` + +`min-width: 0` on the toggle is **kept**, not replaced. That also means the existing +assertion at `gui/tests/models-provider-head.test.ts:29` stays green — draft 2 would +have broken it. + +**No `max-width` anywhere, and no child named by identity.** The bound comes from +removing every child's floor, so there is nothing to forget and nothing to re-tune when +a chip is added to this header later. The single exemption selects on element type +(`svg`), which is the distinction that matters: text children abbreviate, icons do not. + +## Measured result + +Gate: in every cell `chipLines === 1`, name width > 0, name text overflow +(`scrollWidth - width`) <= 0, no page overflow. + +| | ko | ru | fr | en | de | +|-|----|----|----|----|----| +| 1440 | pass | pass | pass | pass | pass | +| 1280 | pass | pass | pass | pass | pass | +| 1100 | pass | pass | pass | pass | pass | +| 1024 | pass | pass | pass | pass | pass | + +20/20, worst bad-cell count 0, re-measured after the chevron exemption was added +(draft 6). The chevron also returns to 14px on the rows where it had collapsed to 0. + +Containment, reading `cardScrollOver` = card `scrollWidth` minus its width, where +**positive means the card is silently clipping** (`.models-provider-card` sets +`overflow: hidden`, `styles-models-workspace.css:296`): + +| stress case | baseline | draft 3 | draft 4 | draft 5 | **draft 6** | +|-------------|---------:|--------:|--------:|--------:|------------:| +| 64-char name @1100 | 39 | -2 | -2 | -2 | **-2** | +| 64-char alias @1100 | 16 | -2 | -2 | -2 | **-2** | +| name + alias together @1100 | 229 | **64** | -2 | -2 | **-2** | +| realistic worst row @1100 (64-char name + alias + longest `de` badge) | 229 | — | -2 | -2 | **-2** | +| adversarial: every child forced to 64 chars @1100 | 484 | — | **484** | -2 | **-2** | +| chevron width in that adversarial case | 14 | — | — | **2.5** | **14** | + +The last row is what draft 4 could not survive and what forced the universal rule. It +is deliberately beyond reachable input — the count and badge are localized strings +with small interpolated numbers, not free text — but it is the only case that proves +the bound does not depend on knowing what the children are. + +The final row is the round-5 audit finding, and it is the reason containment alone is +not a sufficient gate: draft 5 reported `cardScrollOver: -2` on the adversarial case +**while** silently shrinking the 14px collapse chevron to 2.5px. A gate that measures +only "does the row fit" certifies a fix that bought the fit by destroying an +affordance. `flex: none` on the icon restores 14px with containment unchanged at -2. + +The gate is not vacuous: against the unpatched stylesheet it reports +`ko/1100 bad=3` (0px name, **6-line** chip) and `ko/1280 bad=4` (name 9.9px, chip 2 +lines). `ru/1100` is green even unpatched — Russian wraps to a wider min-content — +which is why a single-locale check would have missed this defect entirely. + +## Removal test + +| dropped | normal bad cells @ko/1100 | adversarial stress | realistic stress | chevron @adversarial | +|---------|--------------------------:|-------------------:|-----------------:|---------------------:| +| nothing | 0 | -2 | -2 | 14 | +| `flex: 1 1 auto` | **3** | -2 | -2 | 14 | +| the child rule | 0 | **908** | **229** | 14 | +| the `svg` exemption | 0 | -2 | -2 | **2.5** | + +All three are load-bearing and none substitutes for another: the basis fixes the +everyday defect, the child rule bounds the pathological ones, and the exemption keeps +the child rule from paying for that bound with the collapse affordance. Each row was +driven by actually removing the declaration and re-measuring. Contrast drafts 1 and 3, +where four of five and two of three declarations measured inert. + +## Cost of the universal rule + +`white-space: nowrap` on every child means no child of this header can wrap. That is +correct here — it is a single-line identity row of a slug, chips and a count, none of +which should ever wrap — but it is a real constraint on future content. Anything +genuinely multi-line belongs in `.models-provider-body`, not the header. The +alternative was another enumerated exception list, which is what drafts 2-4 already +disproved. + +## What is deliberately NOT changed + +- **The shared `.models-chip` rule.** Only the toggle's own children are touched. The + model-row chips at `Models.tsx:1447-1455` sit in a non-wrapping `.row` with long + translations (de "Benutzerdefiniert", ru "Пользовательская"); a primitive-level + change there was rejected in draft 1. +- `overflow-wrap: anywhere` stays on the existing name rule although the inline + `white-space: nowrap` makes it dead. Removing it is unrelated cleanup; it is noted + so the next reader knows it is inert rather than load-bearing. + +## Diff scope + +- `gui/src/pages/Models.tsx` — one inline style value. +- `gui/src/styles-models-workspace.css` — two rules added (the universal child rule + and the `svg` exemption); the existing `min-width: 0` on the toggle is kept. +- `gui/tests/models-provider-head.test.ts` — extended; the existing line-29 + `min-width: 0` assertion stays valid and must not be removed. +- `gui/tests/helpers/css-declarations.ts` — NEW. The shared source-text CSS readers, + lifted out of `viewport-scroll-caps.test.ts` so two tests can use one copy. +- `gui/tests/viewport-scroll-caps.test.ts` — its four file-local helpers are deleted and + replaced by an import from that module; its assertions are unchanged. + +## Regression test (red first) + +Use the effective-declaration reader so a commented-out or custom-property occurrence +cannot satisfy an assertion. + +**It lives in `gui/tests/helpers/css-declarations.ts`**, which exports +`effectiveDeclaration`, `ruleBodies`, `allRuleBodies` and `withoutComments`. + +That module is part of this change. The reader originated in +`viewport-scroll-caps.test.ts` (PR #2915) as four **file-local, unexported** functions, +so it could not be imported as first planned. B resolved that by moving all four into the +shared module and rewriting the original test to import them — one copy, not the third +copy that copying them here would have produced. + +**What this gate can and cannot see.** The reader's own comment (:53) records that it +does not model competing specificity, `!important`, or at-rule nesting. So it proves +the four declarations exist on the exact selector, and nothing about computed layout: +the ellipsis, the containment numbers, and the 14px chevron are **measurements** +recorded above, not unit assertions. That split is deliberate and is why the tables in +this document are the primary evidence for the fix. + +1. The provider-toggle button in `Models.tsx` carries `flex: "1 1 auto"`. The + negative half must be **scoped to that style object**, not a file-wide search for + `flex: 1` — a legitimate bare `flex: 1` exists at `Models.tsx:2162`, so a global + assertion would be wrong. This is the declaration whose absence reproduces the + user's screenshot. +2. `.models-provider-toggle > *` declares `min-width: 0`, `overflow: hidden`, + `text-overflow: ellipsis` and `white-space: nowrap`, with a comment naming the + defect so the rule is not narrowed back to specific children later. +3. `.models-provider-toggle > svg` declares `flex: none`. Assert this **separately** + from rule 2: it is the declaration whose removal reintroduces the 2.5px chevron, and + a reader who sees only the universal rule is likely to delete it as redundant. +4. Every direct child the toggle renders is an **element**, never a bare string. The + universal selector cannot reach an anonymous flex item, so this is the invariant the + `> *` bound actually rests on. Assert that the JSX between the toggle's opening and + closing tag contains no bare interpolation — all seven children today are `` or + ``. + +A declaration test cannot observe clipping, so the containment table above stays a +recorded measurement rather than a unit assertion. + +## Render grounding + +Screenshots at the failing width, captured from the running dashboard and then **read +back** rather than merely produced: `evidence/010-before-ko-1100.png` and +`evidence/010-after-ko-1100.png` (ko, 1100px, dpr 2, the second chip-bearing provider +row). The before shot is the shipped build with the fix reverted **in the browser** by an +injected override, so both images come from the same code and differ only by the two +declarations. + +| | before | after | +|-|-------:|------:| +| capture height (dpr 2) | 696px | **416px** | +| rows containing ink | 365 | **101** | +| name box | 8.6px, chip on 6 lines | **43.6px, chip on 1 line** | + +Pixel readback is the observation step: ink was counted per row against the sampled +background luminance, which is what confirms the vertical sprawl actually collapsed +rather than the clip rectangle merely shrinking. + +The chevron was verified the same way, since a rendered width is exactly what the round-5 +audit found the numbers hiding. Under the adversarial stress row at 1100: + +| | `getBoundingClientRect` | drawn glyph span | +|-|------------------------:|-----------------:| +| shipped (exemption present) | **14.0px**, `flex-shrink: 0` | 9.5px | +| exemption overridden away | 4.9px, `flex-shrink: 1` | 36.8px of smeared ink | + +Two notes on reproducing this. `Page.captureScreenshot` hangs indefinitely over CDP +unless `Page.bringToFront` is called first. And an injected `