From 2a10a2bc450067e3b475315ffeaefc4bb5b52ab4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:30:38 -0700 Subject: [PATCH 01/29] chore(release): open dev at 2.89.0 before releasing 2.88.0 (#7263) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ffe3177df6..f7645cc7b7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.88.0" +version = "2.89.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e2646bca24..8c5cb958a8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.88.0" +version = "2.89.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 50ce2387ef..3a3751ddbe 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.88.0", + "version": "2.89.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 5b09a0263b..c1e5e08ab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.88.0", + "version": "2.89.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 688b25845443ab9d3f6e01a124e60ddbf3742e10 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 13:00:23 -0700 Subject: [PATCH 02/29] feat(compatibility): separate custom-tool grammar evidence from representation (#7282) * feat(compatibility): separate custom-tool grammar evidence from representation Record custom-tool representation and grammar as separate compatibility claims, and add a second exact subject where grammar is lost. - canonical Codex forward (gpt-5.6-sol): new request.custom_tools.grammar passthrough claim backed by a format assertion with a real Lark definition; manifest 1.4.0 - OpenGateway key auth (z-ai/glm-5.3-flash-ultrafast on the Responses wire, supportsResponsesCustomTools=false): custom tools translated to a string-input function, grammar degraded, custom_tool_call and custom_tool_call_output history translated - manifest test runs each fixture against the production adapter for its own subject, pins every subject's route, and adds a catalog evidence gate: cited assertion ids must exist in the owning fixture, and a passthrough claim may only cite equals assertions whose expected value is the caller's own input, so grammar loss cannot be labeled lossless No runtime behavior change; manifests stay passive. Refs #2358 * test(compatibility): bind passthrough evidence to the claimed feature A passthrough claim could cite any preserved caller value, so a grammar claim backed by an equals assertion on /body/model passed the gate while the grammar was dropped. Each passthrough-claimable feature now names the selectors that can prove it, grammar evidence must carry a non-empty definition, and unbound features cannot be claimed as passthrough. Refs #2358 * docs(contributing): sync passthrough evidence rule to localized guides Refs #2358 --- docs-site/src/content/docs/contributing.md | 5 + docs-site/src/content/docs/ja/contributing.md | 4 + docs-site/src/content/docs/ko/contributing.md | 4 + docs-site/src/content/docs/ru/contributing.md | 5 + .../src/content/docs/zh-cn/contributing.md | 3 + src/compatibility/index.ts | 3 + src/compatibility/openai-responses.ts | 9 +- src/compatibility/opengateway-responses.ts | 61 +++ structure/adapters/compatibility-contracts.md | 54 ++- .../compatibility-manifest.test.ts | 440 +++++++++++++++--- .../openai-codex-forward-gpt56-sol-v1.json | 10 +- .../opengateway-key-glm53-responses-v1.json | 121 +++++ 12 files changed, 652 insertions(+), 67 deletions(-) create mode 100644 src/compatibility/opengateway-responses.ts create mode 100644 tests/fixtures/compatibility/opengateway-key-glm53-responses-v1.json diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index aa8668df8c..bfa47f4d4e 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -263,6 +263,11 @@ Use one of the versioned dispositions: `passthrough`, `translated`, `degraded`, Every non-passthrough claim must state its limitation, and every fixture-backed claim must name the exact assertion ids that prove it. Add the secret-free request vector under `tests/fixtures/compatibility/` and execute it against the production adapter in a focused test. +A `passthrough` claim may only cite `equals` assertions whose expected value is the caller's own +value at the same place, on a selector bound to that feature in the manifest test, so a removed, +rewritten, or unrelated field cannot be labeled lossless. Keep related +aspects as separate claims when a route can preserve one and lose the other, such as a custom tool +and its grammar. Compatibility manifests are passive data: the ordinary router, Responses handler, and server startup path must not import the manifest catalog or activate Compatibility Lab. diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 5a82e0e607..9ad672176e 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -175,6 +175,10 @@ versioned disposition は `passthrough`、`translated`、`degraded`、`unsupport `passthrough` 以外のクレームには具体的な制限を記載し、fixture に基づくクレームでは根拠となる正確な assertion id を指定してください。秘密情報を含まない request vector を `tests/fixtures/compatibility/` に 追加し、production adapter に対して実行する集中テストを用意します。 +`passthrough` claim が引用できるのは、manifest test でその feature に紐付けられた selector 上の +`equals` assertion で、期待値が同じ位置の呼び出し元の値そのものであるものだけです。これにより、削除・ +書き換え・無関係なフィールドを lossless と表示できません。custom tool とその grammar のように、片方だけ +保たれうる側面は別々の claim にしてください。 compatibility manifest は受動的なデータです。通常の router、Responses handler、server startup path から manifest catalog を import したり、Compatibility Lab を有効化したりしてはいけません。 diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 3508277d71..cb8ba43379 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -173,6 +173,10 @@ wire format을 쓴다는 이유만으로 다른 프로바이더 또는 목적지 `passthrough`가 아닌 주장에는 구체적인 제한을 적고, fixture 기반 주장에는 이를 입증하는 정확한 assertion id를 명시하세요. 비밀정보가 없는 request vector를 `tests/fixtures/compatibility/`에 추가하고 production adapter를 대상으로 실행하는 집중 테스트를 작성합니다. +`passthrough` claim은 manifest test에서 그 feature에 묶인 selector 위의 `equals` assertion 가운데, +기대값이 같은 위치의 호출자 값 그대로인 것만 인용할 수 있습니다. 그래서 제거되거나 바뀌었거나 관계없는 +필드를 무손실로 표시할 수 없습니다. custom tool과 그 grammar처럼 한쪽만 보존될 수 있는 측면은 별도 +claim으로 나누세요. 호환성 매니페스트는 수동적인 데이터입니다. 일반 router, Responses handler, server startup path가 manifest catalog를 import하거나 Compatibility Lab을 활성화해서는 안 됩니다. diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 7d4c7622ec..84da5ac93e 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -179,6 +179,11 @@ Pull request'ы с ребейзом приветствуются: ребейз заявления на основе fixture укажите точные assertion id, которые его доказывают. Добавьте request vector без секретов в `tests/fixtures/compatibility/` и сфокусированный тест, выполняющий его через production adapter. +Claim `passthrough` может ссылаться только на assertion `equals` по selector, привязанному к этой +feature в manifest test, чей ожидаемый результат совпадает с собственным значением вызывающей стороны в +том же месте. Поэтому удалённое, переписанное или не относящееся к делу поле нельзя пометить как +lossless. Аспекты, которые маршрут может сохранить по отдельности, например custom tool и его grammar, +оформляйте отдельными claim. Манифесты совместимости являются пассивными данными. Обычные router, Responses handler и server startup path не должны импортировать каталог манифестов или активировать Compatibility Lab. diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 412a3d66be..8aa8bb4cef 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -162,6 +162,9 @@ package API,还要从 `src/index.ts` export。 `passthrough` 声明都必须说明具体限制;基于 fixture 的声明必须列出证明它的准确 assertion id。 在 `tests/fixtures/compatibility/` 中添加不含 secret 的 request vector,并编写通过 production adapter 执行该向量的聚焦测试。 +`passthrough` claim 只能引用 manifest test 中绑定到该 feature 的 selector 上的 `equals` assertion, +且其期望值必须正是调用方在同一位置的原值。因此,被删除、被改写或无关的字段都不能标为 lossless。 +对于路由可能只保留其一的方面,例如 custom tool 及其 grammar,请拆成独立的 claim。 兼容性 manifest 是被动数据。普通 router、Responses handler 和 server startup path 不得导入 manifest 目录或激活 Compatibility Lab。 diff --git a/src/compatibility/index.ts b/src/compatibility/index.ts index 8b403dc9fd..bff975801c 100644 --- a/src/compatibility/index.ts +++ b/src/compatibility/index.ts @@ -1,4 +1,5 @@ import { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses"; +import { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses"; import type { CompatibilityManifestV1 } from "./manifest"; export { @@ -16,9 +17,11 @@ export type { CompatibilitySubjectV1, } from "./manifest"; export { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses"; +export { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses"; export const COMPATIBILITY_MANIFESTS: readonly CompatibilityManifestV1[] = Object.freeze([ OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST, + OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST, ]); export function getCompatibilityManifest(id: string): CompatibilityManifestV1 | undefined { diff --git a/src/compatibility/openai-responses.ts b/src/compatibility/openai-responses.ts index 35f62cec81..bd99bcae34 100644 --- a/src/compatibility/openai-responses.ts +++ b/src/compatibility/openai-responses.ts @@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1"; export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({ schemaVersion: 1, id: "openai.codex-forward.gpt-5-6-sol.responses", - version: "1.3.0", + version: "1.4.0", subject: { providerId: "openai", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -117,5 +117,12 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.", evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-options-removed"] }], }, + { + id: "custom-tool-grammar", + feature: "request.custom_tools.grammar", + disposition: "passthrough", + summary: "A custom tool's grammar format, including its definition, reaches the backend unchanged.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-grammar-preserved"] }], + }, ], } as const); diff --git a/src/compatibility/opengateway-responses.ts b/src/compatibility/opengateway-responses.ts new file mode 100644 index 0000000000..b9d31cf4ca --- /dev/null +++ b/src/compatibility/opengateway-responses.ts @@ -0,0 +1,61 @@ +import { defineCompatibilityManifest } from "./manifest"; + +const FIXTURE_ID = "opengateway-key-glm53-responses-v1"; + +/** + * A routed Responses subject whose destination rejects native custom tools, so OpenCodex lowers + * them to functions before dispatch. The claims describe that local wire transformation only; + * they do not certify how the upstream model treats the lowered declaration. + */ +export const OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST = defineCompatibilityManifest({ + schemaVersion: 1, + id: "opengateway.key.glm-5-3-flash-ultrafast.responses", + version: "1.0.0", + subject: { + providerId: "opengateway", + baseUrl: "https://apis.opengateway.ai/v1", + adapterId: "openai-responses", + authMode: "key", + inboundProtocol: "responses", + upstreamProtocol: "openai-responses", + modelIds: ["z-ai/glm-5.3-flash-ultrafast"], + }, + claims: [ + { + id: "custom-tools", + feature: "request.custom_tools", + disposition: "translated", + summary: "Custom tool declarations are sent as functions that take one raw string input.", + limitation: "The destination rejects native custom tools, so each declaration is lowered to a function with a single required string parameter named input.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-lowered-to-function"] }], + }, + { + id: "custom-tool-grammar", + feature: "request.custom_tools.grammar", + disposition: "degraded", + summary: "The tool and its description survive, but its grammar does not reach the destination.", + limitation: "The format, including any grammar definition, is removed during lowering, so the upstream model is not constrained by it; only the string input type remains.", + evidence: [{ + kind: "fixture", + id: FIXTURE_ID, + assertionIds: ["custom-tool-grammar-removed", "custom-tool-lowered-to-function"], + }], + }, + { + id: "custom-tool-call-history", + feature: "continuation.custom_tool_call", + disposition: "translated", + summary: "Replayed custom tool calls are sent as function calls with the same call_id and name.", + limitation: "The raw input is wrapped as the JSON arguments object {\"input\": }.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-call-lowered"] }], + }, + { + id: "custom-tool-output-history", + feature: "continuation.custom_tool_call_output", + disposition: "translated", + summary: "Replayed custom tool outputs are sent as function call outputs with the same call_id and output.", + limitation: "Only the item type changes from custom_tool_call_output to function_call_output.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-output-lowered"] }], + }, + ], +} as const); diff --git a/structure/adapters/compatibility-contracts.md b/structure/adapters/compatibility-contracts.md index 89d52fa271..fc06cbfb30 100644 --- a/structure/adapters/compatibility-contracts.md +++ b/structure/adapters/compatibility-contracts.md @@ -6,19 +6,22 @@ Compatibility manifests state what one exact provider, normalized upstream base authentication mode, inbound protocol, upstream protocol, and model set does with a feature. They do not infer that every model using the same adapter or wire protocol has identical behavior. -The initial contract is intentionally narrow: canonical `openai` Codex-login forwarding for -`gpt-5.6-sol` over the `openai-responses` adapter. Later providers or models need their own fixture -evidence before they can be added. +The contracts are intentionally narrow. The first covers canonical `openai` Codex-login forwarding +for `gpt-5.6-sol` over the `openai-responses` adapter. The second covers key-auth `opengateway` for +`z-ai/glm-5.3-flash-ultrafast`, which reaches the `openai-responses` wire through the registry's +per-model wire default and declares `supportsResponsesCustomTools: false`. Later providers or +models need their own fixture evidence before they can be added. ## Files | Path | Responsibility | | --- | --- | | `src/compatibility/manifest.ts` | Versioned schema, wire classifications, and fail-closed validation. | -| `src/compatibility/openai-responses.ts` | First bundled compatibility manifest. | +| `src/compatibility/openai-responses.ts` | Canonical Codex forward manifest (native custom tools). | +| `src/compatibility/opengateway-responses.ts` | OpenGateway key-auth manifest (custom tools lowered to functions). | | `src/compatibility/index.ts` | Manifest catalog for future CLI and GUI readers. | | `tests/fixtures/compatibility/` | Secret-free request vectors plus destination, header-boundary, and assertion-level expected behavior. | -| `tests/codex-integration/compatibility-manifest.test.ts` | Executes fixtures against production adapters and proves every claim has evidence. | +| `tests/codex-integration/compatibility-manifest.test.ts` | Executes each fixture against the production adapter for its manifest's subject and runs the catalog evidence gate. | ## Dispositions @@ -33,6 +36,47 @@ evidence before they can be added. claim names exact assertion IDs; a test-file name alone is not evidence because it can stay green after the relevant assertion is deleted. +## Evidence validation layers + +Validation happens in two places. The schema validator in `src/compatibility/manifest.ts` is a +passive leaf: it checks shape, dispositions, limitations, and that fixture evidence lists at least +one assertion ID, without reading fixtures. The catalog evidence gate in the manifest test owns the +rest: each manifest maps to exactly one fixture by `manifestId`, every cited assertion ID must exist +in that fixture, and a deleted or misspelled assertion fails the gate. + +The gate also checks that a `passthrough` claim is coherent with its evidence. Passthrough means +the caller's value reaches the upstream unchanged, so each cited assertion must be an `equals` +assertion whose expected value is the caller's value at the same place: `/body/` maps to the +case's `request` at `/`, and `/headers/` maps to the case's `incomingHeaders` by +case-insensitive name. An `absent` assertion, an equality against a rewritten value, or a selector +with no caller counterpart (such as `/url` or a runner-selected credential header) cannot back a +passthrough claim. Envelope header names are normalized to lowercase before assertions run. + +Passthrough evidence must also be about the claimed feature. The test keeps a binding from each +feature that may be claimed as passthrough to the outbound selectors that can prove it +(`request.custom_tools` → `/body/tools/` holding a native custom declaration, +`request.custom_tools.grammar` → `/body/tools//format` holding a grammar with a non-empty +definition, `request.prompt_cache_key` → `/body/prompt_cache_key`). A preserved but unrelated value, +such as the model id, cannot back a grammar claim, and a feature without a binding cannot be claimed +as passthrough until one is added. + +## Custom-tool representation and grammar + +Custom-tool representation (`request.custom_tools`) and grammar (`request.custom_tools.grammar`) +are separate claims, because a route can keep a usable tool while losing its grammar. Both fixtures +send the same custom tool with a non-empty Lark grammar definition, so the difference between them is +the route. The canonical forward route keeps both unchanged (`passthrough`). The OpenGateway route +lowers the declaration to a function with one required string `input` parameter (`translated`), +removes the format and its definition (`degraded`: the upstream model is not constrained by the +grammar), and replays `custom_tool_call` / `custom_tool_call_output` history as `function_call` / +`function_call_output` (`translated`). These claims describe the local wire transformation only; +they do not certify upstream acceptance or that generated input follows the grammar. + +The key-auth fixture runner builds the provider from the registry seed, resolves its per-model wire +default, and applies `enrichProviderFromRegistry` before constructing the adapter. Enrichment is +what carries `supportsResponsesCustomTools: false` onto a configured provider; without it the +lowering this contract describes would not run. + ## Runtime boundary Compatibility manifests are passive data. The Responses request path, router, and server startup do diff --git a/tests/codex-integration/compatibility-manifest.test.ts b/tests/codex-integration/compatibility-manifest.test.ts index 8576136955..0e0d36ab8b 100644 --- a/tests/codex-integration/compatibility-manifest.test.ts +++ b/tests/codex-integration/compatibility-manifest.test.ts @@ -1,21 +1,24 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { createRegisteredAdapter, effectiveAdapterContract } from "../../src/adapters/registry"; import { COMPATIBILITY_MANIFESTS, OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST, + OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST, compatibilityManifestIssues, } from "../../src/compatibility"; +import type { CompatibilityManifestV1, CompatibilitySubjectV1 } from "../../src/compatibility"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { listScenarioIds } from "../../src/lab/conformance/runner"; -import { providerConfigSeed } from "../../src/providers/derive"; -import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry, providerModelWireDefault } from "../../src/providers/registry"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; import { parseRequest } from "../../src/responses/parser"; import { resolveJsonPointer } from "../../src/lab/conformance/json-pointer"; -import fixtureJson from "../fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json"; +import type { OcxProviderConfig } from "../../src/types"; +import forwardFixtureJson from "../fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json"; +import openGatewayFixtureJson from "../fixtures/compatibility/opengateway-key-glm53-responses-v1.json"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; type FixtureAssertion = { @@ -39,7 +42,12 @@ type CompatibilityFixture = { cases: FixtureCase[]; }; -const fixture = fixtureJson as CompatibilityFixture; +const FIXTURES = [forwardFixtureJson, openGatewayFixtureJson] as CompatibilityFixture[]; +const forwardFixture = forwardFixtureJson as CompatibilityFixture; +const openGatewayFixture = openGatewayFixtureJson as CompatibilityFixture; +/** Synthetic credential the runner selects; fixtures may assert it but never carry a real one. */ +const FIXTURE_RUNTIME_TOKEN = "fixture-runtime-token"; +const OPENAI_WIRES: ReadonlySet = new Set(["openai-chat", "openai-responses"]); const repoRoot = resolveRepoRoot(); const RUNTIME_IMPORT_RE = /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/gm; @@ -90,18 +98,57 @@ type OutboundFixtureRequest = { body: Record; }; -async function outboundRequest(fixtureCase: FixtureCase): Promise { - const registry = getProviderRegistryEntry("openai"); - if (!registry) throw new Error("openai registry entry missing"); - const provider = { - ...providerConfigSeed(registry), - _codexAccountRequired: true, - _codexAccountOverride: { - accessToken: "fixture-runtime-token", - chatgptAccountId: "fixture-account", - }, - }; - const adapter = createRegisteredAdapter(provider); +function fixtureForManifest(manifest: CompatibilityManifestV1): CompatibilityFixture { + const matches = FIXTURES.filter(candidate => candidate.manifestId === manifest.id); + if (matches.length !== 1) throw new Error(`${manifest.id} needs exactly one fixture, found ${matches.length}`); + return matches[0]!; +} + +/** Effective adapter for one subject model, resolved the way routing resolves mixed-wire presets. */ +function subjectModelAdapter(subject: CompatibilitySubjectV1, modelId: string): string | undefined { + const registry = getProviderRegistryEntry(subject.providerId); + if (!registry) return undefined; + const seed = providerConfigSeed(registry); + if (seed.adapter === subject.adapterId) return seed.adapter; + return providerModelWireDefault( + subject.providerId, + seed, + modelId, + OPENAI_WIRES, + subject.inboundProtocol === "messages" ? "anthropic" : subject.inboundProtocol, + ); +} + +function providerForSubject(subject: CompatibilitySubjectV1): OcxProviderConfig { + const registry = getProviderRegistryEntry(subject.providerId); + if (!registry) throw new Error(`${subject.providerId} registry entry missing`); + if (subject.authMode === "forward") { + return { + ...providerConfigSeed(registry), + _codexAccountRequired: true, + _codexAccountOverride: { + accessToken: FIXTURE_RUNTIME_TOKEN, + chatgptAccountId: "fixture-account", + }, + } as OcxProviderConfig; + } + if (subject.authMode === "key") { + const adapter = subjectModelAdapter(subject, subject.modelIds[0]!); + if (adapter !== subject.adapterId) throw new Error(`${subject.providerId} does not resolve to ${subject.adapterId}`); + // Enrichment is what carries registry capability flags such as supportsResponsesCustomTools + // onto a configured provider; the bare seed would skip the lowering this fixture proves. + const provider = { ...providerConfigSeed(registry), apiKey: FIXTURE_RUNTIME_TOKEN }; + enrichProviderFromRegistry(subject.providerId, provider); + return { ...provider, adapter }; + } + throw new Error(`no fixture runner for auth mode ${subject.authMode}`); +} + +async function outboundRequest( + subject: CompatibilitySubjectV1, + fixtureCase: FixtureCase, +): Promise { + const adapter = createRegisteredAdapter(providerForSubject(subject)); const parsed = parseRequest(structuredClone(fixtureCase.request)); const budget = createTranslatorBudget(); try { @@ -112,7 +159,9 @@ async function outboundRequest(fixtureCase: FixtureCase): Promise [name.toLowerCase(), value]), + ), body: JSON.parse(request.body) as Record, }; } finally { @@ -123,8 +172,8 @@ async function outboundRequest(fixtureCase: FixtureCase): Promise, assertion: FixtureAssertion): void { - const resolved = resolveJsonPointer(body, assertion.path); +function expectAssertion(outbound: OutboundFixtureRequest, assertion: FixtureAssertion): void { + const resolved = resolveJsonPointer(outbound, assertion.path); if (assertion.operator === "absent") { expect(resolved.ok, assertion.id).toBe(false); return; @@ -133,6 +182,138 @@ function expectAssertion(body: Record, assertion: FixtureAssert if (resolved.ok) expect(resolved.value, assertion.id).toEqual(assertion.expected); } +/** The caller-supplied value an outbound selector corresponds to, if the selector has one. */ +function callerInputAt(fixtureCase: FixtureCase, path: string): { ok: true; value: unknown } | { ok: false } { + if (path.startsWith("/body/")) { + const resolved = resolveJsonPointer(fixtureCase.request, path.slice("/body".length)); + return resolved.ok ? { ok: true, value: resolved.value } : { ok: false }; + } + if (path.startsWith("/headers/") && path.indexOf("/", "/headers/".length) === -1) { + const name = path.slice("/headers/".length).toLowerCase(); + const entry = Object.entries(fixtureCase.incomingHeaders ?? {}) + .find(([candidate]) => candidate.toLowerCase() === name); + return entry ? { ok: true, value: entry[1] } : { ok: false }; + } + return { ok: false }; +} + +/** + * Passthrough means the caller's value reaches the upstream unchanged, so its evidence must be an + * equality whose expected value IS the caller's value at the same place. An absence check, or an + * equality against a rewritten value, proves a transformation and cannot back a lossless claim. + */ +type PassthroughBinding = { + /** Outbound selectors that can prove this feature survived. */ + selector: RegExp; + /** Extra requirement on the preserved value, so a partial value cannot stand in for the feature. */ + valueIssue?: (value: unknown) => string | undefined; +}; + +/** + * Features that may be claimed as passthrough, and where their evidence must look. Evidence for an + * unrelated field (the model id, say) preserves a caller value but proves nothing about the feature. + * A new passthrough feature needs a binding here before it can be claimed. + */ +const PASSTHROUGH_FEATURE_BINDINGS: Readonly> = { + "request.custom_tools": { + selector: /^\/body\/tools\/\d+$/, + valueIssue: value => (value as { type?: unknown } | null)?.type === "custom" + ? undefined + : "is not a native custom tool declaration", + }, + "request.custom_tools.grammar": { + selector: /^\/body\/tools\/\d+\/format$/, + valueIssue: value => { + const format = value as { type?: unknown; definition?: unknown } | null; + return format?.type === "grammar" && typeof format.definition === "string" && format.definition.length > 0 + ? undefined + : "is not a grammar format with a non-empty definition"; + }, + }, + "request.prompt_cache_key": { selector: /^\/body\/prompt_cache_key$/ }, +}; + +function passthroughEvidenceIssues( + claimId: string, + feature: string, + fixtureCase: FixtureCase, + assertion: FixtureAssertion, +): string[] { + const binding = PASSTHROUGH_FEATURE_BINDINGS[feature]; + if (!binding) return [`${claimId}: passthrough feature ${feature} has no evidence selector binding`]; + if (!binding.selector.test(assertion.path)) { + return [`${claimId}: passthrough evidence ${assertion.id} selector ${assertion.path} does not prove ${feature}`]; + } + if (assertion.operator !== "equals" || !Object.prototype.hasOwnProperty.call(assertion, "expected")) { + return [`${claimId}: passthrough evidence ${assertion.id} must be an equals assertion`]; + } + const input = callerInputAt(fixtureCase, assertion.path); + if (!input.ok) { + return [`${claimId}: passthrough evidence ${assertion.id} selector ${assertion.path} has no caller input to compare`]; + } + if (!Bun.deepEquals(input.value, assertion.expected, true)) { + return [`${claimId}: passthrough evidence ${assertion.id} expects a value that differs from the caller input`]; + } + const valueIssue = binding.valueIssue?.(assertion.expected); + if (valueIssue) return [`${claimId}: passthrough evidence ${assertion.id} ${valueIssue}`]; + return []; +} + +/** + * Catalog evidence gate. The schema validator only requires assertion ids to be listed; this + * resolves each one inside the manifest's own fixture and checks passthrough coherence. + */ +function manifestEvidenceIssues(manifest: CompatibilityManifestV1, fixture: CompatibilityFixture): string[] { + const issues: string[] = []; + const assertions = new Map(); + for (const fixtureCase of fixture.cases) { + for (const assertion of fixtureCase.assertions) { + if (assertions.has(assertion.id)) issues.push(`${fixture.id}: assertion ${assertion.id} is not unique`); + assertions.set(assertion.id, { fixtureCase, assertion }); + } + } + const labScenarios = new Set(listScenarioIds()); + for (const claim of manifest.claims) { + let fixtureEvidence = 0; + for (const evidence of claim.evidence) { + if (evidence.kind !== "fixture") { + if (!labScenarios.has(evidence.id)) issues.push(`${claim.id}: lab scenario ${evidence.id} does not exist`); + continue; + } + fixtureEvidence += 1; + if (evidence.id !== fixture.id) issues.push(`${claim.id}: evidence fixture ${evidence.id} is not ${fixture.id}`); + for (const assertionId of evidence.assertionIds ?? []) { + const found = assertions.get(assertionId); + if (!found) { + issues.push(`${claim.id}: assertion ${assertionId} does not exist in fixture ${fixture.id}`); + continue; + } + if (claim.disposition === "passthrough") { + issues.push(...passthroughEvidenceIssues(claim.id, claim.feature, found.fixtureCase, found.assertion)); + } + } + } + if (claim.disposition === "passthrough" && fixtureEvidence === 0) { + issues.push(`${claim.id}: passthrough claims need fixture evidence`); + } + } + return issues; +} + +type MutableManifest = { + claims: Array & { evidence: Array> }>; +}; + +function mutableClone(manifest: CompatibilityManifestV1): CompatibilityManifestV1 & MutableManifest { + return structuredClone(manifest) as unknown as CompatibilityManifestV1 & MutableManifest; +} + +function claimIndex(manifest: CompatibilityManifestV1, id: string): number { + const index = manifest.claims.findIndex(claim => claim.id === id); + if (index < 0) throw new Error(`${manifest.id} has no claim ${id}`); + return index; +} + describe("versioned compatibility manifests", () => { test("every bundled manifest is schema-valid and uniquely identified", () => { expect(COMPATIBILITY_MANIFESTS.length).toBeGreaterThan(0); @@ -152,7 +333,7 @@ describe("versioned compatibility manifests", () => { invalid.claims[1]!.id = invalid.claims[0]!.id; invalid.claims[1]!.feature = invalid.claims[0]!.feature; delete invalid.claims[2]!.limitation; - invalid.claims[3]!.evidence = [{ kind: "fixture", id: fixture.id }]; + invalid.claims[3]!.evidence = [{ kind: "fixture", id: forwardFixture.id }]; invalid.claims[4]!.unknownField = true; const issues = compatibilityManifestIssues(invalid); expect(issues.some(issue => issue.includes("duplicates"))).toBe(true); @@ -163,19 +344,29 @@ describe("versioned compatibility manifests", () => { expect(issues.some(issue => issue.includes("unknownField"))).toBe(true); }); - test("the first manifest is pinned to the canonical Codex forward route", () => { - const manifest = OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST; - const provider = getProviderRegistryEntry(manifest.subject.providerId); - expect(provider).toBeDefined(); - expect(provider?.baseUrl).toBe(manifest.subject.baseUrl); - expect(provider?.adapter).toBe(manifest.subject.adapterId); - expect(provider?.authKind).toBe(manifest.subject.authMode); - expect(effectiveAdapterContract(manifest.subject.adapterId).wire) - .toBe(manifest.subject.upstreamProtocol); - expect(manifest.subject.modelIds).toEqual(["gpt-5.6-sol"]); - expect(manifest.subject.modelIds.every(model => SUPPORTED_NATIVE_OPENAI_SLUGS.has(model))).toBe(true); - expect(Object.isFrozen(manifest.claims)).toBe(true); - expect(Object.isFrozen(manifest.claims[0]?.evidence)).toBe(true); + test("every manifest is pinned to the route its subject names", () => { + for (const manifest of COMPATIBILITY_MANIFESTS) { + const { subject } = manifest; + const provider = getProviderRegistryEntry(subject.providerId); + expect(provider, manifest.id).toBeDefined(); + expect(provider?.baseUrl, manifest.id).toBe(subject.baseUrl); + expect(provider?.authKind, manifest.id).toBe(subject.authMode); + expect(effectiveAdapterContract(subject.adapterId).wire, manifest.id).toBe(subject.upstreamProtocol); + for (const modelId of subject.modelIds) { + expect(subjectModelAdapter(subject, modelId), `${manifest.id}:${modelId}`).toBe(subject.adapterId); + } + expect(Object.isFrozen(manifest.claims), manifest.id).toBe(true); + expect(Object.isFrozen(manifest.claims[0]?.evidence), manifest.id).toBe(true); + } + + const forward = OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST.subject; + expect(forward.modelIds).toEqual(["gpt-5.6-sol"]); + expect(forward.modelIds.every(model => SUPPORTED_NATIVE_OPENAI_SLUGS.has(model))).toBe(true); + + const lowered = OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST.subject; + const openGateway = getProviderRegistryEntry(lowered.providerId); + expect(openGateway?.supportsResponsesCustomTools).toBe(false); + expect(lowered.modelIds.every(model => openGateway?.models?.includes(model))).toBe(true); }); test("ordinary request and startup graphs do not load the passive manifest catalog", () => { @@ -193,36 +384,167 @@ describe("versioned compatibility manifests", () => { }); }); -describe("OpenAI Codex forward compatibility fixture", () => { +describe("compatibility fixtures", () => { + test("each fixture belongs to exactly one bundled manifest", () => { + expect(new Set(FIXTURES.map(fixture => fixture.id)).size).toBe(FIXTURES.length); + expect(new Set(FIXTURES.map(fixture => fixture.manifestId)).size).toBe(FIXTURES.length); + for (const fixture of FIXTURES) { + expect(fixture.schemaVersion, fixture.id).toBe(1); + expect(COMPATIBILITY_MANIFESTS.some(manifest => manifest.id === fixture.manifestId), fixture.id).toBe(true); + expect(new Set(fixture.cases.map(fixtureCase => fixtureCase.id)).size, fixture.id).toBe(fixture.cases.length); + } + for (const manifest of COMPATIBILITY_MANIFESTS) expect(fixtureForManifest(manifest).manifestId).toBe(manifest.id); + }); + test("executes every fixture assertion against the production adapter", async () => { - expect(fixture.schemaVersion).toBe(1); - expect(fixture.manifestId).toBe(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST.id); - expect(new Set(fixture.cases.map(fixtureCase => fixtureCase.id)).size).toBe(fixture.cases.length); - for (const fixtureCase of fixture.cases) { - const outbound = await outboundRequest(fixtureCase); - for (const assertion of fixtureCase.assertions) expectAssertion(outbound, assertion); + for (const manifest of COMPATIBILITY_MANIFESTS) { + const fixture = fixtureForManifest(manifest); + for (const fixtureCase of fixture.cases) { + const outbound = await outboundRequest(manifest.subject, fixtureCase); + for (const assertion of fixtureCase.assertions) expectAssertion(outbound, assertion); + } + } + }); + + test("every manifest claim names assertion-level evidence that exists and supports it", () => { + for (const manifest of COMPATIBILITY_MANIFESTS) { + expect(manifestEvidenceIssues(manifest, fixtureForManifest(manifest)), manifest.id).toEqual([]); + } + }); +}); + +describe("custom-tool grammar evidence", () => { + const grammarFeature = "request.custom_tools.grammar"; + + test("native and lowered subjects record representation and grammar separately", () => { + const forwardGrammar = OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST.claims.find(claim => claim.feature === grammarFeature); + const loweredGrammar = OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST.claims.find(claim => claim.feature === grammarFeature); + const forwardTools = OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST.claims.find(claim => claim.feature === "request.custom_tools"); + const loweredTools = OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST.claims.find(claim => claim.feature === "request.custom_tools"); + expect([forwardTools?.disposition, forwardGrammar?.disposition]).toEqual(["passthrough", "passthrough"]); + expect([loweredTools?.disposition, loweredGrammar?.disposition]).toEqual(["translated", "degraded"]); + + // Both fixtures send the same non-empty grammar, so the difference is the route, not the input. + const forwardFormat = resolveJsonPointer(forwardFixture.cases[0]!.request, "/tools/0/format"); + const loweredFormat = resolveJsonPointer(openGatewayFixture.cases[0]!.request, "/tools/0/format"); + expect(forwardFormat.ok && loweredFormat.ok).toBe(true); + if (forwardFormat.ok && loweredFormat.ok) { + expect(loweredFormat.value).toEqual(forwardFormat.value); + expect((forwardFormat.value as { definition?: string }).definition?.length ?? 0).toBeGreaterThan(0); } }); - test("every manifest claim names assertion-level evidence that exists", () => { - const allAssertionIds = new Set(fixture.cases.flatMap(fixtureCase => - fixtureCase.assertions.map(assertion => assertion.id))); - expect(allAssertionIds.size).toBe( - fixture.cases.reduce((total, fixtureCase) => total + fixtureCase.assertions.length, 0), + test("grammar loss cannot be labeled passthrough", () => { + const index = claimIndex(OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST, "custom-tool-grammar"); + const asPassthrough = (assertionIds: string[]) => { + const clone = mutableClone(OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST); + clone.claims[index]!.disposition = "passthrough"; + delete clone.claims[index]!.limitation; + clone.claims[index]!.evidence = [{ kind: "fixture", id: openGatewayFixture.id, assertionIds }]; + // Schema-valid on purpose: only the evidence gate can see that the value did not survive. + expect(compatibilityManifestIssues(clone)).toEqual([]); + return clone; + }; + + expect(manifestEvidenceIssues(asPassthrough(["custom-tool-grammar-removed"]), openGatewayFixture)).toContain( + "custom-tool-grammar: passthrough evidence custom-tool-grammar-removed must be an equals assertion", ); - const labScenarios = new Set(listScenarioIds()); - - for (const claim of OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST.claims) { - for (const evidence of claim.evidence) { - if (evidence.kind === "fixture") { - expect(evidence.id, claim.id).toBe(fixture.id); - for (const assertionId of evidence.assertionIds ?? []) { - expect(allAssertionIds.has(assertionId), `${claim.id}:${assertionId}`).toBe(true); - } - } else { - expect(labScenarios.has(evidence.id), `${claim.id}:${evidence.id}`).toBe(true); - } - } + expect(manifestEvidenceIssues(asPassthrough(["custom-tool-lowered-to-function"]), openGatewayFixture)).toContain( + "custom-tool-grammar: passthrough evidence custom-tool-lowered-to-function selector /body/tools/0 does not prove request.custom_tools.grammar", + ); + + // A genuinely preserved but unrelated caller value must not stand in for the grammar. + const withModelAssertion = structuredClone(openGatewayFixture); + withModelAssertion.cases[0]!.assertions.push({ + id: "model-preserved", + operator: "equals", + path: "/body/model", + expected: withModelAssertion.cases[0]!.request.model, + }); + expect(manifestEvidenceIssues(asPassthrough(["model-preserved"]), withModelAssertion)).toContain( + "custom-tool-grammar: passthrough evidence model-preserved selector /body/model does not prove request.custom_tools.grammar", + ); + }); + + test("a rewritten value cannot back a passthrough claim", () => { + const clone = mutableClone(OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST); + const index = claimIndex(clone, "custom-tools"); + clone.claims[index]!.disposition = "passthrough"; + delete clone.claims[index]!.limitation; + expect(compatibilityManifestIssues(clone)).toEqual([]); + expect(manifestEvidenceIssues(clone, openGatewayFixture)).toContain( + "custom-tools: passthrough evidence custom-tool-lowered-to-function expects a value that differs from the caller input", + ); + }); + + test("grammar passthrough evidence must carry a non-empty definition", () => { + const withoutDefinition = structuredClone(forwardFixture); + const fieldCase = withoutDefinition.cases.find(fixtureCase => fixtureCase.id === "field-contract")!; + const tools = fieldCase.request.tools as Array<{ format: Record }>; + delete tools[0]!.format.definition; + const grammarAssertion = fieldCase.assertions.find(assertion => assertion.id === "custom-tool-grammar-preserved")!; + delete (grammarAssertion.expected as Record).definition; + expect(manifestEvidenceIssues(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST, withoutDefinition)).toContain( + "custom-tool-grammar: passthrough evidence custom-tool-grammar-preserved is not a grammar format with a non-empty definition", + ); + }); + + test("passthrough evidence must point at a caller-supplied value for a bound feature", () => { + const grammarIndex = claimIndex(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST, "custom-tool-grammar"); + const unrelatedSelector = mutableClone(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST); + unrelatedSelector.claims[grammarIndex]!.evidence = [{ + kind: "fixture", + id: forwardFixture.id, + assertionIds: ["canonical-url"], + }]; + expect(manifestEvidenceIssues(unrelatedSelector, forwardFixture)).toContain( + "custom-tool-grammar: passthrough evidence canonical-url selector /url does not prove request.custom_tools.grammar", + ); + + const noCallerValue = structuredClone(forwardFixture); + noCallerValue.cases[0]!.assertions.push({ + id: "second-tool-format", + operator: "equals", + path: "/body/tools/1/format", + expected: { type: "grammar", syntax: "lark", definition: "start: X" }, + }); + const citesMissingInput = mutableClone(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST); + citesMissingInput.claims[grammarIndex]!.evidence = [{ + kind: "fixture", + id: forwardFixture.id, + assertionIds: ["second-tool-format"], + }]; + expect(manifestEvidenceIssues(citesMissingInput, noCallerValue)).toContain( + "custom-tool-grammar: passthrough evidence second-tool-format selector /body/tools/1/format has no caller input to compare", + ); + + const unbound = mutableClone(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST); + const metadataIndex = claimIndex(unbound, "metadata"); + unbound.claims[metadataIndex]!.disposition = "passthrough"; + delete unbound.claims[metadataIndex]!.limitation; + expect(manifestEvidenceIssues(unbound, forwardFixture)).toContain( + "metadata: passthrough feature request.metadata has no evidence selector binding", + ); + }); + + test("missing assertion evidence fails the catalog gate", () => { + const dangling = mutableClone(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST); + dangling.claims[claimIndex(dangling, "custom-tool-grammar")]!.evidence = [{ + kind: "fixture", + id: forwardFixture.id, + assertionIds: ["no-such-assertion"], + }]; + expect(compatibilityManifestIssues(dangling)).toEqual([]); + expect(manifestEvidenceIssues(dangling, forwardFixture)).toContain( + `custom-tool-grammar: assertion no-such-assertion does not exist in fixture ${forwardFixture.id}`, + ); + + const deleted = structuredClone(forwardFixture); + for (const fixtureCase of deleted.cases) { + fixtureCase.assertions = fixtureCase.assertions.filter(assertion => assertion.id !== "custom-tool-grammar-preserved"); } + expect(manifestEvidenceIssues(OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST, deleted)).toContain( + `custom-tool-grammar: assertion custom-tool-grammar-preserved does not exist in fixture ${forwardFixture.id}`, + ); }); }); diff --git a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json index 9712c558e3..e163028f8b 100644 --- a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json +++ b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json @@ -51,7 +51,7 @@ "type": "custom", "name": "apply_patch", "description": "Apply a patch", - "format": { "type": "grammar", "syntax": "lark" } + "format": { "type": "grammar", "syntax": "lark", "definition": "start: PATCH\nPATCH: /[\\s\\S]+/" } } ] }, @@ -71,9 +71,15 @@ "type": "custom", "name": "apply_patch", "description": "Apply a patch", - "format": { "type": "grammar", "syntax": "lark" } + "format": { "type": "grammar", "syntax": "lark", "definition": "start: PATCH\nPATCH: /[\\s\\S]+/" } } }, + { + "id": "custom-tool-grammar-preserved", + "operator": "equals", + "path": "/body/tools/0/format", + "expected": { "type": "grammar", "syntax": "lark", "definition": "start: PATCH\nPATCH: /[\\s\\S]+/" } + }, { "id": "max-output-tokens-removed", "operator": "absent", "path": "/body/max_output_tokens" }, { "id": "metadata-removed", "operator": "absent", "path": "/body/metadata" }, { "id": "system-message-folded", "operator": "equals", "path": "/body/instructions", "expected": "Fixture instructions\n\nFixture system instruction" }, diff --git a/tests/fixtures/compatibility/opengateway-key-glm53-responses-v1.json b/tests/fixtures/compatibility/opengateway-key-glm53-responses-v1.json new file mode 100644 index 0000000000..68fb850151 --- /dev/null +++ b/tests/fixtures/compatibility/opengateway-key-glm53-responses-v1.json @@ -0,0 +1,121 @@ +{ + "schemaVersion": 1, + "id": "opengateway-key-glm53-responses-v1", + "manifestId": "opengateway.key.glm-5-3-flash-ultrafast.responses", + "cases": [ + { + "id": "custom-tool-lowering", + "incomingHeaders": { + "authorization": "Bearer caller-stale-token", + "x-not-forwarded": "must-not-cross" + }, + "request": { + "model": "z-ai/glm-5.3-flash-ultrafast", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Apply the fixture patch" + } + ] + }, + { + "type": "custom_tool_call", + "call_id": "call_fixture", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }, + { + "type": "custom_tool_call_output", + "call_id": "call_fixture", + "output": "Done" + } + ], + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: PATCH\nPATCH: /[\\s\\S]+/" + } + } + ] + }, + "assertions": [ + { + "id": "canonical-url", + "operator": "equals", + "path": "/url", + "expected": "https://apis.opengateway.ai/v1/responses" + }, + { + "id": "selected-authorization", + "operator": "equals", + "path": "/headers/authorization", + "expected": "Bearer fixture-runtime-token" + }, + { + "id": "unrelated-header-removed", + "operator": "absent", + "path": "/headers/x-not-forwarded" + }, + { + "id": "custom-tool-lowered-to-function", + "operator": "equals", + "path": "/body/tools/0", + "expected": { + "type": "function", + "name": "apply_patch", + "description": "Apply a patch", + "parameters": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw input for this client-executed custom tool." + } + }, + "required": [ + "input" + ], + "additionalProperties": false + } + } + }, + { + "id": "custom-tool-grammar-removed", + "operator": "absent", + "path": "/body/tools/0/format" + }, + { + "id": "custom-tool-call-lowered", + "operator": "equals", + "path": "/body/input/1", + "expected": { + "type": "function_call", + "call_id": "call_fixture", + "name": "apply_patch", + "arguments": "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" + } + }, + { + "id": "custom-tool-output-lowered", + "operator": "equals", + "path": "/body/input/2", + "expected": { + "type": "function_call_output", + "call_id": "call_fixture", + "output": "Done" + } + } + ] + } + ] +} From f443f49ed0af3a57873395779ddba44bc6412dd9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 13:24:37 -0700 Subject: [PATCH 03/29] fix(antigravity): show the Gemini 4 Argon quota group separately (#7283) * fix(antigravity): show the Gemini 4 Argon quota group separately retrieveUserQuotaSummary reports Gemini 4 Argon as its own group. The summary parser matched it as "gemini", so its weekly bucket collided with Gem (Weekly) and was dropped, or replaced the Gemini reading when the Argon group came first. The model-list fallback had the same order problem. Each bucket now decides its family from its own id and name (Argon before Gemini), with the group display name only as a fallback. Argon buckets become Argon / Argon (Weekly) windows tagged with the canonical pool model gemini-4-argon, which keeps them out of headroom, exhaustion, reset ordering and reset notifications like the existing model rows. The account reset-time selection now skips model-attributed rows as well. Closes #7272 * docs(structure): state the Antigravity group fallback rules exactly --- .../docs/reference/configuration/providers.md | 1 + src/oauth/account-quota-rank.ts | 3 +- src/providers/quota-types.ts | 7 +- src/providers/quota/antigravity.ts | 45 ++++- structure/providers-and-adapters.md | 2 +- structure/transports/inventory.md | 2 + .../provider-antigravity-model-quota.test.ts | 172 +++++++++++++++++- 7 files changed, 222 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a8e4499e9a..c72a3048f3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -367,6 +367,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | +| — | — | Antigravity quota shows Gemini 4 Argon as separate `Argon` / `Argon (Weekly)` windows next to Gem and Cla. They are display-only: account ranking, quota exhaustion, reset ordering and reset notifications keep using the Gemini and Claude windows, and Argon requests are still judged by the Gemini window. | | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index d1b51a51f2..6582a29d3b 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -153,7 +153,8 @@ export function accountResetTimestamp( const quota = getCachedProviderAccountQuota(provider, accountId); if (!quota) return null; const family = classifyModelFamilyForQuota(provider, requestedModelId); - const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family)); + // Model-attributed rows are diagnostics; one must never become the account's reset time. + const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family))); const weekly = windows.find(w => /week/i.test(w.label)); let rawReset = weekly?.resetAt ?? quota.weeklyResetAt; if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") { diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index 4f1d319c5a..8306bc4754 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -19,7 +19,12 @@ export interface ProviderQuotaWindow { label: string; percent: number; resetAt?: number; - /** Exact CCA wire model measured by this window; display evidence only, not eligibility. */ + /** + * Model this window is attributed to: the exact CCA wire model of a fallback measurement, or + * the canonical model of a quota-summary bucket dedicated to one model (Antigravity's Gemini 4 + * Argon pool). Display evidence only, never eligibility: routing, exhaustion, reset ordering + * and reset notifications ignore every window that carries it. + */ modelId?: string; /** * Set only when the PRODUCER proved this window covers one model family, structurally rather diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index fda26590ba..9ae43e9a67 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -45,8 +45,31 @@ function quotaInfoEntries(modelInfo: Record): Record): AntigravitySummaryFamily | undefined { + const identity = `${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`; + if (ARGON_TOKEN.test(identity)) return "argon"; + if (GEMINI_TOKEN.test(identity)) return "gem"; + if (CLAUDE_FAMILY_TOKEN.test(identity)) return "cla"; + return undefined; +} + function classifyAntigravityFamily(modelId: string, modelInfo: Record, quotaInfo: Record): "Gem" | "Cla" | null { const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : ""; + // Argon models keep their exact model rows but never seed the shared Gem family bar. + if (ARGON_TOKEN.test(`${modelId} ${displayName}`)) return null; const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : ""; const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase(); if (haystack.includes("gemini")) return "Gem"; @@ -128,9 +151,13 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro for (const rawGroup of groups) { const group = asRecord(rawGroup); if (!group) continue; - const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase(); - const isGemini = groupName.includes("gemini"); - const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt"); + const groupDisplayName = typeof group.displayName === "string" ? group.displayName : ""; + const groupName = `${groupDisplayName} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase(); + // A description that merely mentions Argon must not move a whole Gemini group. + const groupFamily: AntigravitySummaryFamily | undefined = ARGON_TOKEN.test(groupDisplayName) ? "argon" + : groupName.includes("gemini") ? "gem" + : groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt") ? "cla" + : undefined; const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : []; for (const rawBucket of buckets) { @@ -143,13 +170,19 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro const isWeekly = windowStr.includes("week"); const is5h = windowStr.includes("5h") || windowStr.includes("five"); + const family = antigravitySummaryBucketFamily(bucket) ?? groupFamily; - if (isGemini) { + if (family === "argon") { + const label = is5h ? "Argon" : isWeekly ? "Argon (Weekly)" : ""; + if (label && !customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, modelId: ARGON_POOL_MODEL_ID, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } else if (family === "gem") { const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : ""; if (label && !customWindowsMap.has(label)) { customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); } - } else if (isClaude) { + } else if (family === "cla") { const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : ""; if (label && !customWindowsMap.has(label)) { customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); @@ -164,7 +197,7 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro } } - const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"]; + const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)", "Argon", "Argon (Weekly)"]; const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => { const ia = PREFERRED_ORDER.indexOf(a.label); const ib = PREFERRED_ORDER.indexOf(b.label); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index aab02c118d..6fc19e30b8 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -578,7 +578,7 @@ Antigravity discovery in `src/providers/antigravity-models.ts` collapses complet Antigravity catalog rows carry the observed complete effort map from `src/providers/antigravity-models.ts` through the in-memory cache. `src/providers/antigravity-effort-families.ts` uses that evidence to group public rows and project suffix allowlists into the base selection, including both final catalog merge callers. Internal retained suffix rows remain available to combo resolution. Authoritative new-model reconciliation normalizes both the prior baseline and policy input before detecting arrivals: an existing suffix family is not a new model. All-disabled tiers transfer a base disable once; a known base keeps the operator's later choice. Genuinely new families still follow new-model policy. Saved defaults, combo targets, suffix disables and provider selections remain unchanged. A discovered ladder advertises only its wire-backed efforts unless explicitly overridden; unknown single-wire models gain no invented ladder. Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a deterministic usage identity in `src/providers/antigravity-models.ts`, independently of live discovery. This accounting map does not migrate saved routing selections; Claude 4.6 and unknown suffix identities remain unchanged. Antigravity model entitlement is per account: one account may list Claude 4.6, another only the Claude 5.5 suffix families, and Gemini 4 Argon only on Ultra/Enterprise. `ANTIGRAVITY_MODELS` is therefore a static superset (Gemini 3.8 Flash, Gemini 4 Argon, Claude Opus 5.5, Claude Sonnet 5.5), and `ANTIGRAVITY_KNOWN_PICKER_MODELS` keeps dropped bases known to routing, discovery collapse and usage identity. The catalog exposes only what the active account's authoritative discovery returns. `src/providers/antigravity-account-roster.ts` records each account's discovered roster (in memory, six-hour freshness, scoped to config home); `src/oauth/generic-account-failover.ts` ranks Antigravity accounts by it the way it ranks Kiro listings: a listing account wins, a known non-listing account is skipped while another remains, and an account without a fresh roster is unknown and stays usable. Gemini 4 Argon requests and Antigravity model discovery send the Antigravity CLI User-Agent; every other model keeps the IDE User-Agent. -When the `retrieveUserQuotaSummary` endpoint has no usable result, `src/providers/quota/antigravity.ts` preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. +`retrieveUserQuotaSummary` reports Gemini 4 Argon as its own group; `src/providers/quota/antigravity.ts` decides each bucket's family from its own id/name (Argon before Gemini, then Claude/3P/GPT), and only for an inconclusive bucket falls back to the group: Argon from the group display name alone (never its description), Gemini and Claude/3P/GPT from the existing display-name-plus-description rules. Argon buckets become `Argon`/`Argon (Weekly)` windows tagged with the canonical pool model `gemini-4-argon`, so they never fill or replace Gem/Cla in either group order and stay diagnostic like the model windows below; an Argon-only summary is a display report with no routing evidence, and the compact GUI summary and tray aggregation treat it like other diagnostic-only data. When the summary endpoint has no usable result, the same file preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries, where an Argon model never seeds Gem. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. Account-row projection is loaded with `import()` after each account probe in `src/providers/quota.ts`; its cache dependencies do not create a static return edge into the quota facade. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 9481c2a8c7..f0c9870e6e 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -304,6 +304,8 @@ Unicode pattern normalization uses [copy-on-write traversal](byte-accounting.md# `src/oauth/account-quota-rank.ts` ranks Antigravity custom windows for the requested Gemini or Claude family, including GPT-OSS in the Claude family. An unknown model retains all-window ranking; absent matching evidence retains the existing unranked behavior. +Windows carrying `modelId` (exact model rows and the Argon summary pool) are excluded +from headroom and from reset-time selection, including family-less requests. `src/server/responses/request-transport.ts` passes the routed model at initial selection. The passthrough, adapter, continuation, sidecar and run-turn execution owners pass the same routed model during account rotation, without bypassing their send-budget diff --git a/tests/providers/provider-antigravity-model-quota.test.ts b/tests/providers/provider-antigravity-model-quota.test.ts index 9a48618553..be00103dd9 100644 --- a/tests/providers/provider-antigravity-model-quota.test.ts +++ b/tests/providers/provider-antigravity-model-quota.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; -import { rankAccountsByHeadroom } from "../../src/oauth/account-quota-rank"; +import { + accountHeadroomPercent, + accountResetTimestamp, + rankAccountsByHeadroom, + rankAccountsByResetFirst, +} from "../../src/oauth/account-quota-rank"; import { cachedProviderQuotaIsExhausted } from "../../src/combos/resolve"; import { getCachedProviderAccountQuota, setCachedProviderAccountQuotaForTests } from "../../src/providers/quota"; import { sweepExpiredProviderAccountQuotaRows } from "../../src/providers/quota/account-cache"; @@ -187,3 +192,168 @@ describe("Antigravity model quota evidence", () => { expect(providerWindowObservations(result.quota).map(observation => observation.window)).toEqual(["custom:Gem"]); }); }); + +describe("Antigravity Argon quota summary (#7272)", () => { + const argonReset = "2026-10-24T04:34:17Z"; + const geminiGroup = { + displayName: "Gemini Models", + // A description that mentions Argon must not move the whole Gemini group. + description: "Models within this group: Gemini Flash, Gemini Pro, Gemini 4 Argon", + buckets: [ + { bucketId: "gemini-weekly", displayName: "Weekly Limit Remaining", window: "weekly", resetTime: "2026-10-25T00:00:00Z", remainingFraction: 0.88 }, + { bucketId: "gemini-5h", displayName: "Five Hour Limit Remaining", window: "5h", resetTime: "2026-10-18T05:00:00Z", remainingFraction: 0.75 }, + ], + }; + const claudeGroup = { + displayName: "Claude and GPT models", + buckets: [{ bucketId: "3p-weekly", displayName: "Weekly Limit Remaining", window: "weekly", resetTime: "2026-10-26T00:00:00Z", remainingFraction: 0.95 }], + }; + const argonGroup = { + displayName: "Gemini 4 Argon", + description: "For paid Pro plans, Argon quota is separate.", + buckets: [{ bucketId: "gemini-argon-wtu-weekly", displayName: "Gemini 4 Argon Weekly Limit", window: "weekly", resetTime: argonReset, remainingFraction: 0.75 }], + }; + + let modelRequests = 0; + function setSummaryResponse(groups: unknown[], models: unknown = {}): void { + modelRequests = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], + privateNetwork: false, + }), + pinnedPost: async url => { + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ groups }), { status: 200, headers: { "content-type": "application/json" } }); + } + modelRequests += 1; + return new Response(JSON.stringify({ models }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + } + + async function summaryWindows(groups: unknown[]) { + setSummaryResponse(groups); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result).toMatchObject({ kind: "available", source: "google-antigravity:retrieveUserQuotaSummary" }); + return result.kind === "available" ? result.quota.customWindows ?? [] : []; + } + + const label = (windows: Array<{ label: string; percent: number; modelId?: string }>) => + windows.map(window => `${window.label}=${window.percent}${window.modelId ? `@${window.modelId}` : ""}`); + + test.each([ + ["Gemini group first", [geminiGroup, claudeGroup, argonGroup]], + ["Argon group first", [argonGroup, geminiGroup, claudeGroup]], + ])("keeps Gem (Weekly) and a separate Argon window: %s", async (_name, groups) => { + const windows = await summaryWindows(groups); + expect(label(windows)).toEqual(["Gem=25", "Gem (Weekly)=12", "Cla (Weekly)=5", "Argon (Weekly)=25@gemini-4-argon"]); + expect(windows.at(-1)).toEqual({ label: "Argon (Weekly)", modelId: "gemini-4-argon", percent: 25, resetAt: Date.parse(argonReset) }); + // The summary already carries Argon; no model-list request is needed. + expect(modelRequests).toBe(0); + }); + + test("Argon buckets map 5h and weekly, skip other cadences and malformed readings, and stay first-seen", async () => { + const windows = await summaryWindows([ + { displayName: "Gemini 4 Argon", buckets: [ + { bucketId: "gemini-argon-daily", window: "daily", remainingFraction: 0.1 }, + { bucketId: "gemini-argon-5h", window: "5h", remainingFraction: "unknown" }, + { bucketId: "gemini-argon-5h", window: "5h", remainingFraction: 0.6 }, + { bucketId: "gemini-argon-weekly", window: "weekly", remainingFraction: 0.9 }, + ] }, + { displayName: "Gemini 4 Argon", buckets: [{ bucketId: "gemini-argon-weekly", window: "weekly", remainingFraction: 0.1 }] }, + ]); + expect(label(windows)).toEqual(["Argon=40@gemini-4-argon", "Argon (Weekly)=10@gemini-4-argon"]); + }); + + test("a bucket's own identity decides its family; the group decides only an inconclusive bucket", async () => { + expect(label(await summaryWindows([ + { displayName: "Gemini Models", buckets: [ + { bucketId: "gemini-weekly", window: "weekly", remainingFraction: 0.5 }, + { bucketId: "gemini-argon-wtu-weekly", window: "weekly", remainingFraction: 0.2 }, + ] }, + ]))).toEqual(["Gem (Weekly)=50", "Argon (Weekly)=80@gemini-4-argon"]); + + expect(label(await summaryWindows([ + { displayName: "Gemini 4 Argon", buckets: [ + { bucketId: "gemini-weekly", window: "weekly", remainingFraction: 0.5 }, + { bucketId: "claude-weekly", window: "weekly", remainingFraction: 0.4 }, + { bucketId: "weekly-1", window: "weekly", remainingFraction: 0.3 }, + ] }, + { displayName: "Claude and GPT models", buckets: [{ bucketId: "gemini-5h", window: "5h", remainingFraction: 0.9 }] }, + ]))).toEqual(["Gem=10", "Gem (Weekly)=50", "Cla (Weekly)=60", "Argon (Weekly)=70@gemini-4-argon"]); + + // Token boundaries: an unknown group with an "argonaut" bucket keeps its own label. + expect(label(await summaryWindows([ + { displayName: "Experimental", buckets: [{ bucketId: "argonaut-weekly", window: "weekly", remainingFraction: 0.5 }] }, + ]))).toEqual(["Experimental (Weekly)=50"]); + }); + + test("model-list fallback never lets an Argon model seed the Gem family", async () => { + setModelsResponse({ + "gemini-4-argon-high": { displayName: "Gemini 4 Argon High", quotaInfo: { remainingFraction: 0.2 } }, + "gemini-3.8-flash-high": { quotaInfo: { remainingFraction: 0.9 } }, + }); + const first = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(first.kind).toBe("available"); + if (first.kind !== "available") return; + expect(first.quota.customWindows?.filter(window => window.modelId === undefined)).toEqual([{ label: "Gem", percent: 10 }]); + expect(first.quota.customWindows).toContainEqual({ label: "gemini-4-argon-high", modelId: "gemini-4-argon-high", percent: 80 }); + + // An Argon-only listing keeps its exact row (even one labelled like the pool) and invents no Gem evidence. + setModelsResponse({ "gemini-4-argon": { quotaInfo: { remainingFraction: 0.2 } } }); + const only = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(only.kind).toBe("available"); + if (only.kind !== "available") return; + expect(only.quota.customWindows).toEqual([{ label: "gemini-4-argon", modelId: "gemini-4-argon", percent: 80 }]); + }); + + test("an exhausted Argon pool changes no routing, exhaustion, notification or reset decision", async () => { + const windows = await summaryWindows([ + geminiGroup, + claudeGroup, + { ...argonGroup, buckets: [{ ...argonGroup.buckets[0], resetTime: "2026-10-19T00:00:00Z", remainingFraction: 0 }] }, + ]); + const quota = { updatedAt: Date.now(), customWindows: windows }; + const withoutArgon = { updatedAt: Date.now(), customWindows: windows.filter(window => window.modelId === undefined) }; + expect(windows.some(window => window.modelId === "gemini-4-argon" && window.percent === 100)).toBe(true); + + for (const model of [undefined, "gemini-4-argon-high", "gemini-3.8-flash-high", "claude-sonnet-4-6"]) { + expect(cachedProviderQuotaIsExhausted(quota, Date.now(), model)).toBe(false); + setCachedProviderAccountQuotaForTests("google-antigravity", "first", quota); + setCachedProviderAccountQuotaForTests("google-antigravity", "second", withoutArgon); + expect(accountHeadroomPercent("google-antigravity", "first", model)) + .toBe(accountHeadroomPercent("google-antigravity", "second", model)); + expect(accountResetTimestamp("google-antigravity", "first", model, Date.parse("2026-10-17T00:00:00Z"))) + .toBe(accountResetTimestamp("google-antigravity", "second", model, Date.parse("2026-10-17T00:00:00Z"))); + } + expect(providerWindowObservations(quota).map(observation => observation.window)) + .toEqual(["custom:Gem", "custom:Gem (Weekly)", "custom:Cla (Weekly)"]); + + // Family-less reset time: with no Gem weekly window, an Argon weekly row must not stand in for it. + setCachedProviderAccountQuotaForTests("google-antigravity", "first", { + updatedAt: Date.now(), + customWindows: [ + { label: "Gem", percent: 10, resetAt: Date.parse("2026-10-18T05:00:00Z") }, + { label: "Argon (Weekly)", modelId: "gemini-4-argon", percent: 10, resetAt: Date.parse("2026-10-19T00:00:00Z") }, + ], + }); + expect(accountResetTimestamp("google-antigravity", "first", undefined, Date.parse("2026-10-17T00:00:00Z"))) + .toBe(Date.parse("2026-10-18T05:00:00Z")); + }); + + test("an Argon-only summary is an available display report that carries no routing evidence", async () => { + const windows = await summaryWindows([argonGroup]); + expect(label(windows)).toEqual(["Argon (Weekly)=25@gemini-4-argon"]); + // A usable summary suppresses the model-list fallback, as for any other summary. + expect(modelRequests).toBe(0); + setCachedProviderAccountQuotaForTests("google-antigravity", "first", { updatedAt: Date.now(), customWindows: windows }); + for (const model of [undefined, "gemini-4-argon-high", "claude-sonnet-4-6"]) { + expect(accountHeadroomPercent("google-antigravity", "first", model)).toBeNull(); + expect(accountResetTimestamp("google-antigravity", "first", model, Date.parse("2026-10-17T00:00:00Z"))).toBeNull(); + } + expect(rankAccountsByResetFirst("google-antigravity", ["first", "second"])).toEqual(["first", "second"]); + expect(rankAccountsByResetFirst("google-antigravity", ["second", "first"])).toEqual(["second", "first"]); + }); +}); From 5f6a62932c8fcd2e525b4095bafdc3e849d0777a Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 17 Oct 2026 16:45:43 -0400 Subject: [PATCH 04/29] fix(vertex): honor project and location for API-key inference (#7266) * fix(google): honor Vertex project endpoints with API keys * fix(vertex): select API-key project endpoints from provider config only Keep #7266's project/location endpoint for API keys, but only when the provider configures project explicitly. Ambient GOOGLE_CLOUD_PROJECT, GCLOUD_PROJECT and GOOGLE_CLOUD_LOCATION stay ADC fallbacks, so a gcloud default in the shell cannot move an express-mode key onto another project. Endpoint selection now lives in src/adapters/google-vertex-endpoint.ts and also derives the thought-signature replay namespace. Every Vertex branch rejects a model id that is not a single path segment before credentials are attached. * fix(vertex): separate express and project replay namespaces Prefix the Vertex replay key with the endpoint kind and encode the project, so a configured project named "api-key" can no longer share express mode's thought-signature namespace. * fix(vertex): treat a blank project as unset and sync translated docs Trim provider.project once so a whitespace-only value keeps API keys on express mode and lets ADC use its environment fallback, and update the project/location rows in the seven translated provider references. --------- Co-authored-by: JUN --- .../fr/reference/configuration/providers.md | 4 +- .../ja/reference/configuration/providers.md | 4 +- .../ko/reference/configuration/providers.md | 4 +- .../docs/reference/configuration/providers.md | 6 +- .../ru/reference/configuration/providers.md | 4 +- .../tr/reference/configuration/providers.md | 4 +- .../reference/configuration/providers.md | 4 +- .../reference/configuration/providers.md | 4 +- src/adapters/google-vertex-endpoint.ts | 67 ++++++++++ src/adapters/google.ts | 35 ++--- src/types/provider.ts | 4 +- structure/providers/google.md | 19 +++ tests/adapters/google/gcp-adc.test.ts | 123 +++++++++++++++++- .../google-vertex-thought-signature.test.ts | 37 ++++++ 14 files changed, 274 insertions(+), 45 deletions(-) create mode 100644 src/adapters/google-vertex-endpoint.ts 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 fd362c344a..e0543e12d7 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -167,8 +167,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `anthropicEofTolerance?` | `boolean` | Laissez une passerelle compatible Anthropic compléter un flux qui se termine avant `message_stop`, uniquement lorsque du texte visible ou une entrée complète d'outil d'objet JSON a été reçue. Désactivé par défaut. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Mode Google transport/auth. Par défaut `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google uniquement. Une valeur absente ou `compatible` conserve le schéma compatible et la réparation 400 existante hors mode direct. `reject-lossy` refuse une perte initiale ou une comparaison bornée indéterminée avant l’envoi et bloque une réparation Vertex ou Cloud Code Assist qui ouvrirait des contraintes. AI Studio direct n’effectue jamais cette réparation. | -| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. | -| `location?` | `string` | Région Vertex ; la valeur de repli de l'environnement est `GOOGLE_CLOUD_LOCATION`. | +| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. Lorsqu'il est défini, les clés API Vertex utilisent le point de terminaison du projet ; sinon elles utilisent le mode express, même si `GOOGLE_CLOUD_PROJECT` est défini. ADC se replie sur `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | +| `location?` | `string` | Région Vertex. Les points de terminaison de projet avec clé API utilisent `global` par défaut et ignorent `GOOGLE_CLOUD_LOCATION` ; ADC exige ce champ ou le repli `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor uniquement : serveurs MCP sur entrée-sortie standard ou HTTP diffusé en continu. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor uniquement : commandes externes d'utilisation d'un ordinateur et d'enregistrement de l'écran. | | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | 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 d80b0b8238..bd1221c396 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -159,8 +159,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `anthropicEofTolerance?` | `boolean` | `message_stop` 前にストリームが終了しても、可視テキストまたは完全な JSON オブジェクトのツール入力が受信済みの場合に限り完了を許可します(Anthropic 互換ゲートウェイ向け)。デフォルトはオフ。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google トランスポート/認証モード。デフォルトは`ai-studio`です。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 専用です。省略時または `compatible` は互換スキーマと既存の非直接 400 修復を維持します。`reject-lossy` は初期損失または判定不能な上限付き比較を送信前に拒否し、制約を開く Vertex/Cloud Code Assist 修復を保留します。直接 AI Studio はこの修復を行いません。 | -| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。 | -| `location?` | `string` |頂点の位置。環境フォールバックは `GOOGLE_CLOUD_LOCATION` です。 | +| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。Vertex の API キーは、これが設定されているとプロジェクトエンドポイントを使い、未設定なら `GOOGLE_CLOUD_PROJECT` があっても express モードを使います。ADC は `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` にフォールバックします。 | +| `location?` | `string` | Vertex のロケーション。API キーのプロジェクトエンドポイントは既定で `global` を使い、`GOOGLE_CLOUD_LOCATION` を無視します。ADC にはこの値か `GOOGLE_CLOUD_LOCATION` フォールバックが必要です。 | | `mcpServers?` | `Record` |カーソルのみ: 標準入出力またはストリーミング可能な HTTP MCP サーバー。 | | `desktopExecutor?` | `DesktopExecutorConfig` |カーソルのみ: 外部コンピュータ使用および画面録画コマンド。 | | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | 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 b8eef4372b..010b1df4cc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -159,8 +159,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `anthropicEofTolerance?` | `boolean` | `message_stop` 전에 스트림이 끝나도 표시 텍스트 또는 완전한 JSON 객체 툴 입력을 받은 경우에만 완료를 허용합니다(Anthropic 호환 게이트웨이용). 기본값은 꺼짐. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 전송/인증 모드입니다. 기본값은 `ai-studio`입니다. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 전용입니다. 생략하거나 `compatible`이면 호환 스키마와 기존 비직접 400 복구를 유지합니다. `reject-lossy`는 초기 손실 또는 판정 불가능한 상한 비교를 전송 전에 거부하고 제약을 개방하는 Vertex 또는 Cloud Code Assist 복구를 보류합니다. 직접 AI Studio는 이 복구를 수행하지 않습니다. | -| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. | -| `location?` | `string` | Vertex 위치입니다. 환경 변수 폴백은 `GOOGLE_CLOUD_LOCATION`입니다. | +| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. Vertex API 키는 이 값이 있으면 프로젝트 엔드포인트를 쓰고, 없으면 `GOOGLE_CLOUD_PROJECT`가 설정돼 있어도 express 모드를 씁니다. ADC는 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`로 폴백합니다. | +| `location?` | `string` | Vertex 위치입니다. API 키 프로젝트 엔드포인트는 기본값이 `global`이고 `GOOGLE_CLOUD_LOCATION`을 무시합니다. ADC는 이 값이나 `GOOGLE_CLOUD_LOCATION` 폴백이 필요합니다. | | `mcpServers?` | `Record` | Cursor 전용입니다. stdio 또는 Streamable HTTP MCP 서버입니다. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor 전용입니다. 외부 computer-use 및 record-screen 명령입니다. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c72a3048f3..e2bf7ee5b2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -3,6 +3,8 @@ title: Provider Configuration description: Provider entries, authentication, endpoints, model catalogs, quotas, context caps, and provider-specific options. --- + + A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are authenticated. @@ -364,11 +366,11 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | | `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | -| `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | +| `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. Vertex API keys use this project endpoint when set; without it they use express mode, even if `GOOGLE_CLOUD_PROJECT` is set. ADC falls back to `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | | — | — | Antigravity quota shows Gemini 4 Argon as separate `Argon` / `Argon (Weekly)` windows next to Gem and Cla. They are display-only: account ranking, quota exhaustion, reset ordering and reset notifications keep using the Gemini and Claude windows, and Argon requests are still judged by the Gemini window. | -| `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | +| `location?` | `string` | Vertex location. API-key project endpoints default to `global` and ignore `GOOGLE_CLOUD_LOCATION`; ADC requires this field or the `GOOGLE_CLOUD_LOCATION` fallback. Gemini 4 Argon supports `global`, `us`, and `eu`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | 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 b143a3e748..fbca4a5d27 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -172,8 +172,8 @@ cross-route credential fallback не существует. Строки API GPT- | `anthropicEofTolerance?` | `boolean` | Позволяет Anthropic-совместимому шлюзу завершить поток до `message_stop`, только если получен видимый текст или полный JSON-объект аргументов инструмента. По умолчанию выключено. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Режим транспорта/аутентификации Google. По умолчанию `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Только Google. Отсутствие поля или `compatible` сохраняет совместимую схему и существующее недиректное восстановление после 400. `reject-lossy` отклоняет начальную потерю или неопределённый результат ограниченного сравнения до отправки и блокирует восстановление Vertex или Cloud Code Assist, которое сняло бы ограничения. Прямой AI Studio такое восстановление не выполняет. | -| `project?` | `string` | Идентификатор проекта Vertex или Antigravity Cloud Code Assist. | -| `location?` | `string` | Локация Vertex; fallback через окружение — `GOOGLE_CLOUD_LOCATION`. | +| `project?` | `string` | Идентификатор проекта Vertex или Antigravity Cloud Code Assist. Если он задан, API-ключи Vertex используют endpoint проекта; без него — режим express, даже если задан `GOOGLE_CLOUD_PROJECT`. ADC использует fallback `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | +| `location?` | `string` | Локация Vertex. Endpoint проекта для API-ключа по умолчанию использует `global` и игнорирует `GOOGLE_CLOUD_LOCATION`; ADC требует это поле или fallback `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Только Cursor: MCP-серверы в режимах stdio или Streamable HTTP. | | `desktopExecutor?` | `DesktopExecutorConfig` | Только Cursor: команды внешнего computer-use и record-screen. | | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | 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 cd588eb2da..a2d31756d4 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -171,8 +171,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `anthropicEofTolerance?` | `boolean` | Yalnızca görünür metin veya eksiksiz bir JSON nesnesi araç girdisi alındığında, Anthropic uyumlu bir ağ geçidinin `message_stop` öncesinde biten bir akışı tamamlamasına izin verin. Varsayılan olarak kapalıdır. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google aktarım/kimlik doğrulama modu. Varsayılan `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Yalnızca Google. Alanın atlanması veya `compatible`, uyumlu şemayı ve mevcut doğrudan olmayan 400 onarımını korur. `reject-lossy`, ilk kaybı veya sonucu belirsiz sınırlı bir karşılaştırmayı gönderimden önce reddeder ve kısıtları açacak Vertex ya da Cloud Code Assist onarımını engeller. Doğrudan AI Studio bu onarımı yapmaz. | -| `project?` | `string` | Vertex veya Antigravity Cloud Code Assist proje kimliği. | -| `location?` | `string` | Vertex konumu; ortam geri dönüşü `GOOGLE_CLOUD_LOCATION`'dır. | +| `project?` | `string` | Vertex veya Antigravity Cloud Code Assist proje kimliği. Ayarlandığında Vertex API anahtarları proje uç noktasını kullanır; ayarlanmadığında `GOOGLE_CLOUD_PROJECT` tanımlı olsa bile express modunu kullanır. ADC, `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` değerine geri döner. | +| `location?` | `string` | Vertex konumu. API anahtarlı proje uç noktaları varsayılan olarak `global` kullanır ve `GOOGLE_CLOUD_LOCATION` değerini yok sayar; ADC bu alanı veya `GOOGLE_CLOUD_LOCATION` geri dönüşünü gerektirir. | | `mcpServers?` | `Record` | Yalnızca Cursor: stdio veya Akışlanabilir HTTP MCP sunucuları. | | `desktopExecutor?` | `DesktopExecutorConfig` | Yalnızca Cursor: harici bilgisayar kullanımı ve ekran kaydetme komutları. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | 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 e3695f386d..de7d9c6187 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 @@ -161,8 +161,8 @@ selector,而不是分配一个新名称。 | `anthropicEofTolerance?` | `boolean` | 允许 Anthropic 兼容网关在 `message_stop` 前结束流,仅当已收到可见文本或完整的 JSON 对象工具输入时。默认关闭。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 传输/身份验证模式。默认 `ai-studio`。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | 仅限 Google。省略或设为 `compatible` 时保留兼容架构和现有的非直连 400 修复。`reject-lossy` 会在发送前拒绝初始损失或结果不确定的有界比较,并阻止会放宽约束的 Vertex 或 Cloud Code Assist 修复。直连 AI Studio 从不执行该修复。 | -| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 项目 id。 | -| `location?` | `string` | Vertex 位置;环境变量回退为 `GOOGLE_CLOUD_LOCATION`。 | +| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 项目 id。设置后,Vertex API 密钥使用项目端点;未设置时,即使存在 `GOOGLE_CLOUD_PROJECT` 也使用 express 模式。ADC 回退到 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`。 | +| `location?` | `string` | Vertex 位置。API 密钥的项目端点默认使用 `global`,并忽略 `GOOGLE_CLOUD_LOCATION`;ADC 需要此字段或 `GOOGLE_CLOUD_LOCATION` 回退。 | | `mcpServers?` | `Record` | 仅 Cursor:stdio 或 Streamable HTTP MCP 服务器。 | | `desktopExecutor?` | `DesktopExecutorConfig` | 仅 Cursor:外部 computer-use 和录屏命令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | 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 073d565357..845f5d1e85 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 @@ -131,8 +131,8 @@ ocx models provider openrouter on | `escapeBuiltinToolNames?` | `boolean` | 為 Anthropic 相容閘道轉義內建工具名稱,並在回傳的呼叫中還原它們。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 傳輸/認證模式。預設 `ai-studio`。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | 僅限 Google。省略或設為 `compatible` 時保留相容結構描述與既有的非直接 400 修復。`reject-lossy` 會在傳送前拒絕初始損失或結果不確定的有界比較,並阻止會放寬限制的 Vertex 或 Cloud Code Assist 修復。直接 AI Studio 不會執行該修復。 | -| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 專案 id。 | -| `location?` | `string` | Vertex 位置;環境後備為 `GOOGLE_CLOUD_LOCATION`。 | +| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 專案 id。設定後,Vertex API 金鑰使用專案端點;未設定時,即使有 `GOOGLE_CLOUD_PROJECT` 也使用 express 模式。ADC 後備為 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`。 | +| `location?` | `string` | Vertex 位置。API 金鑰的專案端點預設為 `global`,並忽略 `GOOGLE_CLOUD_LOCATION`;ADC 需要此欄位或 `GOOGLE_CLOUD_LOCATION` 後備。 | | `mcpServers?` | `Record` | 僅 Cursor:stdio 或 Streamable HTTP MCP 伺服器。 | | `desktopExecutor?` | `DesktopExecutorConfig` | 僅 Cursor:外部 computer-use 與 record-screen 指令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | diff --git a/src/adapters/google-vertex-endpoint.ts b/src/adapters/google-vertex-endpoint.ts new file mode 100644 index 0000000000..ca170022e5 --- /dev/null +++ b/src/adapters/google-vertex-endpoint.ts @@ -0,0 +1,67 @@ +import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; + +/** + * Vertex endpoint selection, shared by inference and replay identity. + * + * API keys follow explicit provider configuration only: `provider.project` selects the project + * endpoint (location defaults to `global`), otherwise the projectless express endpoint is used. + * Ambient GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT / GOOGLE_CLOUD_LOCATION belong to ADC, so an + * unrelated gcloud default in the shell cannot move an express-mode key onto another project. + */ +export type VertexEndpointSelection = + | { kind: "express" } + | { kind: "project"; project: string; location: string }; + +type VertexEndpointConfig = { project?: string; location?: string }; + +export function selectVertexEndpoint( + provider: VertexEndpointConfig, + hasApiKey: boolean, + env: NodeJS.ProcessEnv = process.env, +): VertexEndpointSelection { + // A blank or whitespace-only project is unset: it must not select a /projects/%20/ endpoint. + const configuredProject = provider.project?.trim() || undefined; + if (hasApiKey) { + if (!configuredProject) return { kind: "express" }; + return { kind: "project", project: configuredProject, location: provider.location || "global" }; + } + const project = configuredProject || env.GOOGLE_CLOUD_PROJECT || env.GCLOUD_PROJECT; + if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT)."); + const location = provider.location || env.GOOGLE_CLOUD_LOCATION; + if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION)."); + return { kind: "project", project, location }; +} + +/** + * Replay namespace for thought-signature continuation; mirrors the endpoint actually used. + * The endpoint kind is part of the key and the project is encoded, so no configured project + * (for example one literally named "api-key" or containing ":") can share express mode's space. + */ +export function vertexReplayModelKey(selection: VertexEndpointSelection, modelId: string): string { + return selection.kind === "express" + ? `vertex:express:${modelId}` + : `vertex:project:${encodeURIComponent(selection.project)}:${selection.location}:${modelId}`; +} + +const VERTEX_MODEL_PATH_REJECT = /[/?#\\%]/; + +/** The model id is interpolated as one URL path segment after a fixed Google authority. */ +export function vertexModelPathError(modelId: string): string | undefined { + if (!modelId || VERTEX_MODEL_PATH_REJECT.test(modelId) || modelId.includes("..")) { + return "Vertex AI model id must be a single path segment without '/', '?', '#', '\\', '%' or '..'."; + } + return undefined; +} + +/** Builds the inference URL for a selection; validates location before any authority is formed. */ +export function vertexInferenceUrl(selection: VertexEndpointSelection, modelId: string, method: string, streamParam: string): string { + const modelError = vertexModelPathError(modelId); + if (modelError) throw new Error(modelError); + if (selection.kind === "express") { + return `https://aiplatform.googleapis.com/v1/publishers/google/models/${modelId}:${method}${streamParam}`; + } + const locationError = googleVertexLocationConfigError(selection.location); + if (locationError) throw new Error(locationError); + const host = selection.location === "global" ? "aiplatform.googleapis.com" : `${selection.location}-aiplatform.googleapis.com`; + return `https://${host}/v1/projects/${encodeURIComponent(selection.project)}/locations/${selection.location}/publishers/google/models/${modelId}:${method}${streamParam}`; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 5093fe0917..6bcafa0ce4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -36,7 +36,7 @@ import { observeAntigravityReplay, } from "./google-antigravity-replay"; import { canonicalAntigravityUsageModel, resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; -import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; +import { selectVertexEndpoint, vertexInferenceUrl, vertexReplayModelKey } from "./google-vertex-endpoint"; import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { isTranslatorBudgetExceededError, @@ -1192,9 +1192,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); reportToolSchemaLoss(compiled.toolSchemaLossReport); restoreGoogleToolName = compiled.restoreToolName; - const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key"; - const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global"; - vertexReplayModel = `vertex:${vertexProject}:${vertexLocation}:${parsed.modelId}`; + const apiKey = resolveVertexApiKey(provider.apiKey); + const vertexEndpoint = selectVertexEndpoint(provider, Boolean(apiKey)); + const url = vertexInferenceUrl(vertexEndpoint, parsed.modelId, method, streamParam); + vertexReplayModel = vertexReplayModelKey(vertexEndpoint, parsed.modelId); vertexReplaySession = vertexReplaySessionId(parsed); // Compile names before replay so the cache matches the exact provider-visible // functionCall identity. This is the same bounded TTL/LRU store used by CCA, with the @@ -1210,27 +1211,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte (compiled.body as { contents: unknown[] }).contents, ); } - // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path. - const apiKey = resolveVertexApiKey(provider.apiKey); - if (apiKey) { - const url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; - headers["x-goog-api-key"] = apiKey; - return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; + // Endpoint, location and model path were validated above, before any credential is attached. + if (apiKey) headers["x-goog-api-key"] = apiKey; + else { + // Resolve each marker per request; never share a process-global active account. + const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); + headers["Authorization"] = `Bearer ${await getVertexAccessToken({ markerAccount })}`; } - const project = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; - if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT)."); - const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION; - if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION)."); - const locationError = googleVertexLocationConfigError(location); - if (locationError) throw new Error(locationError); - const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; - const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; - // The marker account travels per-request as an argument (never process-global state): a - // module-level "active marker" written at routing time would race across concurrent - // requests for different providers and could resolve one call with another's credential. - const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); - const token = await getVertexAccessToken({ markerAccount }); - headers["Authorization"] = `Bearer ${token}`; return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } diff --git a/src/types/provider.ts b/src/types/provider.ts index d103585420..2a94ae0a52 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1110,9 +1110,9 @@ export interface OcxProviderConfig { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; /** Google tool-schema compatibility policy. Omitted preserves compatible report-only behavior. */ googleToolSchemaPolicy?: "compatible" | "reject-lossy"; - /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */ + /** Vertex AI GCP project id. API keys use it to select the project endpoint; ADC falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env. */ project?: string; - /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ + /** Vertex AI location, e.g. "us-central1" or "global". API-key project endpoints default to "global"; ADC falls back to GOOGLE_CLOUD_LOCATION env. */ location?: string; /** * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent diff --git a/structure/providers/google.md b/structure/providers/google.md index ca74f84386..b4a7383e67 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -1,5 +1,24 @@ # Google Provider +Created: 2026-10-16 | Updated: 2026-10-18 | Version: 1.1 (Vertex endpoint selection) + +## Vertex API-key endpoint selection + +`src/adapters/google-vertex-endpoint.ts` selects the Vertex endpoint once per request and the +same selection feeds the thought-signature replay namespace. API keys follow explicit provider +configuration only: `provider.project` selects +`/v1/projects/{project}/locations/{location}/...` with `provider.location` or `global`, which +keeps models such as Gemini 4 Argon reachable when express mode would pick a region without +them. Without `provider.project`, API keys keep the projectless express endpoint even when +`GOOGLE_CLOUD_PROJECT`, `GCLOUD_PROJECT` or `GOOGLE_CLOUD_LOCATION` are set, so an unrelated +gcloud default cannot move an express key onto another project. Those environment fallbacks +apply to ADC only, which still requires a project and a location and resolves service-account +markers per request. Every branch validates the location and rejects a model id that is not a +single path segment before any credential is attached; keys stay in `x-goog-api-key`. +`tests/adapters/google/gcp-adc.test.ts` and +`tests/adapters/google/google-vertex-thought-signature.test.ts` cover the selection, ambient +environment isolation, ADC fallbacks, model-path rejection, and replay isolation. + ## Vertex credential markers `src/lib/gcp-adc.ts` resolves an explicit `gcp-sa:` through the OS credential store. diff --git a/tests/adapters/google/gcp-adc.test.ts b/tests/adapters/google/gcp-adc.test.ts index e7234848dc..fe0c8d894c 100644 --- a/tests/adapters/google/gcp-adc.test.ts +++ b/tests/adapters/google/gcp-adc.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, afterAll, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, afterAll, describe, expect, test } from "bun:test"; import { Buffer } from "node:buffer"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -58,15 +58,25 @@ afterAll(() => { afterEach(() => { __resetVertexTokenCache(); - for (const k of ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "CLOUDSDK_CONFIG"]) { + for (const k of VERTEX_ENV_KEYS) { if (prevEnv[k] === undefined) delete process.env[k]; else process.env[k] = prevEnv[k]; } prevEnv = {}; oauthCalls = 0; }); +const VERTEX_ENV_KEYS = ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "CLOUDSDK_CONFIG"]; + +// Start every case from a clean Vertex environment; afterEach restores the caller's values. +beforeEach(() => { + for (const k of VERTEX_ENV_KEYS) { + if (!(k in prevEnv)) prevEnv[k] = process.env[k]; + delete process.env[k]; + } +}); + function setEnv(k: string, v: string): void { - prevEnv[k] = process.env[k]; + if (!(k in prevEnv)) prevEnv[k] = process.env[k]; process.env[k] = v; } @@ -136,6 +146,90 @@ describe("google adapter vertex mode", () => { expect(oauthCalls).toBe(0); }); + test("vertex API key honors an explicit project and global location without ADC", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project", location: "global" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/fixture-project/locations/global/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(req.headers.Authorization).toBeUndefined(); + expect(oauthCalls).toBe(0); + }); + + test("ambient project and location leave a projectless API key on express mode", async () => { + setEnv("GOOGLE_CLOUD_PROJECT", "unrelated-gcloud-default"); + setEnv("GCLOUD_PROJECT", "unrelated-gcloud-default"); + setEnv("GOOGLE_CLOUD_LOCATION", "eu"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(oauthCalls).toBe(0); + }); + + test("a blank provider project is unset for API keys and ADC", async () => { + const keyed = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: " " } as OcxProviderConfig; + const req = await createGoogleAdapter(keyed).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + setEnv("GOOGLE_CLOUD_PROJECT", "env-proj"); + const adc = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: " ", location: "global" } as OcxProviderConfig; + const adcReq = await createGoogleAdapter(adc).buildRequest(parsed()); + expect(adcReq.url).toBe("https://aiplatform.googleapis.com/v1/projects/env-proj/locations/global/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + }); + + test("vertex API key project endpoint defaults to global and ignores ambient location", async () => { + setEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/fixture-project/locations/global/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + }); + + test("vertex API key honors an explicit regional location and non-streaming method", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture project", location: "us-central1" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest({ ...parsed("gemini-2.5-pro"), stream: false } as OcxParsedRequest); + expect(req.url).toBe("https://us-central1-aiplatform.googleapis.com/v1/projects/fixture%20project/locations/us-central1/publishers/google/models/gemini-2.5-pro:generateContent"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(req.headers.Authorization).toBeUndefined(); + }); + + test("environment API key uses the configured provider project", async () => { + setEnv("GOOGLE_CLOUD_API_KEY", "env-key"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", project: "fixture-project", location: "eu" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://eu-aiplatform.googleapis.com/v1/projects/fixture-project/locations/eu/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("env-key"); + expect(oauthCalls).toBe(0); + }); + + test("vertex accepts dotted and versioned model ids unchanged", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" } as OcxProviderConfig; + for (const id of ["gemini-2.5-flash-lite", "gemini-3.6-flash", "gemini-x@001"]) { + const req = await createGoogleAdapter(provider).buildRequest(parsed(id)); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/" + id + ":streamGenerateContent?alt=sse"); + } + }); + + test("vertex rejects model ids that escape the model path segment before any credential", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const providers = [ + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" }, + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project" }, + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", project: "fixture-project", location: "global" }, + ] as OcxProviderConfig[]; + for (const provider of providers) { + for (const id of ["a/b", "a?b", "a#b", "a\\b", "a%2Fb", "..", "gemini..x", ""]) { + await expect(createGoogleAdapter(provider).buildRequest(parsed(id))).rejects.toThrow("Vertex AI model id must be a single path segment"); + } + } + expect(oauthCalls).toBe(0); + }); + + test("vertex API key validates project endpoint locations before sending credentials", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project", location: "attacker.example/capture" } as OcxProviderConfig; + await expect(createGoogleAdapter(provider).buildRequest(parsed())).rejects.toThrow("Vertex AI location must be a single lowercase Google Cloud location label"); + expect(oauthCalls).toBe(0); + }); + test("vertex + ADC -> regional host + Authorization Bearer", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1", location: "us-central1" } as OcxProviderConfig; @@ -144,6 +238,29 @@ describe("google adapter vertex mode", () => { expect(req.headers["Authorization"]).toBe("Bearer vertex-tok"); }); + test("vertex + ADC keeps the environment project aliases and location fallback", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + setEnv("GOOGLE_CLOUD_LOCATION", "europe-west4"); + const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex" } as OcxProviderConfig; + setEnv("GOOGLE_CLOUD_PROJECT", "env-proj"); + let req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.url).toBe("https://europe-west4-aiplatform.googleapis.com/v1/projects/env-proj/locations/europe-west4/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + expect(req.headers["Authorization"]).toBe("Bearer vertex-tok"); + delete process.env.GOOGLE_CLOUD_PROJECT; + setEnv("GCLOUD_PROJECT", "alias-proj"); + req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.url).toBe("https://europe-west4-aiplatform.googleapis.com/v1/projects/alias-proj/locations/europe-west4/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + }); + + test("vertex + ADC still requires a project and a location", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const noProject = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", location: "global" } as OcxProviderConfig; + await expect(createGoogleAdapter(noProject).buildRequest(parsed())).rejects.toThrow("Vertex AI requires a project id"); + const noLocation = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1" } as OcxProviderConfig; + await expect(createGoogleAdapter(noLocation).buildRequest(parsed())).rejects.toThrow("Vertex AI requires a location"); + expect(oauthCalls).toBe(0); + }); + test("vertex + ADC + location global -> global aiplatform host", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1", location: "global" } as OcxProviderConfig; diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 9a95896a4c..585067de08 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -147,6 +147,43 @@ describe("Vertex thought-signature continuation (#1254)", () => { expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); }); + test("ambient Vertex environment changes do not move an express API key's replay namespace", async () => { + const saved = process.env.GOOGLE_CLOUD_PROJECT; + try { + delete process.env.GOOGLE_CLOUD_PROJECT; + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + process.env.GOOGLE_CLOUD_PROJECT = "unrelated-gcloud-default"; + const followup = await createGoogleAdapter(provider).buildRequest(continuation()); + expect(followup.url).toContain("/v1/publishers/google/models/"); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + } finally { + if (saved === undefined) delete process.env.GOOGLE_CLOUD_PROJECT; else process.env.GOOGLE_CLOUD_PROJECT = saved; + } + }); + + test("distinct API-key project endpoints keep separate replay namespaces", async () => { + const firstAdapter = createGoogleAdapter({ ...provider, project: "project-a" }); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + + const otherProject = await createGoogleAdapter({ ...provider, project: "project-b" }).buildRequest(continuation()); + expect(replayedFunctionCall(otherProject.body as string).thoughtSignature).toBe("skip_thought_signature_validator"); + const sameProject = await createGoogleAdapter({ ...provider, project: "project-a" }).buildRequest(continuation()); + expect(replayedFunctionCall(sameProject.body as string).thoughtSignature).toBe(SIGNATURE); + }); + + test("express mode and a project literally named api-key keep separate replay namespaces", async () => { + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + + const projectEndpoint = await createGoogleAdapter({ ...provider, project: "api-key", location: "global" }).buildRequest(continuation()); + expect(projectEndpoint.url).toContain("/v1/projects/api-key/locations/global/"); + expect(replayedFunctionCall(projectEndpoint.body as string).thoughtSignature).toBe("skip_thought_signature_validator"); + }); + test("Vertex signatures cannot enter the Antigravity replay namespace", async () => { const first = firstTurn(false); const adapter = createGoogleAdapter(provider); From d80dfcd2a4ae870e58cb89840529533bbe3bf0a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=8A=B9=ED=9B=88?= <99964673+Jossi02@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:14:02 +0900 Subject: [PATCH 05/29] fix(google): preserve matched and orphan Vertex Argon tool images (#7280) * fix(google): nest Vertex Argon tool-result images * fix(google): separate orphan images from Vertex Argon response batches * docs(google): clarify orphan result ordering guarantee * test(google): cover Argon signature replay with matched and duplicate tool images Seeds a real replay signature, then checks on one continuation that the matched image nests in functionResponse.parts, the duplicate image moves to the following marked user turn, and neither image's bytes leak into text parts. * test(google): cover streaming continuations and error results for Argon tool images The continuation request now runs with stream on and off, and isError results keep matched-image nesting and orphan-image placement. Both cases fail on the pre-#7280 serializer. * test(google): cover API-key project endpoints with Argon tool images Combines #7266 and #7280 on one request: a configured-project API key uses the project endpoint with location global, a projectless key stays on express, and in both the matched image nests in functionResponse.parts while the duplicate image moves to the marked orphan turn. Ambient project/location variables are set to conflicting values and restored. --------- Co-authored-by: JUN --- .../src/content/docs/guides/providers.md | 7 + .../src/content/docs/ko/guides/providers.md | 7 + src/adapters/google.ts | 23 +- .../ADR-7279-vertex-argon-tool-images.md | 16 + .../ADR-7280-vertex-argon-orphan-images.md | 15 + structure/providers/google.md | 11 +- .../google-vertex-thought-signature.test.ts | 300 +++++++++++++++++- 7 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 structure/decisions/ADR-7279-vertex-argon-tool-images.md create mode 100644 structure/decisions/ADR-7280-vertex-argon-orphan-images.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index bbbf9a6132..40486d1b24 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,6 +190,13 @@ eligible Enterprise accounts. Cloud Code Assist serves Argon only to the Antigra OpenCodex sends Argon requests and model discovery with the CLI User-Agent (`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model. +On Vertex AI, `gemini-4-argon` receives images returned by tools, such as screenshots, inside +the matching function response. This avoids the upstream 400 error "Requests ending with a +model turn are not supported" after an image-producing tool call while preserving the images. +Images from duplicate results or results without a matching call are preserved in a separate +message after the tool responses, with a marker explaining that they could not be paired. +No configuration change is needed; this handling is specific to Vertex Argon. + For Gemini on Antigravity, including Argon, provider-marked thought summaries stay separate from the answer: Responses uses reasoning summary items, Chat Completions uses `reasoning_content`, and Messages uses `thinking` blocks. Clients decide how to display those diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 71e0fcb6d1..90e504c920 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -121,6 +121,13 @@ ocx logout Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. +Vertex AI의 `gemini-4-argon`에는 스크린샷 같은 도구 결과 이미지를 해당 함수 응답 안에 담아 +전송합니다. 이미지가 있는 도구 호출 뒤에 발생하는 400 오류 +"Requests ending with a model turn are not supported"를 방지하며 이미지를 보존합니다. +중복된 결과나 일치하는 호출이 없는 결과의 이미지는 도구 응답 뒤의 별도 메시지로 보존하고, +호출과 연결할 수 없었다는 표시를 남깁니다. +별도의 설정 변경은 필요하지 않으며, 이 처리는 Vertex Argon에만 적용됩니다. + ### Google 도구 스키마 손실 진단 Google 도구 선언은 선택된 엔드포인트 클래스에 맞춰 컴파일됩니다. `ocx debug provider on`, diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 6bcafa0ce4..ed55b5870f 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -333,13 +333,20 @@ function geminiToolResultParts( msg: OcxToolResultMessage, wireName: string, wireCallId: string, + nestToolResultImages: boolean, ): unknown[] { const functionResponse: Record = { name: wireName, response: { result: geminiToolResultText(msg.content) }, id: wireCallId, }; - return [{ functionResponse }, ...toolResultImageParts(msg.content)]; + const images = toolResultImageParts(msg.content); + // Vertex Argon rejects image siblings beside a functionResponse as an unsupported model tail. + if (nestToolResultImages && images.length > 0) { + functionResponse.parts = images; + return [{ functionResponse }]; + } + return [{ functionResponse }, ...images]; } function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown { @@ -370,6 +377,7 @@ function messagesToGeminiFormat( identityModelId: string, stripRejectedClaudeSdkParagraph = false, isCloudCodeAssist = false, + nestToolResultImages = false, ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. @@ -551,13 +559,17 @@ function messagesToGeminiFormat( const responseParts: unknown[] = []; for (const call of toolCalls) { const result = resultsById.get(call.wireCallId); - if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId)); + if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId, nestToolResultImages)); else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId)); } - for (const orphan of orphanResults) { - responseParts.push(...geminiOrphanToolResultParts(orphan)); - } + const orphanParts = orphanResults.flatMap(geminiOrphanToolResultParts); contents.push({ role: "user", parts: responseParts }); + // Orphan images cannot share Argon's response batch or claim another call's ownership. + if (nestToolResultImages && orphanParts.some(part => typeof part === "object" && part !== null && "inline_data" in part)) { + contents.push({ role: "user", parts: orphanParts }); + } else { + responseParts.push(...orphanParts); + } i = j - 1; } break; @@ -1026,6 +1038,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte identityModelId, stripRejectedClaudeSdkParagraph, isCloudCodeAssist, + provider.googleMode === "vertex" && parsed.modelId === "gemini-4-argon", ); lastInjectedCallIds = [...replayedCallIds]; lastReasoningReplayScope = parsed._reasoningReplayScope; diff --git a/structure/decisions/ADR-7279-vertex-argon-tool-images.md b/structure/decisions/ADR-7279-vertex-argon-tool-images.md new file mode 100644 index 0000000000..88b7f6940f --- /dev/null +++ b/structure/decisions/ADR-7279-vertex-argon-tool-images.md @@ -0,0 +1,16 @@ +# ADR-7279: decision recorded under "Google tool-result adjacency repair" + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) +- Purpose: allow Vertex `gemini-4-argon` to continue after a tool returns an image (#7279). +- Prior constraint: tool images were sibling `inline_data` parts beside `functionResponse`. + A synthetic live request ending in `user` still received HTTP 400, "Requests ending with + a model turn are not supported"; adding final user text did not repair it. +- Alternatives: discard images, move them to a separate user turn, or nest them inside the + response. Discarding loses information; separate turns obscure ownership in parallel results. +- Choice: nest matched result images in `functionResponse.parts` only for Vertex + `gemini-4-argon`, preserving the existing text, IDs, names, order and image bytes. The nested + synthetic request succeeded and reported IMAGE input tokens. Other routes retain their existing + representation because this incompatibility was reproduced only on Vertex Argon. +- Consequences: no new configuration, retry or shared-history mutation. Orphan results still + degrade to marked text and sibling images; existing adjacency and thought-signature repairs + remain intact. Revisit the model scope when another route has equivalent upstream evidence. diff --git a/structure/decisions/ADR-7280-vertex-argon-orphan-images.md b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md new file mode 100644 index 0000000000..401567e778 --- /dev/null +++ b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md @@ -0,0 +1,15 @@ +# ADR-7280: decision recorded under "Google tool-result adjacency repair" + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) +- Purpose: preserve adjacent orphan images without reopening the Vertex Argon 400 from #7279. +- Prior constraint: ADR-7279 nested matched images, but duplicate or mismatched result images + still shared their user turn with the function responses. Both cases reproduced the same + upstream 400; moving the orphan content to a following user turn succeeded. +- Alternatives: discard orphan images, attach them to a matched response, or preserve them in + a separate turn. The first loses content; the second invents ownership. +- Choice: for Vertex `gemini-4-argon` only, move the entire adjacent orphan batch to a following + user turn when it contains a representable image. Keep the complete response batch adjacent + to its calls and preserve the orphan markers, image bytes, and orphan result order. +- Consequences: no history mutation, invented success, configuration or retry. Text-only and + standalone orphans keep their prior shape; other Google routes are unchanged. The existing + Vertex regression file covers duplicates, mismatches, missing results and route isolation. diff --git a/structure/providers/google.md b/structure/providers/google.md index b4a7383e67..050021d825 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -132,9 +132,18 @@ Google-family requests serialize a model tool-call turn and its results as one a `model -> user` pair. The user turn contains exactly one `functionResponse` for every representable call in original call order. Missing results use an explicit unknown-history marker; duplicate, mismatched, and standalone results become marked text instead of unpaired function responses. -Representable data-URL images remain sibling `inline_data` parts in either case. +For Vertex `gemini-4-argon`, matched results carry representable data-URL images inside +`functionResponse.parts`, preserving each image's call ownership. Sibling images on that route can +produce a misleading "Requests ending with a model turn are not supported" error even when the +last turn is `user`. When an adjacent orphan batch contains representable images on that route, +the entire marked orphan batch follows the complete function-response batch in a separate user +turn, preserving orphan result order and image bytes without assigning an orphan image to another call. Text-only +orphans, standalone orphans and other routes retain their existing representation. +`tests/adapters/google/google-vertex-thought-signature.test.ts` covers the scoped wire format. > Decision record: [ADR-0058](../decisions/ADR-0058-google-tool-result-adjacency-repair.md) +> Decision record: [ADR-7279](../decisions/ADR-7279-vertex-argon-tool-images.md) +> Decision record: [ADR-7280](../decisions/ADR-7280-vertex-argon-orphan-images.md) ## Google opening functionCall repair diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 585067de08..0f8ad95666 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -5,7 +5,7 @@ import { applyAntigravityReplay, } from "../../../src/adapters/google-antigravity-replay"; import { antigravitySessionId } from "../../../src/adapters/google-antigravity-wire"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import type { AdapterEvent, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; const createGoogleAdapter = (...args: Parameters) => @@ -198,3 +198,301 @@ describe("Vertex thought-signature continuation (#1254)", () => { expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); }); }); + +describe("Vertex Argon tool-result images", () => { + const image = (data = "YQ==", mediaType = "image/png"): OcxContentPart => + ({ type: "image", imageUrl: `data:${mediaType};base64,${data}` }); + const inline = (data = "YQ==", mime_type = "image/png") => ({ inline_data: { mime_type, data } }); + + async function wire( + messages = continuation().context.messages, + googleMode: OcxProviderConfig["googleMode"] = "vertex", + modelId = "gemini-4-argon", + ) { + const built = await createGoogleAdapter({ ...provider, googleMode, project: "test-project" }) + .buildRequest({ ...request(messages, false), modelId }); + const body = JSON.parse(built.body); + return body.request ?? body; + } + + function history(content: string | OcxContentPart[]) { + const messages = continuation().context.messages; + messages[2] = { role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content, isError: false, timestamp: 0 }; + return messages; + } + + test.each([false, true])("Argon image continuation replays the original signature (stream=%s)", async (stream) => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(stream), modelId: "gemini-4-argon" }); + const json = JSON.stringify(vertexResponseBody()); + const response = new Response(stream ? `data: ${json}\n\n` : json); + const events: AdapterEvent[] = []; + if (stream) { + for await (const event of adapter.parseStream(response)) events.push(event); + } else events.push(...await adapter.parseResponse!(response)); + expect(events.some(event => event.type === "tool_call_start")).toBe(true); + const followup = await createGoogleAdapter(provider).buildRequest({ + ...request(history([image()]), false), modelId: "gemini-4-argon", + }); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(JSON.parse(followup.body).contents.at(-1).parts[0].functionResponse.parts).toEqual([inline()]); + }); + + test("Argon continuation replays the signature, nests the matched image and separates a duplicate image", async () => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await adapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const messages = history([image()]); + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "again" }, image("Yg==")], isError: false, timestamp: 0 }); + const followup = await createGoogleAdapter(provider).buildRequest({ ...request(messages, false), modelId: "gemini-4-argon" }); + const contents = JSON.parse(followup.body as string).contents; + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(contents.at(-2)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }); + expect(contents.at(-1)).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nagain[image]" }, inline("Yg=="), + ] }); + const textParts = contents.flatMap((c: { parts: Array<{ text?: string }> }) => c.parts).filter((p: { text?: string }) => typeof p.text === "string"); + expect(textParts.some((p: { text: string }) => p.text.includes("YQ==") || p.text.includes("Yg=="))).toBe(false); + }); + + test.each([false, true])("streaming and non-streaming continuations nest Argon images identically (stream=%p)", async (stream) => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await adapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const followup = await createGoogleAdapter(provider).buildRequest({ + ...request(history([image()]), stream), modelId: "gemini-4-argon", + }); + expect(followup.url).toContain(stream ? ":streamGenerateContent" : ":generateContent"); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(JSON.parse(followup.body as string).contents.at(-1)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }); + }); + + test("error results keep matched-image ownership and orphan-image placement", async () => { + const messages = history([{ type: "text", text: "failed" }, image()]); + (messages[2] as { isError: boolean }).isError = true; + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "failed again" }, image("Yg==")], isError: true, timestamp: 0 }); + const body = await wire(messages); + expect(body.contents.at(-2)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "failed[image]" }, parts: [inline()], + } }] }); + expect(body.contents.at(-1)).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nfailed again[image]" }, inline("Yg=="), + ] }); + }); + + const withAmbientVertexEnv = async (run: () => Promise) => { + const saved = { p: process.env.GOOGLE_CLOUD_PROJECT, g: process.env.GCLOUD_PROJECT, l: process.env.GOOGLE_CLOUD_LOCATION }; + try { + process.env.GOOGLE_CLOUD_PROJECT = "ambient-project"; + process.env.GCLOUD_PROJECT = "ambient-gcloud-project"; + process.env.GOOGLE_CLOUD_LOCATION = "europe-west4"; + await run(); + } finally { + if (saved.p === undefined) delete process.env.GOOGLE_CLOUD_PROJECT; else process.env.GOOGLE_CLOUD_PROJECT = saved.p; + if (saved.g === undefined) delete process.env.GCLOUD_PROJECT; else process.env.GCLOUD_PROJECT = saved.g; + if (saved.l === undefined) delete process.env.GOOGLE_CLOUD_LOCATION; else process.env.GOOGLE_CLOUD_LOCATION = saved.l; + } + }; + const expectApiKeyAuth = (built: { headers?: Record }) => { + const headers = Object.fromEntries(Object.entries(built.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])); + expect(headers["x-goog-api-key"]).toBe("vertex-test-key"); + expect(headers.authorization).toBeUndefined(); + }; + const orphanTurn = { role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nagain[image]" }, inline("Yg=="), + ] }; + const nestedTurn = { role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }; + function withDuplicate() { + const messages = history([image()]); + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "again" }, image("Yg==")], isError: false, timestamp: 0 }); + return messages; + } + + test("API-key project endpoint (#7266) carries Argon image nesting and signature replay (#7280)", async () => { + await withAmbientVertexEnv(async () => { + __resetAntigravityReplayCache(); + const keyed = { ...provider, project: "configured-project" } as OcxProviderConfig; + const first = createGoogleAdapter(keyed); + await first.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await first.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const followup = await createGoogleAdapter(keyed).buildRequest({ ...request(withDuplicate(), false), modelId: "gemini-4-argon" }); + expect(followup.url).toBe("https://aiplatform.googleapis.com/v1/projects/configured-project/locations/global/publishers/google/models/gemini-4-argon:generateContent"); + expectApiKeyAuth(followup); + const contents = JSON.parse(followup.body as string).contents; + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(contents.at(-2)).toEqual(nestedTurn); + expect(contents.at(-1)).toEqual(orphanTurn); + }); + }); + + test("projectless API key stays on the express endpoint while nesting Argon images", async () => { + await withAmbientVertexEnv(async () => { + const built = await createGoogleAdapter(provider).buildRequest({ ...request(withDuplicate(), false), modelId: "gemini-4-argon" }); + expect(built.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:generateContent"); + expectApiKeyAuth(built); + const contents = JSON.parse(built.body as string).contents; + expect(contents.at(-2)).toEqual(nestedTurn); + expect(contents.at(-1)).toEqual(orphanTurn); + }); + }); + + test.each([ + ["single image", [image()], [inline()], "[image]"], + ["multiple images with text", [{ type: "text", text: "captured" }, image(), image("Yg==", "image/jpeg")], + [inline(), inline("Yg==", "image/jpeg")], "captured[image][image]"], + ] as const)("nests %s inside the matching functionResponse", async (_label, images, expected, text) => { + const body = await wire(history([...images])); + expect(body.contents.at(-1)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: text }, parts: [...expected], + } }] }); + }); + + test("reversed parallel results retain image ownership and collision-safe call ids", async () => { + const messages = continuation().context.messages; + messages[1] = { role: "assistant", timestamp: 0, content: [ + { type: "toolCall", id: "call:a", name: "first", arguments: {} }, + { type: "toolCall", id: "call/a", name: "second", arguments: {} }, + ] }; + messages.splice(2, 1, + { role: "toolResult", toolCallId: "call/a", toolName: "second", content: [image("Yg==")], + isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: "call:a", toolName: "first", isError: false, timestamp: 0, + content: [{ type: "text", text: "first" }, image(), image("Yw==")] }, + ); + const body = await wire(messages); + const [first, second] = body.contents[1].parts; + expect(first.functionCall.id).not.toBe(second.functionCall.id); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: first.functionCall.id, name: "first", + response: { result: "first[image][image]" }, parts: [inline(), inline("Yw==")] } }, + { functionResponse: { id: second.functionCall.id, name: "second", + response: { result: "[image]" }, parts: [inline("Yg==")] } }, + ]); + }); + + test.each([ + ["plain text", "plain text"], ["empty string", ""], ["empty parts", []], + ] as const)("text-only output retains its existing shape (%s)", async (_label, content) => { + const body = await wire(history(typeof content === "string" ? content : [...content])); + expect(body.contents.at(-1).parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: typeof content === "string" && content ? content : "(empty tool output)" }, + } }]); + }); + + test.each([ + ["vertex", "gemini-3.1-pro"], + ["vertex", "gemini-4-argon-preview"], + ["ai-studio", "gemini-4-argon"], + ["cloud-code-assist", "gemini-4-argon"], + ] as const)("other routes retain image siblings (%s, %s)", async (mode, model) => { + const body = await wire(history([image()]), mode, model); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "[image]" } } }, + inline(), + ]); + }); + + test("ordinary user and orphan result images retain their existing shape", async () => { + const body = await wire([ + { role: "user", content: [image()], timestamp: 0 }, + { role: "toolResult", toolCallId: "orphan", toolName: "inspect", content: [image("Yg==")], + isError: false, timestamp: 0 }, + ]); + expect(body.contents).toEqual([ + { role: "user", parts: [inline()] }, + { role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: inspect (orphan)]\n[image]" }, inline("Yg=="), + ] }, + ]); + }); + + test.each(["call_shell_1", "unknown"])("separates orphan images after the complete response batch (%s)", async (id) => { + const messages = history([image()]); + messages.push( + { role: "toolResult", toolCallId: id, toolName: "inspect", content: "before", isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: id, toolName: "inspect", + content: [{ type: "text", text: "orphan" }, image("Yg=="), image("Yw==", "image/jpeg")], + isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: id, toolName: "inspect", content: "after", isError: false, timestamp: 0 }, + ); + const original = structuredClone(messages); + const body = await wire(messages); + expect(body.contents.slice(2)).toEqual([ + { role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }, + { role: "user", parts: [ + { text: `[tool_result without adjacent tool_use: inspect (${id})]\nbefore` }, + { text: `[tool_result without adjacent tool_use: inspect (${id})]\norphan[image][image]` }, + inline("Yg=="), inline("Yw==", "image/jpeg"), + { text: `[tool_result without adjacent tool_use: inspect (${id})]\nafter` }, + ] }, + ]); + expect(messages).toEqual(original); + }); + + test("orphan images follow missing responses without claiming a successful call", async () => { + const messages = continuation().context.messages; + messages[2] = { role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: [image()], + isError: false, timestamp: 0 }; + const body = await wire(messages); + expect(body.contents[2].parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: "[missing tool_result for this tool_use in history]" }, + } }]); + expect(body.contents[3]).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\n[image]" }, inline(), + ] }); + }); + + test.each(["vertex", "ai-studio", "cloud-code-assist"] as const)("text-only orphans retain their existing batch (%s)", async (mode) => { + const messages = history("done"); + messages.push({ role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: "orphan", + isError: false, timestamp: 0 }); + const body = await wire(messages, mode); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "done" } } }, + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\norphan" }, + ]); + }); + + test.each([ + ["vertex", "gemini-3.1-pro"], ["vertex", "gemini-4-argon-preview"], + ["ai-studio", "gemini-4-argon"], ["cloud-code-assist", "gemini-4-argon"], + ] as const)("other routes retain orphan image siblings (%s, %s)", async (mode, model) => { + const messages = history("done"); + messages.push({ role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: [image()], + isError: false, timestamp: 0 }); + const body = await wire(messages, mode, model); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "done" } } }, + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\n[image]" }, inline(), + ]); + }); + + test("opening call, missing result and model-tail repairs stay intact", async () => { + const messages = continuation().context.messages; + const body = await wire([messages[1]]); + expect(body.contents[0]).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + expect(body.contents.at(-1).parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: "[missing tool_result for this tool_use in history]" }, + } }]); + const tail = await wire([{ role: "assistant", content: [{ type: "text", text: "working" }], timestamp: 0 }]); + expect(tail.contents.at(-1)).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + }); +}); From c06ad2526fc8a9a2c406694524da30a71ca9bf78 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 14:32:37 -0700 Subject: [PATCH 06/29] feat(combos): let a combo set its catalog row description (#7229) (#7284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(combos): let a combo set its catalog row description (#7229) Combos could relabel their Codex picker row with displayName but the row description was always the generated "Routed via opencodex → combo (combo)." text. Add an optional combos..description, validated like displayName, accepted by the management API (omission keeps the saved value, "" clears) and by ocx combo set --description . Catalog maintenance recognized OpenCodex-authored rows by that generated prefix, so an overridden row now carries opencodex_routed_origin: "combo-v1". src/codex/catalog/ownership.ts owns both signals and replaces every prefix check (removal, shadow restore, template selection, auto-review, exact-combo admission, native residue). Rows without an override are byte-identical. * fix(combos): re-project the current description onto retained combo rows A combo row kept from the previous catalog when its fresh derivation fails (for example a native-alias combo whose member provider is disabled) bypassed deriveEntry, so changing or clearing the description in config did not reach the picker. Config-aware merges now pass the current combo descriptions and the retained loop re-applies them; a cleared override returns the generated combo text and drops the marker. * fix(combos): reject C1 controls in descriptions; document zh-tw flags Review follow-up for #7229: the description validator now rejects the full Unicode Cc class (C1 included), with NEL/CSI regression cases, and the Traditional Chinese combo guide lists --display-name/--description and the description field like the other locales. --- .../src/content/docs/fr/guides/combos.md | 5 +- docs-site/src/content/docs/guides/combos.md | 5 +- .../src/content/docs/ja/guides/combos.md | 5 +- .../src/content/docs/ko/guides/combos.md | 5 +- .../src/content/docs/ru/guides/combos.md | 3 +- .../src/content/docs/tr/guides/combos.md | 5 +- .../src/content/docs/zh-cn/guides/combos.md | 3 +- .../src/content/docs/zh-tw/guides/combos.md | 3 +- scripts/test-layout/layout.json | 2 +- .../references/01_surface_agents-routing.md | 3 +- src/cli/capabilities-agents-routing.ts | 4 +- src/cli/combo-result.ts | 2 +- src/cli/combo.ts | 3 + src/codex/catalog/aggregation.ts | 15 + src/codex/catalog/auto-review.ts | 3 +- src/codex/catalog/build-entries.ts | 24 +- src/codex/catalog/derive-entry.ts | 4 +- src/codex/catalog/metadata.ts | 2 + src/codex/catalog/ownership.ts | 71 ++++ src/codex/catalog/parsing.ts | 15 +- src/codex/catalog/retained-sync.ts | 3 +- src/codex/catalog/routed-removal.ts | 8 +- src/codex/convergence.ts | 3 +- src/codex/native-residue.ts | 5 +- src/combos/types.ts | 14 + src/server/management/combo-routes.ts | 5 + src/types/config.ts | 2 + structure/INDEX.md | 2 + structure/catalog-routed-ownership.md | 36 ++ structure/manifest.json | 7 + .../combo-description-catalog.test.ts | 343 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- tests/routing/combo-description.test.ts | 164 +++++++++ 33 files changed, 736 insertions(+), 40 deletions(-) create mode 100644 src/codex/catalog/ownership.ts create mode 100644 structure/catalog-routed-ownership.md create mode 100644 tests/codex-integration/combo-description-catalog.test.ts create mode 100644 tests/routing/combo-description.test.ts diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 7076e7df86..8a9398988f 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -294,8 +294,8 @@ ocx combo remove --yes ``` `set` accepte également `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias` ou -`--display-name` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement +`--display-name`, `--description` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias`, +`--display-name` ou `--description` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement pris en charge et un nom d’affichage non vide. `create` et `update` sont des alias pour `set` ; `delete` est un alias pour `remove` ; et les mêmes sous-commandes sont disponibles sous `ocx route combo`. @@ -341,6 +341,7 @@ Les combos sont stockés dans l'objet `combos` de niveau supérieur, saisi par l | `alias` | Non | aucun | Identifiant de modèle public tronqué facultatif ; utilisez les règles d'alias ci-dessus. Une valeur vide est stockée sans alias. | | `nativeAlias` | Non | `false` | Autoriser explicitement un `alias` natif nu actuellement pris en charge à avoir la priorité sur le routage et le catalogue. Jamais déduit de l'alias. | | `displayName` | Non | aucun | Étiquette de catalogue délimitée en affichage uniquement. Obligatoire et non vide lorsque `nativeAlias` est vrai. | +| `description` | Non | aucun | Description de ligne de catalogue en affichage uniquement, avec les mêmes limites que `displayName`. Sans valeur, le texte généré `Routed via opencodex → combo (combo).` est conservé. | ## Dépannage diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 4fa8724c5c..33ad8676cd 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -792,8 +792,8 @@ ocx combo remove --yes ``` `set` also accepts `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--allow-metered-targets on|off`, `--display-name`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the -value of `--effort`, `--alias`, `--display-name`, `--decision-provider`, or `--decision-timeout` to +`--allow-metered-targets on|off`, `--display-name`, `--description`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the +value of `--effort`, `--alias`, `--display-name`, `--description`, `--decision-provider`, or `--decision-timeout` to clear that field. The two decision flags apply only to `--strategy jev`. `--native-alias` requires a currently supported bare native model alias and a non-empty display name. `create` and `update` are aliases for `set`; `delete` is an alias for `remove`; and the same subcommands are available under `ocx route combo`. @@ -859,6 +859,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | | `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. | +| `description` | No | none | Display-only catalog row description, bounded like `displayName` (at most 128 characters, no control characters). Unset keeps the generated `Routed via opencodex → combo (combo).` text. Management API writes that omit it keep the saved value; an empty string clears it. | | `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a full HTTPS decision URL or a local HTTP `/systemone` URL, such as a self-hosted Ollama `tev1`. | | `decisionTimeoutMs` | No | `4000` | JEV only. Integer from 1000 to 120000: the decision deadline before failing open to the first eligible target. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 7ed25d4cb2..cb31f349e6 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -201,9 +201,9 @@ ocx combo remove --yes ``` `set` は、`--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 -`--display-name`、および `--rename-from` も受け入れます。`--native-alias` には現在 +`--display-name`、`--description`、および `--rename-from` も受け入れます。`--native-alias` には現在 サポートされている bare native alias と空でない display name が必要です。`--effort`、`--alias`、 -`--display-name` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の +`--display-name`、`--description` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の エイリアス、`delete` は `remove` のエイリアスです。すべてのコンボ サブコマンドは `ocx route combo ...` からも利用できます。 ### 管理 API @@ -248,6 +248,7 @@ ocx combo remove --yes | `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 | | `nativeAlias` |いいえ | `false` | 現在サポートされている bare native alias に routing/catalog の優先権を明示的に与えます。 | | `displayName` |いいえ |なし | catalog 表示専用ラベル。`nativeAlias` が true の場合は必須です。 | +| `description` |いいえ |なし | catalog 行の表示専用の説明。制限は `displayName` と同じです。未設定の場合は生成された `Routed via opencodex → combo (combo).` が使われます。 | ## トラブルシューティング diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index a9a69b76b5..e5d8c84451 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -209,9 +209,9 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, +`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, `--description`, `--rename-from`도 받습니다. `--native-alias`에는 현재 지원되는 bare native alias와 비어 있지 않은 display name이 필요합니다. `--effort`, `--alias`, -`--display-name`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의 +`--display-name`, `--description`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의 별칭이고 `delete`는 `remove`의 별칭입니다. 모든 combo 하위 명령은 `ocx route combo ...`에서도 사용할 수 있습니다. ### Management API @@ -256,6 +256,7 @@ ocx combo remove --yes | `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. | | `nativeAlias` | 아니요 | `false` | 현재 지원되는 bare native alias가 routing/catalog 우선권을 갖도록 명시적으로 허용합니다. | | `displayName` | 아니요 | 없음 | catalog 표시 전용 label입니다. `nativeAlias`가 true이면 필수입니다. | +| `description` | 아니요 | 없음 | catalog 행에 표시되는 설명입니다. `displayName`과 같은 제한이 적용됩니다. 설정하지 않으면 생성된 `Routed via opencodex → combo (combo).` 문구가 유지됩니다. | ## 문제 해결 diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 29c59a402b..31b760bfd3 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -251,7 +251,7 @@ ocx combo remove --yes ``` `set` также принимает `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` и `--rename-from`. Значение `-` у `--effort`, `--alias` или `--display-name` +`--display-name`, `--description` и `--rename-from`. Значение `-` у `--effort`, `--alias`, `--display-name` или `--description` очищает соответствующее поле. Для `--native-alias` нужны поддерживаемый сейчас bare native alias и непустой display name. `create` и `update` — alias для `set`, а `delete` — alias для `remove`; те же подкоманды доступны через `ocx route combo`. @@ -301,6 +301,7 @@ Combo хранятся в объекте верхнего уровня `combos`, | `alias` | No | none | Необязательный обрезанный публичный id модели; используйте правила alias выше. Пустое значение хранится как отсутствие alias. | | `nativeAlias` | No | `false` | Явно разрешает поддерживаемому сейчас bare native alias перехватить приоритет routing/catalog только для неквалифицированного id. Bare `gpt-5.6-*` использует учётные данные Codex Pool/Direct; маршруты с квалификатором аккаунта сохраняют свою идентичность, а provider-qualified `openai-apikey/gpt-5.6-*` использует API-ключ и никогда не переходит на native alias. | | `displayName` | No | none | Метка только для отображения в catalog; обязательна при `nativeAlias: true`. | +| `description` | No | none | Описание строки catalog только для отображения, с теми же ограничениями, что и `displayName`. Без него остаётся сгенерированный текст `Routed via opencodex → combo (combo).`. | ## Устранение неполадок diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 6e8b544478..e2063712af 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -324,8 +324,8 @@ ocx combo remove --yes ``` `set` ayrıca `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` ve `--rename-from` kabul eder. Bu alanı temizlemek için -`--effort`, `--alias` veya `--display-name` değeri olarak `-` kullanın. +`--display-name`, `--description` ve `--rename-from` kabul eder. Bu alanı temizlemek için +`--effort`, `--alias`, `--display-name` veya `--description` değeri olarak `-` kullanın. `--native-alias`, şu anda desteklenen yalın bir yerel model takma adı ve boş olmayan bir görüntüleme adı gerektirir. `create` ve `update`, `set`'in takma adlarıdır; `delete`, `remove`'un takma adıdır; ve aynı alt komutlar `ocx route @@ -375,6 +375,7 @@ saklanır: | `alias` | Hayır | yok | İsteğe bağlı kırpılmış genel model kimliği; yukarıdaki takma ad kurallarını kullanın. Boş bir değer takma ad yok olarak saklanır. | | `nativeAlias` | Hayır | `false` | Şu anda desteklenen yalın bir yerel `alias`'ın yönlendirme ve katalog önceliği almasına açıkça izin verin. Asla takma addan çıkarılmaz. | | `displayName` | Hayır | yok | Sınırlı salt görüntüleme katalog etiketi. `nativeAlias` true olduğunda gerekli ve boş değildir. | +| `description` | Hayır | yok | Salt görüntüleme katalog satırı açıklaması; `displayName` ile aynı sınırlar geçerlidir. Ayarlanmazsa üretilen `Routed via opencodex → combo (combo).` metni korunur. | ## Sorun Giderme diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 53e918bd56..efb8ddc85a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -230,7 +230,7 @@ ocx combo remove --yes ``` `set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 -`--display-name` 和 `--rename-from`。将 `--effort`、`--alias` 或 `--display-name` 的值设为 +`--display-name`、`--description` 和 `--rename-from`。将 `--effort`、`--alias`、`--display-name` 或 `--description` 的值设为 `-` 可清除该字段。`--native-alias` 必须配合当前受支持的裸原生 alias 和非空显示名称使用。 `create` 和 `update` 是 `set` 的别名;`delete` 是 `remove` 的别名;同样的子命令也可通过 `ocx route combo` 使用。 @@ -278,6 +278,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 | | `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 | | `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 | +| `description` | 否 | 无 | 仅用于 catalog 展示的行描述,限制与 `displayName` 相同。未设置时保留生成的 `Routed via opencodex → combo (combo).` 文本。 | ## 故障排查 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index d95c840937..86d26e0d0b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -236,7 +236,7 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias` 與 `--rename-from`。用 `-` 作為 `--effort` 或 `--alias` 的值可清除該欄位。`create` 與 `update` 為 `set` 的別名;`delete` 為 `remove` 的別名;且相同子指令在 `ocx route combo` 下也可用。 +`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias`、`--display-name`、`--description` 與 `--rename-from`。用 `-` 作為 `--effort`、`--alias`、`--display-name` 或 `--description` 的值可清除該欄位。`create` 與 `update` 為 `set` 的別名;`delete` 為 `remove` 的別名;且相同子指令在 `ocx route combo` 下也可用。 ### 管理 API @@ -275,6 +275,7 @@ Combo 儲存於頂層 `combos` 物件中,以 combo id 為 key: | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;僅在呼叫者省略 effort 且目標宣告支援時套用。 | | `reasoningEffortMode` | 否 | `"strict"` | `strict` 或 `adaptive`;選擇混合能力交集及目標層級控制正規化。 | | `alias` | 否 | 無 | 可選的修剪後公開模型 id;使用上述別名規則。空值儲存為無別名。 | +| `description` | 否 | 無 | 僅供 catalog 顯示的列描述,最多 128 個字元且不含控制字元。未設定時保留產生的 `Routed via opencodex → combo (combo).` 文字。 | ## 疑難排解 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index edab985cda..4949715da9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 5e7d916889..f8e1e9b10d 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -704,7 +704,7 @@ State-changing: no. JSON mode: `payload`. ### `ocx combo set` -Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` +Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` Create, update or rename a combo. @@ -725,6 +725,7 @@ State-changing: yes. JSON mode: `payload`. | `--effort-mode` | string | fallback or force; - resets to fallback. | | `--alias` | string | Alias; - clears. | | `--display-name` | string | Label; - clears. | +| `--description` | string | Catalog row description; - clears. | | `--decision-provider` | string | JEV provider; - clears. | | `--decision-model` | string | JEV route; - clears; exclusive with provider. | | `--decision-timeout` | string | JEV deadline 1000-120000 ms; - clears. | diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index 62e93f6eb7..dd047a5e09 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -298,10 +298,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ }, { command: ["combo","set"], - usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", + usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", summary: "Create, update or rename a combo.", routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}], - flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], + flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], mutates: true, json: "payload", details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."], diff --git a/src/cli/combo-result.ts b/src/cli/combo-result.ts index 97a0ad0aac..9af467dc5a 100644 --- a/src/cli/combo-result.ts +++ b/src/cli/combo-result.ts @@ -7,7 +7,7 @@ const ENUMS: Readonly> = { defaultEffortMode: ["fallback", "force"], reasoningEffortMode: ["strict", "adaptive"], imageInput: ["auto", "disabled"], cooldownWaitPolicy: ["before-last-resort"], }; -const STRINGS = new Set(["alias", "displayName", "decisionProvider", "decisionModel"]); +const STRINGS = new Set(["alias", "displayName", "description", "decisionProvider", "decisionModel"]); const RANGES: Readonly> = { stickyLimit: [1, 100], cooldownMs: [1, 600_000], waitForCooldownMs: [0, 600_000], decisionTimeoutMs: [1_000, 120_000], diff --git a/src/cli/combo.ts b/src/cli/combo.ts index c29dbf1fa3..cf91f6426e 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -24,6 +24,7 @@ const USAGE = `Usage: [--effort ] [--effort-mode ] (force overrides valid client effort and can increase cost/latency) [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] + [--description ] [--image-input ] [--reasoning-effort-mode ] [--decision-provider | --decision-model ] [--decision-timeout ] (jev only; the provider must be a configured jev-decision row) @@ -118,6 +119,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const alias = takeOption(args, "--alias"); const displayName = takeOption(args, "--display-name"); + const description = takeOption(args, "--description"); const decisionProvider = takeOption(args, "--decision-provider"); const decisionModel = takeOption(args, "--decision-model"); if (decisionModel !== undefined && decisionModel !== "-" && decisionProvider !== undefined && decisionProvider !== "-") { @@ -164,6 +166,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput; if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode; if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName; + if (description !== undefined) combo.description = description === "-" ? "" : description; if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider; if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel; if (decisionProvider !== undefined && decisionProvider !== "-") combo.decisionModel = null; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 8352eaabc9..d56b07db21 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -195,6 +195,7 @@ export function deriveComboCatalogModel( ...(combo.alias ? { alias: combo.alias } : {}), ...(combo.nativeAlias ? { nativeAlias: true } : {}), ...(combo.displayName ? { displayName: combo.displayName } : {}), + ...(combo.description ? { description: combo.description } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } @@ -401,6 +402,20 @@ export function exactComboCatalogSlugs( })); } +/** Current combo row descriptions by public catalog slug, for retained-row re-projection (#7229). */ +export function comboCatalogDescriptions( + config: Pick, +): Map { + const current = exactComboCatalogSlugs(config); + return new Map(listComboIds(config).flatMap(id => { + const raw = config.combos?.[id]; + const alias = typeof raw?.alias === "string" ? raw.alias.trim() : ""; + const publicSlug = alias || comboModelId(id); + const description = typeof raw?.description === "string" ? raw.description.trim() : ""; + return description && current.has(publicSlug) ? [[publicSlug, description] as const] : []; + })); +} + export function normalizedOpenAiApiSignature(model: CatalogModel): string { const normalized = { provider: model.provider, diff --git a/src/codex/catalog/auto-review.ts b/src/codex/catalog/auto-review.ts index dfb4b603ba..243d4fd8ba 100644 --- a/src/codex/catalog/auto-review.ts +++ b/src/codex/catalog/auto-review.ts @@ -5,6 +5,7 @@ import { encodeRoutedModelId } from "../../providers/slug-codec"; import { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation"; import { readConfiguredAutoReviewModel } from "./parsing"; import type { RawEntry } from "./parsing"; +import { hasOcxRoutedAuthorship } from "./ownership"; import { configuredCatalogEntry } from "./subagent-roster"; const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root"; @@ -38,7 +39,7 @@ function isRoutedCatalogEntry(entry: RawEntry): boolean { if (isCodexControlPlaneModel(entry.slug)) return false; const slug = typeof entry.slug === "string" ? entry.slug : ""; return slug.includes("/") - || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); + || hasOcxRoutedAuthorship(entry); } /** Restore an owned native value, retaining provenance to avoid legacy reclassification. */ diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 61ea74f1be..205e78d77b 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -4,6 +4,7 @@ import { CODEX_REASONING_LEVELS, type CodexReasoningLevel } from "../../reasonin import { clearModelCache } from "../model-cache"; import { routedSlug, slugEquivalenceKey } from "../../providers/slug-codec"; import { COMBO_NAMESPACE } from "../../combos"; +import { hasOcxComboAuthorship, hasOcxRoutedAuthorship, reapplyRetainedComboDescription, ROUTED_DESCRIPTION_PREFIX } from "./ownership"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, @@ -218,7 +219,7 @@ export function buildCatalogEntriesFromObservedState({ const routed = deriveEntry( template, slug, - `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, + `${ROUTED_DESCRIPTION_PREFIX}${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, 5, nativeAlias, exactComboSlugs, @@ -276,7 +277,7 @@ export function buildCatalogEntriesFromObservedState({ const e = deriveEntry( template, slug, - `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, + `${ROUTED_DESCRIPTION_PREFIX}${m.provider} (${m.owned_by ?? m.provider}).`, 5, m, exactComboSlugs, @@ -380,15 +381,15 @@ export function orderForModelPicker( * True when an existing catalog row was authored by OpenCodex routing (#855). * Every generated routed row — current full-slug form, the June–July 2026 * provider-name form, and legacy combo aliases — carries the stable - * description prefix `Routed via opencodex → `; foreign rows from Cursor or + * description prefix `Routed via opencodex → `, or — when a combo overrides its description + * (#7229) — the `opencodex_routed_origin` marker (./ownership); foreign rows from Cursor or * user tooling do not. `owned_by` describes upstream ownership, and `comp_hash` * describes history compatibility; neither is an authorship signal. */ export function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { if (isNativeAliasCatalogEntry(entry)) return true; - const desc = typeof entry.description === "string" ? entry.description : ""; const slug = typeof entry.slug === "string" ? entry.slug : ""; - return slug.includes("/") && desc.startsWith("Routed via opencodex → "); + return slug.includes("/") && hasOcxRoutedAuthorship(entry); } function recoverableNativeSlug(entry: RawEntry): string | null { @@ -503,6 +504,11 @@ export interface ObservedCatalogMergeInput { readonly multiAgentV2Enabled: boolean; readonly keepNativeChatGptOnV1?: boolean; readonly exactComboSlugs: ReadonlySet; + /** + * Current combo row descriptions by public slug (#7229). When supplied, retained combo rows + * are re-projected onto it; absent means the caller has no config and leaves them unchanged. + */ + readonly comboDescriptions?: ReadonlyMap; readonly hasPhysicalComboProvider: boolean; readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; @@ -545,6 +551,7 @@ export function mergeCatalogEntriesFromObservedState({ multiAgentV2Enabled, keepNativeChatGptOnV1, exactComboSlugs, + comboDescriptions, hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, @@ -639,8 +646,7 @@ export function mergeCatalogEntriesFromObservedState({ } const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( isExactComboCatalogEntry(entry, exactComboSlugs) - && typeof entry.description === "string" - && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`) + && hasOcxComboAuthorship(entry) ))); const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( @@ -802,6 +808,10 @@ export function mergeCatalogEntriesFromObservedState({ delete entry[SPAWN_PRIORITY_FIELD]; } const slug = String(entry.slug); + if (comboDescriptions !== undefined && exactComboSlugs.has(slug) + && (isNativeAliasCatalogEntry(entry) || (entry.owned_by === COMBO_NAMESPACE && hasOcxComboAuthorship(entry)))) { + reapplyRetainedComboDescription(entry, comboDescriptions.get(slug)); + } if (!isOcxAuthoredRoutedEntry(entry)) continue; // The builder no longer copies a template's comp_hash onto routed rows (#5796), but a row // kept from disk may still carry one. Custom rows, Codex-forward aliases included, never diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 98bd424e71..8a710a295a 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -26,6 +26,7 @@ import { isGpt56NativeSlug, } from "./effort"; import { CATALOG_INACTIVE_REASON_FIELD, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; +import { applyRoutedDescription } from "./ownership"; export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { if (priority !== 9) clone.priority = priority; @@ -128,7 +129,7 @@ export function deriveEntry( delete e[SPAWN_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); - e.description = desc; + applyRoutedDescription(e, desc, model); e.priority = priority; e.visibility = "list"; if ("upgrade" in e) e.upgrade = null; @@ -212,6 +213,7 @@ export function deriveEntry( : { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), }; + applyRoutedDescription(entry, desc, model); if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); applyReasoningLevels( diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 4bc001a5a4..ada0e73893 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -38,6 +38,7 @@ import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { ROUTED_ORIGIN_FIELD } from "./ownership"; import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, @@ -813,6 +814,7 @@ export function observedReserveCatalogSource( } source.slug = NATIVE_RESERVE_MODEL; delete source.opencodex_catalog_kind; + delete source[ROUTED_ORIGIN_FIELD]; delete source[RESERVE_METADATA_SOURCE_FIELD]; delete source[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER]; delete source[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER]; diff --git a/src/codex/catalog/ownership.ts b/src/codex/catalog/ownership.ts new file mode 100644 index 0000000000..b27119c671 --- /dev/null +++ b/src/codex/catalog/ownership.ts @@ -0,0 +1,71 @@ +import { COMBO_NAMESPACE } from "../../combos/identifiers"; +import type { CatalogModel, RawEntry } from "./parsing"; + +/** + * Authorship of OpenCodex-generated routed catalog rows (#855, #7229). + * + * A generated routed row normally carries the description prefix below, and that prefix is what + * removal, template selection, auto-review and residue detection have always recognized. A combo + * may replace its row description (#7229), so such a row carries a stable marker instead. Rows + * with the generated description stay byte-identical and keep prefix recognition; only an exact + * known marker value confers authorship. + */ +export const ROUTED_DESCRIPTION_PREFIX = "Routed via opencodex → "; +export const ROUTED_ORIGIN_FIELD = "opencodex_routed_origin"; +export const COMBO_ROUTED_ORIGIN = "combo-v1"; +const COMBO_DESCRIPTION_PREFIX = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (`; + +function descriptionStartsWith(entry: RawEntry, prefix: string): boolean { + return typeof entry.description === "string" && entry.description.startsWith(prefix); +} + +/** True for a row whose description was overridden by a combo and stamped by OpenCodex. */ +export function hasComboRoutedOriginMarker(entry: RawEntry): boolean { + return entry[ROUTED_ORIGIN_FIELD] === COMBO_ROUTED_ORIGIN; +} + +/** Generated by OpenCodex routing: the stable marker, or the legacy generated description. */ +export function hasOcxRoutedAuthorship(entry: RawEntry): boolean { + return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, ROUTED_DESCRIPTION_PREFIX); +} + +/** Generated for a combo: the stable marker, or the legacy generated combo description. */ +export function hasOcxComboAuthorship(entry: RawEntry): boolean { + return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, COMBO_DESCRIPTION_PREFIX); +} + +/** The operator's combo description override, if this routed model has one. */ +export function comboDescriptionOverride(model: CatalogModel | undefined): string | undefined { + if (model?.provider !== COMBO_NAMESPACE) return undefined; + const description = typeof model.description === "string" ? model.description.trim() : ""; + return description || undefined; +} + +/** + * Set a routed row's description and its authorship marker together. A template clone may carry + * another row's marker, so the marker is always rewritten from this model's own override. + */ +export function applyRoutedDescription(entry: RawEntry, generated: string, model: CatalogModel | undefined): void { + const override = comboDescriptionOverride(model); + entry.description = override ?? generated; + if (override !== undefined) entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN; + else delete entry[ROUTED_ORIGIN_FIELD]; +} + +/** + * A combo row retained from the previous catalog (its fresh derivation failed, e.g. a member + * provider is temporarily unavailable) bypasses deriveEntry. Re-project the combo's CURRENT + * description onto it so clearing or changing the description in config still reaches the + * picker. A cleared override returns the row to the generated combo text without the marker; + * an unmarked row without a current override is left exactly as it was. + */ +export function reapplyRetainedComboDescription(entry: RawEntry, current: string | undefined): void { + const description = current?.trim(); + if (description) { + entry.description = description; + entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN; + } else if (hasComboRoutedOriginMarker(entry)) { + entry.description = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (${COMBO_NAMESPACE}).`; + delete entry[ROUTED_ORIGIN_FIELD]; + } +} diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 1db62301cd..f5d0a860f8 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -43,6 +43,7 @@ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREA import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { hasComboRoutedOriginMarker, hasOcxRoutedAuthorship } from "./ownership"; import { NATIVE_GPT6_ASTRA_MODEL } from "./native-models"; import { recordOwnedConfigPath } from "../../lib/config-ownership"; @@ -95,6 +96,12 @@ export interface CatalogModel { * which carries no CatalogModel, so a configured displayName can never override a native name. */ displayName?: string; + /** + * Display-only Codex catalog `description` override, honored for combo rows only (#7229). + * Unset keeps the generated "Routed via opencodex → …" text; a set value stamps the row with + * the ownership marker in ./ownership so removal and template selection still recognize it. + */ + description?: string; owned_by?: string; reasoningEfforts?: string[]; /** Exact CCA family evidence, retained through the in-memory discovery cache. */ @@ -297,7 +304,7 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null && "base_instructions" in m && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND && m.owned_by !== COMBO_NAMESPACE - && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), + && !hasOcxRoutedAuthorship(m), ) ?? null; } @@ -325,7 +332,7 @@ export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntr && "base_instructions" in m && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND && m.owned_by !== COMBO_NAMESPACE - && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), + && !hasOcxRoutedAuthorship(m), ) ?? null; } @@ -925,7 +932,9 @@ export function readCatalogBackup(catalogPath: string): RawCatalog | null { export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean { return (catalog?.models ?? []).some(m => typeof m.slug === "string" - && (m.slug.includes("/") || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND)); + && (m.slug.includes("/") + || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND + || hasComboRoutedOriginMarker(m))); } export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void { diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 1175d3525d..4994f28dca 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -60,7 +60,7 @@ import { isMultiAgentV2Enabled } from "../features"; import { clampCatalogModelsToCodexSupport } from "./effort"; import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints"; import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch"; -import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; +import { comboCatalogDescriptions, dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; import { withCatalogWriteSerialization, type CatalogWritePermit, @@ -530,6 +530,7 @@ function writeRetainedCatalogSync({ multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, + comboDescriptions: comboCatalogDescriptions(config), hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, diff --git a/src/codex/catalog/routed-removal.ts b/src/codex/catalog/routed-removal.ts index cdd1f4b0f2..642bb5f3ec 100644 --- a/src/codex/catalog/routed-removal.ts +++ b/src/codex/catalog/routed-removal.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../../types"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { isOcxAuthoredRoutedEntry } from "./build-entries"; import { isNativeAliasCatalogEntry } from "./metadata"; +import { hasOcxRoutedAuthorship } from "./ownership"; import type { RawCatalog, RawEntry } from "./parsing"; /** @@ -25,10 +26,9 @@ function routedNamespace(entry: RawEntry): string | null { if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null; if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE; // Combo aliases may be bare or use an unrelated provider namespace. Ownership selects the - // combo namespace only with the same generated-description authorship signal as routed rows. - if (entry.owned_by === COMBO_NAMESPACE - && typeof entry.description === "string" - && entry.description.startsWith("Routed via opencodex → ")) return COMBO_NAMESPACE; + // combo namespace only with the same authorship signal as routed rows (generated description or + // the #7229 marker a custom combo description carries). + if (entry.owned_by === COMBO_NAMESPACE && hasOcxRoutedAuthorship(entry)) return COMBO_NAMESPACE; // Foreign rows (Cursor, user tooling) are never removed by the provider rule. if (!isOcxAuthoredRoutedEntry(entry)) return null; return entry.slug.slice(0, entry.slug.indexOf("/")); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index b6e4979334..fc4de4a6b2 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -53,7 +53,7 @@ import { orderForSubagents, } from "./catalog/sync"; import { multiAgentV2EnabledFromConfigText } from "./features"; - import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation"; + import { comboCatalogDescriptions, enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation"; import { isNativeAliasCatalogEntry, accountBoundNativeOpenAiSlugs, @@ -392,6 +392,7 @@ function prepareCatalog( multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, + comboDescriptions: comboCatalogDescriptions(config), hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index c7898b1241..a8cd47f58f 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -17,6 +17,7 @@ import { Database } from "bun:sqlite"; import { getConfigDir } from "../config"; import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing"; +import { hasOcxRoutedAuthorship } from "./catalog/ownership"; import { codexHistoryBackupId, legacyCodexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest"; import { hasInjectedCodexRouting, @@ -79,7 +80,6 @@ const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH); const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH); const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH); const JOURNAL_FILE_NAME = "opencodex-journal.json"; -const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → "; const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024; const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024; // Bound one observation across every referenced rollout, not just each file. @@ -339,8 +339,7 @@ function classifyProfile(path: string): NativeRoutedResidueResult { } function isOcxRoutedCatalogEntry(entry: Record): boolean { - return typeof entry.description === "string" - && entry.description.startsWith(ROUTED_CATALOG_DESCRIPTION_PREFIX); + return hasOcxRoutedAuthorship(entry); } function classifyCatalogLike( diff --git a/src/combos/types.ts b/src/combos/types.ts index 2823301dd1..336a464476 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -68,6 +68,8 @@ export interface NormalizedComboConfig { nativeAlias: boolean; /** Display-only label for the catalog row, or null when unset. */ displayName: string | null; + /** Display-only catalog row description; absent keeps the generated text (#7229). */ + description?: string; /** JEV decision service provider id; absent means the canonical `jev` service. */ decisionProvider?: string; /** Ordinary inference route used for JEV decisions. */ @@ -277,6 +279,16 @@ export function comboConfigIssues( }); } } + if (body.description !== undefined) { + if (typeof body.description !== "string") { + issues.push({ path: ["description"], message: "description must be a string" }); + } else if (body.description.trim().length > 128 || /\p{Cc}/u.test(body.description)) { + issues.push({ + path: ["description"], + message: "description must be at most 128 characters and contain no control characters", + }); + } + } const alias = typeof body.alias === "string" ? body.alias.trim() : ""; const nativeAlias = body.nativeAlias === true; if (nativeAlias && !SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias)) { @@ -483,6 +495,7 @@ export function comboConfigError( export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig { const alias = typeof raw.alias === "string" ? raw.alias.trim() : ""; const displayName = typeof raw.displayName === "string" ? raw.displayName.trim() : ""; + const description = typeof raw.description === "string" ? raw.description.trim() : ""; const decisionProvider = typeof raw.decisionProvider === "string" ? raw.decisionProvider.trim() : ""; const decisionModel = typeof raw.decisionModel === "string" ? raw.decisionModel.trim() : ""; const defaultEffort = typeof raw.defaultEffort === "string" && isCodexReasoningEffort(raw.defaultEffort) @@ -502,6 +515,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig alias: alias || null, nativeAlias: raw.nativeAlias === true, displayName: displayName || null, + ...(description ? { description } : {}), // Explicit "jev" is the default and stays sparse. ...(decisionProvider && decisionProvider !== CANONICAL_JEV_DECISION_PROVIDER ? { decisionProvider } : {}), ...(decisionModel ? { decisionModel } : {}), diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 6f4979e007..c9537d9659 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -244,6 +244,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | +| `src/codex/catalog/ownership.ts` | [`catalog-routed-ownership.md`](catalog-routed-ownership.md) | | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | diff --git a/structure/catalog-routed-ownership.md b/structure/catalog-routed-ownership.md new file mode 100644 index 0000000000..07e31e7cad --- /dev/null +++ b/structure/catalog-routed-ownership.md @@ -0,0 +1,36 @@ +# Routed-row authorship and combo descriptions + +Catalog maintenance has to tell rows OpenCodex generated from rows Codex, Cursor or the user put +there: removal of a deleted provider's or combo's rows, baseline-shadow restoration, native +template selection, auto-review routed detection, exact-combo admission and native-residue +detection all ask that question. Historically every generated routed row answered it through its +description, which always starts with `Routed via opencodex → `. + +A combo may replace its own row description (#7229, `combos..description`, validated like +`displayName`: string, at most 128 characters after trimming, no control characters; empty +clears). Such a row carries `opencodex_routed_origin: "combo-v1"` instead of the generated +prefix. `src/codex/catalog/ownership.ts` owns both signals and is the only place they are read: +`hasOcxRoutedAuthorship` accepts the exact marker or the generic prefix, and +`hasOcxComboAuthorship` accepts the marker or the combo-specific prefix. Unknown marker values +confer nothing. Callers keep their own extra conditions (slash-qualified slug, `owned_by`, +native-alias kind, exact-combo membership); the predicates replace only the text test. + +`deriveEntry` sets the description and the marker together through `applyRoutedDescription`, +so a template clone never inherits another row's marker, and the Reserve native reconstruction +in `src/codex/catalog/metadata.ts` strips it. Only combo models are eligible; member +descriptions are never inherited. Rows without an override keep the generated text and no +marker, so their bytes do not change. The management API keeps a saved description when a +write omits it (the dashboard does not edit it yet), and `ocx combo set --description ` +sets or clears it. + +A combo row retained from the previous catalog because its fresh derivation failed (for example a +native-alias combo whose member provider is disabled) bypasses `deriveEntry`. Config-aware merges +(`src/codex/convergence.ts`, `src/codex/catalog/retained-sync.ts`) pass +`comboCatalogDescriptions(config)`, and `reapplyRetainedComboDescription` re-projects the current +value onto such rows: a set description replaces the old one with the marker, a cleared one returns +the generated combo text without it. Merges without config leave retained rows untouched. + +`tests/codex-integration/combo-description-catalog.test.ts` covers ordinary, bare and native +aliases, removal and shadow restoration, foreign and unknown-marker rows, template exclusion, +auto-review, residue detection and byte-idempotent sync. `tests/routing/combo-description.test.ts` +covers validation, management API set/clear/omission/rename, CLI receipts and config salvage. diff --git a/structure/manifest.json b/structure/manifest.json index 8d556d6d56..2c26f53980 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -203,6 +203,13 @@ "scope": "Persisted effort-clamp observation identity and idempotent sync notices.", "documents": ["src/codex/runtime.ts", "src/codex/catalog/effort.ts"] }, + { + "path": "catalog-routed-ownership.md", + "tier": 2, + "title": "Routed-row authorship", + "scope": "Generated routed-row ownership signals and combo catalog description overrides.", + "documents": ["src/codex/catalog/ownership.ts"] + }, { "path": "subagents.md", "tier": 2, diff --git a/tests/codex-integration/combo-description-catalog.test.ts b/tests/codex-integration/combo-description-catalog.test.ts new file mode 100644 index 0000000000..828b9bad5c --- /dev/null +++ b/tests/codex-integration/combo-description-catalog.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { normalizeComboConfig } from "../../src/combos"; +import { saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import { comboCatalogDescriptions, deriveComboCatalogModel } from "../../src/codex/catalog/aggregation"; +import { applyAutoReviewModelOverride } from "../../src/codex/catalog/auto-review"; +import { + buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + isOcxAuthoredRoutedEntry, mergeCatalogEntriesFromObservedState, + resetCatalogRuntimeStateForTests, type ObservedCatalogMergeInput, +} from "../../src/codex/catalog/build-entries"; +import { deriveEntry, finishUpstreamNativeEntry } from "../../src/codex/catalog/derive-entry"; +import { upstreamNativeEntry } from "../../src/codex/catalog/metadata"; +import { catalogHasRoutedEntries, findNativeTemplate, findSupportedNativeTemplate, + type CatalogModel, type RawEntry } from "../../src/codex/catalog/parsing"; +import { ocxRoutedNamespaceCounts } from "../../src/codex/catalog/routed-removal"; +import { syncCatalogModels } from "../../src/codex/catalog/retained-sync"; +import { hasOcxComboAuthorship, hasOcxRoutedAuthorship } from "../../src/codex/catalog/ownership"; +import { classifyNativeRoutedResidue } from "../../src/codex/native-residue"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import type { OcxComboConfig, OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const generated = "Routed via opencodex → combo (combo)."; +const marker = { opencodex_routed_origin: "combo-v1" }; +const targets = [{ provider: "fixture", model: "model" }]; +const member: CatalogModel = { provider: "fixture", id: "model", contextWindow: 128_000, + inputModalities: ["text"], reasoningEfforts: ["high"] }; +const native = (): RawEntry => ({ slug: "gpt-5.6-sol", display_name: "Fixture Sol", description: "Upstream Sol description", + priority: 9, visibility: "list", base_instructions: "You are Codex.", input_modalities: ["text"], + supported_reasoning_levels: [{ effort: "high", description: "High" }], default_reasoning_level: "high" }); +let home: TempHome; +let previousCli: string | undefined; +let network: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-combo-description-catalog-"); + mkdirSync(home.codexHome); + previousCli = process.env.CODEX_CLI_PATH; + network = spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("Network forbidden"); }); +}); +afterEach(() => { + expect(network).not.toHaveBeenCalled(); + network.mockRestore(); + if (previousCli === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCli; + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + home.remove(); +}); +function comboModel(extra: Partial = {}): CatalogModel { + const model = deriveComboCatalogModel("saved", normalizeComboConfig({ targets, ...extra }), [member]); + expect(model).not.toBeNull(); + return model!; +} +function build(model: CatalogModel, template: RawEntry | null = native()): RawEntry[] { + const slug = model.alias ?? "combo/saved"; + return buildCatalogEntriesFromObservedState({ + template, gptSlugs: ["gpt-5.6-sol"], goModels: [model], featured: [], + wsEnabled: false, multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set([slug]), accountSelectors: ["account"], + suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + }); +} +function merge(input: Pick & Partial): RawEntry[] { + return mergeCatalogEntriesFromObservedState({ + baselineCatalogModels: [], baseline: new Map(), featured: [], wsEnabled: false, + template: native(), disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(), degradedProviderNames: new Set(), legacyCustomModelSlugs: new Set(), + multiAgentMode: "default", multiAgentV2Enabled: false, exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, includeNativeOpenAi: false, accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "silent" }, ...input, + }); +} +function retain(row: RawEntry, comboDescriptions?: ReadonlyMap): RawEntry[] { + return merge({ catalogModels: [row], routedEntries: [], comboDescriptions, + exactComboSlugs: new Set([String(row.slug)]), hasPhysicalComboProvider: true, + gatheredProviderNames: new Set(["vendor"]), degradedProviderNames: new Set(["vendor"]) }); +} + +describe("retained combo descriptions (#7229)", () => { + const oldNativeAlias = () => build(comboModel({ alias: "gpt-5.6-sol", nativeAlias: true, description: "Old" })) + .find(row => row.slug === "gpt-5.6-sol")!; + + test("retained native alias replaces its stale marked description with the current override", () => { + const old = oldNativeAlias(); + expect(old).toMatchObject({ description: "Old", ...marker }); + const rows = retain(old, new Map([["gpt-5.6-sol", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "gpt-5.6-sol", description: "New", ...marker }); + expect(old.description).toBe("Old"); + }); + + test("clearing a retained native alias description restores generated text and removes its marker", () => { + const rows = retain(oldNativeAlias(), new Map()); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "gpt-5.6-sol", description: generated }); + expect(rows[0]).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("legacy merges without comboDescriptions preserve the retained description and marker", () => { + const old = oldNativeAlias(); + expect(retain(old)).toEqual([old]); + }); + + test("a retained slash alias with generated combo authorship gains the current description", () => { + const old = build(comboModel({ alias: "vendor/custom" })).find(row => row.slug === "vendor/custom")!; + expect(old).toMatchObject({ owned_by: "combo", description: generated }); + expect(old).not.toHaveProperty("opencodex_routed_origin"); + const rows = retain(old, new Map([["vendor/custom", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "vendor/custom", description: "New", ...marker }); + }); + + test("an exact combo slug and owned_by alone cannot rewrite a foreign retained row", () => { + const foreign = { slug: "vendor/custom", owned_by: "combo", description: "Another tool's description", + priority: 5, visibility: "list", input_modalities: ["text"] }; + expect(hasOcxComboAuthorship(foreign)).toBe(false); + const rows = retain(foreign, new Map([["vendor/custom", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject(foreign); + expect(rows[0]).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("config descriptions use public aliases, exclude disabled combos, and omit empty overrides", () => { + expect(comboCatalogDescriptions({ disabledModels: ["combo/disabled"], combos: { + saved: { targets, description: " Canonical " }, + aliased: { targets, alias: " vendor/custom ", description: " Alias " }, + disabled: { targets, alias: "vendor/disabled", description: "Hidden" }, + empty: { targets, description: "" }, blank: { targets, description: " " }, absent: { targets }, + } })).toEqual(new Map([["combo/saved", "Canonical"], ["vendor/custom", "Alias"]])); + }); +}); + +describe("described combo catalog rows (#7229)", () => { + for (const [label, identity] of [ + ["ordinary combo", {}], ["namespaced alias", { alias: "vendor/custom" }], + ["bare alias", { alias: "fast-coding" }], + ["native alias", { alias: "gpt-5.6-sol", nativeAlias: true, displayName: "My Sol combo" }], + ] as const) test(`${label} carries the override while account-native descriptions stay unchanged`, () => { + for (const template of [null, native()]) { + const cfg = { ...identity, description: " Code quickly " }; + const model = comboModel(cfg); + expect(model.description).toBe("Code quickly"); + const rows = build(model, template); + expect(rows.find(row => row.slug === (model.alias ?? "combo/saved"))).toMatchObject({ description: "Code quickly", ...marker }); + const account = rows.find(row => row.slug === "account/gpt-5.6-sol"); + const baseline = build(comboModel(identity), template).find(row => row.slug === "account/gpt-5.6-sol"); + expect(account).toBeDefined(); + expect(account?.description).toBe(baseline?.description); + expect(account).not.toHaveProperty("opencodex_routed_origin"); + } + }); + + test("unset and empty descriptions produce identical bytes with the generated description and no marker", () => { + const absent = comboModel(); + expect(absent).not.toHaveProperty("description"); + const rows = build(absent); + expect(rows.find(row => row.slug === "combo/saved")).toMatchObject({ description: generated }); + expect(rows.find(row => row.slug === "combo/saved")).not.toHaveProperty("opencodex_routed_origin"); + expect(JSON.stringify(build(comboModel({ description: "" })))).toBe(JSON.stringify(rows)); + expect(JSON.stringify(build(comboModel({ description: " " })))).toBe(JSON.stringify(rows)); + }); + + test("derivation strips a stale template marker and ignores non-combo description overrides", () => { + const template = { ...native(), ...marker }; + const without = deriveEntry(template, "combo/saved", generated, 5, comboModel(), new Set(["combo/saved"])); + expect(without.description).toBe(generated); + expect(without).not.toHaveProperty("opencodex_routed_origin"); + const provider = deriveEntry(template, "fixture/model", "Routed via opencodex → fixture (fixture).", 5, + { ...member, description: "Must not override providers" }); + expect(provider.description).toBe("Routed via opencodex → fixture (fixture)."); + expect(provider).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("removing a described combo removes its row and restores a pristine foreign baseline shadow", () => { + const rawPristine = { slug: "vendor/custom", description: "Foreign pristine description", display_name: "Foreign original", + input_modalities: ["text"], priority: 5, visibility: "list", custom_metadata: { preserved: true } }; + const pristine = merge({ catalogModels: [rawPristine], routedEntries: [] })[0]!; + const row = build(comboModel({ alias: "vendor/custom", description: "Combo replacement" })) + .find(entry => entry.slug === pristine.slug)!; + const installed = merge({ catalogModels: [pristine], baselineCatalogModels: [pristine], routedEntries: [row], + exactComboSlugs: new Set([pristine.slug]) }); + expect(installed.find(entry => entry.slug === pristine.slug)).toMatchObject({ description: "Combo replacement", ...marker }); + const restored = merge({ catalogModels: installed, baselineCatalogModels: [pristine], routedEntries: [] }); + expect(restored.filter(entry => entry.slug === pristine.slug)).toEqual([pristine]); + expect(restored.some(entry => entry.opencodex_routed_origin === "combo-v1")).toBe(false); + const ordinary = build(comboModel({ description: "Remove me" })).find(entry => entry.slug === "combo/saved")!; + expect(merge({ catalogModels: [ordinary], routedEntries: [] }).some(entry => entry.slug === "combo/saved")).toBe(false); + }); + + test("foreign arbitrary descriptions survive, and unknown marker values confer no ownership", () => { + const foreign = { slug: "foreign/model", description: "Another tool's custom description", priority: 5, visibility: "list" }; + for (const row of [foreign, { ...foreign, opencodex_routed_origin: "combo-v2" }]) { + expect(hasOcxRoutedAuthorship(row)).toBe(false); + expect(hasOcxComboAuthorship(row)).toBe(false); + expect(isOcxAuthoredRoutedEntry(row)).toBe(false); + expect(ocxRoutedNamespaceCounts({ models: [row] }).size).toBe(0); + const retained = merge({ catalogModels: [row], routedEntries: [] }); + expect(retained).toHaveLength(1); + expect(retained[0]).toMatchObject(row); + } + expect(catalogHasRoutedEntries({ models: [{ slug: "bare", description: "Foreign", opencodex_routed_origin: "combo-v2" }] })).toBe(false); + }); + + test("marked rows are excluded from native templates independently of owned_by or catalog kind", () => { + const marked = { ...native(), ...marker }; + const real = { ...native(), slug: "gpt-5.5" }; + for (const find of [findNativeTemplate, findSupportedNativeTemplate]) { + expect(find({ models: [marked] })).toBeNull(); + expect(find({ models: [marked, real] })).toBe(real); + expect(find({ models: [{ ...marked, opencodex_routed_origin: "unknown" }] })).not.toBeNull(); + } + }); + + test("marked combo ownership counts bare and namespaced aliases under combo and protects bare-only catalogs", () => { + const rows = ["combo/saved", "vendor/custom", "fast-coding"].map(slug => ({ slug, owned_by: "combo", description: "Custom", ...marker })); + expect(isOcxAuthoredRoutedEntry(rows[0]!)).toBe(true); + expect(isOcxAuthoredRoutedEntry(rows[1]!)).toBe(true); + expect(ocxRoutedNamespaceCounts({ models: rows })).toEqual(new Map([["combo", 3]])); + expect(catalogHasRoutedEntries({ models: [rows[2]!] })).toBe(true); + expect(hasOcxRoutedAuthorship({ slug: "combo/legacy", description: generated })).toBe(true); + expect(hasOcxComboAuthorship({ slug: "combo/legacy", description: generated })).toBe(true); + }); + + test("a described exact alias stays admitted through provider allowlist and pending namespace filters", () => { + const row = build(comboModel({ alias: "vendor/custom", description: "Exact combo" })).find(entry => entry.slug === "vendor/custom")!; + for (const pendingProviderNames of [new Set(), new Set(["vendor"])]) { + const rows = merge({ catalogModels: [], routedEntries: [row], exactComboSlugs: new Set(["vendor/custom"]), + selectedModelsByProvider: new Map([["vendor", new Set(["vendor/other"])]]), + gatheredProviderNames: new Set(["vendor"]), pendingProviderNames }); + expect(rows.filter(entry => entry.slug === "vendor/custom")).toEqual([row]); + } + }); + + test("auto-review classifies a bare marked row as routed when applying and clearing root overrides", () => { + const row = { slug: "fast-coding", description: "Custom", ...marker }; + const real = native(); + const rows: RawEntry[] = [row, real]; + expect(applyAutoReviewModelOverride(rows, "gpt-5.6-sol")).toBe("applied"); + expect(row).not.toHaveProperty("opencodex_auto_review_root"); + expect(real).toHaveProperty("opencodex_auto_review_root"); + expect(applyAutoReviewModelOverride(rows, undefined)).toBe("absent"); + expect(rows[0]?.auto_review_model_override).toBeNull(); + }); + + test("native residue detection recognizes a bare marked catalog through the public classifier", () => { + const path = join(home.codexHome, "custom-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + writeFileSync(path, JSON.stringify({ models: [{ slug: "fast-coding", description: "Custom", ...marker }] })); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "residue", surface: "catalog", path }); + writeFileSync(path, JSON.stringify({ models: [{ slug: "fast-coding", description: "Foreign", opencodex_routed_origin: "unknown" }] })); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + }); +}); + +function installCodexFixture(): string { + const catalog = { models: [finishUpstreamNativeEntry(upstreamNativeEntry("gpt-5.6-sol")!, 9)] }; + const script = home.path("fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', + `else process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + ].join("\n")); + const executable = home.path(process.platform === "win32" ? "fixture-codex.cmd" : "fixture-codex"); + if (process.platform === "win32") writeFileSync(executable, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + else { + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(executable, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(executable, 0o755); + } + process.env.CODEX_CLI_PATH = executable; + resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); resetCodexModelEntitlementCacheForTests(); + const path = join(home.codexHome, "custom-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + writeFileSync(path, JSON.stringify(catalog)); + return path; +} + +function fixtureConfig(): OcxConfig { + return { + port: 10100, defaultProvider: "fixture", subagentModels: [], subagentModelsVersion: SUBAGENT_MODELS_VERSION, + providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, + models: ["model"], modelContextWindows: { model: 128_000 }, modelInputModalities: { model: ["text"] }, + modelReasoningEfforts: { model: ["high"] } } }, + combos: { saved: { targets } }, + }; +} + +test("real sync is byte-idempotent with a description and after clearing it matches an absent field", async () => { + const path = installCodexFixture(); + const cfg = fixtureConfig(); + const sync = async () => { + saveConfig(cfg); + const result = await syncCatalogModels(cfg, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + expect(result.skippedReason).toBeUndefined(); + expect(result.path).toBe(path); + return readFileSync(path, "utf8"); + }; + const absent = await sync(); + expect(await sync()).toBe(absent); + cfg.combos!.saved!.description = "A custom catalog description"; + const described = await sync(); + expect(JSON.parse(described).models.find((row: RawEntry) => row.slug === "combo/saved")) + .toMatchObject({ description: "A custom catalog description", ...marker }); + expect(await sync()).toBe(described); + cfg.combos!.saved!.description = ""; + const cleared = await sync(); + expect(cleared).toBe(absent); + expect(JSON.parse(cleared).models.find((row: RawEntry) => row.slug === "combo/saved")) + .not.toHaveProperty("opencodex_routed_origin"); + expect(await sync()).toBe(cleared); +}, 30_000); + +test("real sync reprojects current config descriptions onto a native alias retained while its provider is disabled", async () => { + const path = installCodexFixture(); + const cfg = fixtureConfig(); + cfg.combos!.saved = { targets, alias: "gpt-5.6-sol", nativeAlias: true, + displayName: "Retained Sol combo", description: "Old" }; + const sync = async () => { + saveConfig(cfg); + const result = await syncCatalogModels(cfg, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + expect(result.path).toBe(path); + return (JSON.parse(readFileSync(path, "utf8")).models as RawEntry[]) + .find(row => row.slug === "gpt-5.6-sol"); + }; + expect(await sync()).toMatchObject({ description: "Old", ...marker }); + cfg.providers.fixture!.disabled = true; + cfg.combos!.saved!.description = "New"; + expect(comboCatalogDescriptions(cfg)).toEqual(new Map([["gpt-5.6-sol", "New"]])); + expect(await sync()).toMatchObject({ description: "New", ...marker }); + delete cfg.combos!.saved!.description; + expect(comboCatalogDescriptions(cfg).size).toBe(0); + const cleared = await sync(); + expect(cleared).toMatchObject({ description: generated }); + expect(cleared).not.toHaveProperty("opencodex_routed_origin"); +}, 30_000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b29ec798a9..eb8f478052 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", +{ "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/tests/routing/combo-description.test.ts b/tests/routing/combo-description.test.ts new file mode 100644 index 0000000000..28a3983c14 --- /dev/null +++ b/tests/routing/combo-description.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { comboConfigIssues, normalizeComboConfig } from "../../src/combos"; +import { getConfigPath, readConfigDiagnostics, saveConfig } from "../../src/config"; +import { handleComboCommand } from "../../src/cli/combo"; +import { printComboWriteResult } from "../../src/cli/combo-result"; +import type { RuntimeApiDeps } from "../../src/cli/runtime-api"; +import { handleComboRoutes } from "../../src/server/management/combo-routes"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const targets = [{ provider: "fixture", model: "model" }]; +const refresh = { status: "committed" as const, changed: false, degraded: false, notices: [] }; +let home: TempHome; +let token: string | undefined; +let output: ReturnType; +let errors: ReturnType; +let network: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-combo-description-"); + token = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + output = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); + network = spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("Network forbidden"); }); +}); +afterEach(() => { + expect(network).not.toHaveBeenCalled(); + output.mockRestore(); errors.mockRestore(); network.mockRestore(); + if (token === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = token; + home.remove(); +}); +function config(): OcxConfig { + return { + port: 10100, defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", models: ["model"], liveModels: false } }, + combos: { saved: { targets } }, + }; +} +async function api(config: OcxConfig, method: string, body?: unknown): Promise { + const req = new Request("http://localhost/api/combos", { + method, headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const response = await handleComboRoutes({ + req, url: new URL(req.url), config, deps: {}, version: "fixture", + trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => refresh, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} +async function listed(config: OcxConfig): Promise[]> { + const response = await api(config, "GET"); + expect(response.status).toBe(200); + return (await response.json()).combos; +} + +describe("combo description contract (#7229)", () => { + for (const [label, description] of [ + ["number", 42], ["null", null], ["129 characters", "x".repeat(129)], + ["C0", "before\u0000after"], ["newline", "before\nafter"], ["DEL", "before\u007fafter"], + ["C1 NEL", "before\u0085after"], ["C1 CSI", "before\u009bafter"], + ] as const) test(`rejects ${label} with the description path`, () => { + const issues = comboConfigIssues("saved", { targets, description }, config().providers); + expect(issues).toHaveLength(1); + expect(issues[0]?.path).toEqual(["description"]); + expect(issues[0]?.message).toContain("description must be"); + }); + test("accepts the trimmed 128-character boundary and normalizes sparsely", () => { + const description = "x".repeat(128); + expect(comboConfigIssues("saved", { targets, description: ` ${description} ` }, config().providers)).toEqual([]); + expect(normalizeComboConfig({ targets, description: " A useful combo " }).description).toBe("A useful combo"); + for (const description of [undefined, "", " "]) { + expect(normalizeComboConfig({ targets, description })).not.toHaveProperty("description"); + } + }); + + test("management set, GET, omission, rename and explicit clear preserve sparse disk state", async () => { + const cfg = config(); + saveConfig(cfg); + const set = await api(cfg, "PUT", { id: "saved", combo: { targets, description: " Fast coding route " } }); + expect(set.status).toBe(200); + expect(await set.json()).toMatchObject({ combo: { description: "Fast coding route" } }); + expect((await listed(cfg))[0]).toMatchObject({ id: "saved", description: "Fast coding route" }); + expect((await api(cfg, "PUT", { id: "saved", combo: { targets } })).status).toBe(200); + expect((await listed(cfg))[0]?.description).toBe("Fast coding route"); + expect((await api(cfg, "PUT", { id: "renamed", renameFrom: "saved", combo: { targets } })).status).toBe(200); + expect(cfg.combos).not.toHaveProperty("saved"); + expect((await listed(cfg))[0]).toMatchObject({ id: "renamed", description: "Fast coding route" }); + expect(readConfigDiagnostics().config.combos?.renamed?.description).toBe("Fast coding route"); + expect((await api(cfg, "PUT", { id: "renamed", combo: { targets, description: "" } })).status).toBe(200); + expect((await listed(cfg))[0]).not.toHaveProperty("description"); + expect(cfg.combos?.renamed).not.toHaveProperty("description"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).combos.renamed).not.toHaveProperty("description"); + }); + + test("invalid management descriptions return 400 and leave memory and disk intact", async () => { + const cfg = config(); + saveConfig(cfg); + const before = structuredClone(cfg); + const disk = readFileSync(getConfigPath(), "utf8"); + for (const description of [false, null, "x".repeat(129), "bad\u001ftext", "bad\u007ftext"]) { + const response = await api(cfg, "PUT", { id: "saved", combo: { targets, description } }); + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("description must be"); + expect(cfg).toEqual(before); + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + } + }); + + test("CLI --description and '-' round-trip through the real management handler and receipt", async () => { + const cfg = config(); + saveConfig(cfg); + const deps: RuntimeApiDeps = { + findLiveProxy: async () => ({ pid: null, port: 14000, source: "runtime" }), + fetchImpl: (async (input, init) => { + expect(new URL(String(input)).pathname).toBe("/api/combos"); + return api(cfg, init?.method ?? "GET", init?.body ? JSON.parse(String(init.body)) : undefined); + }) as typeof fetch, + }; + expect(await handleComboCommand(["set", "saved", "--description", "CLI description", "--json"], deps)).toBe(0); + expect(cfg.combos?.saved?.description).toBe("CLI description"); + const receipt = JSON.parse(String(output.mock.calls.at(-1)?.[0])); + expect(receipt.combo.description).toBe("CLI description"); + expect(printComboWriteResult(receipt, "saved", true)).toBe(0); + expect(() => printComboWriteResult({ ...receipt, combo: { ...receipt.combo, description: 1 } }, "saved", true)) + .toThrow("Invalid combo receipt"); + expect(await handleComboCommand(["set", "saved", "--description", "-", "--json"], deps)).toBe(0); + expect(cfg.combos?.saved).not.toHaveProperty("description"); + expect(JSON.parse(String(output.mock.calls.at(-1)?.[0])).combo).not.toHaveProperty("description"); + expect(readConfigDiagnostics().config.combos?.saved).not.toHaveProperty("description"); + }); + + test("config salvage drops the whole invalid combo just as it does an invalid displayName", () => { + for (const field of ["displayName", "description"] as const) { + const cfg = config(); + cfg.combos = { valid: { targets, description: "Keep this description" }, invalid: { targets, [field]: "x".repeat(129) } }; + writeFileSync(getConfigPath(), JSON.stringify(cfg)); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`combos.invalid.${field}`); + expect(diagnostics.config.combos).not.toHaveProperty("invalid"); + expect(diagnostics.config.combos?.valid?.description).toBe("Keep this description"); + expect(diagnostics.config.providers.fixture).toEqual(expect.objectContaining(cfg.providers.fixture!)); + } + }); + + test("a valid description survives save and reload without dropping sibling combos", () => { + const cfg = config(); + cfg.combos!.saved!.description = "A persisted description"; + cfg.combos!.other = { targets }; + saveConfig(cfg); + const first = readConfigDiagnostics(); + expect(first.source).toBe("file"); + expect(first.error).toBeNull(); + expect(first.config.combos?.saved?.description).toBe("A persisted description"); + saveConfig(first.config); + expect(readConfigDiagnostics().config.combos?.saved?.description).toBe("A persisted description"); + expect(readConfigDiagnostics().config.combos?.other).not.toHaveProperty("description"); + }); +}); From cb6a9a7ef1123941dfe6145ea6511969d7bac34e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 14:53:38 -0700 Subject: [PATCH 07/29] feat(responses): opt-in compression of outbound ChatGPT Responses request bodies (#7285) * feat(responses): opt-in compression of outbound ChatGPT Responses request bodies (#7247) Codex re-sends the full conversation every turn; over HTTP OpenCodex forwarded it to chatgpt.com identity-encoded. upstreamRequestCompression ("gzip" | "zstd", default off) compresses canonical codex responses/compact string bodies of 1 KiB-16 MiB at the final send in sendWithConnectionPolicy, sets Content-Encoding and drops stale Content-Length. WebSocket frames, other destinations, pre-coded or non-string bodies and codec failures keep today's representation. Compression is synchronous so no await separates dispatch admission from the physical send; the 16 MiB ceiling bounds the event-loop block. The setting is read per send from the live server config installed by setLiveStateStoreConfig. * test(responses): make request-compression assertions prove nesting, fallback and caller immutability Count codec calls, assert Buffer fallbacks with Buffer.isBuffer, snapshot caller headers independently, and route the nested case back through sendWithConnectionPolicy with a barely compressible body so a missing idempotence guard double-codes (driven red once). Also asserts the dispatch receipt precedes executor entry with compression active. * fix(config): refuse unknown upstreamRequestCompression values on live writes The schema keeps degrading a hand-edited unknown codec to identity, but validateConfigCandidate now rejects it so ocx config set and imports report the error instead of persisting success and dropping the value (Codex/CodeRabbit review on #7285). Docs name the CLI setter and the restart requirement. --- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 2 +- src/config/diagnostics.ts | 11 + src/config/schema/config-schema.ts | 3 + src/lib/state-store-registrations.ts | 4 + src/server/responses/fetch-helpers.ts | 15 +- src/server/responses/request-compression.ts | 98 ++++++ src/types/config.ts | 7 + structure/transports/byte-accounting.md | 4 + structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../responses-fetch-helpers-boundary.test.ts | 2 + .../responses-request-compression.test.ts | 302 ++++++++++++++++++ 13 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 src/server/responses/request-compression.ts create mode 100644 tests/responses/responses-request-compression.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e2bf7ee5b2..12ccbf51df 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -101,6 +101,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | | `maxUpstreamBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a serialized native Responses **passthrough** body. `0` or omitted disables it — no limit is inferred for any destination. When set, a built body above the ceiling is refused locally before the send: streaming turns receive a terminal `response.failed` / `context_length_exceeded` so the client compacts instead of resending, and non-streaming turns receive a `413` naming the size, the number of embedded `input_image` items, and roughly how many megabytes of image data they represent. Checked at every build and rebuild point, including OAuth-refresh replay and alternate-account retry. Translated adapter paths are not covered. There is deliberately no default: the only measured ceiling here belongs to the WebSocket transport, which already falls back to HTTP for oversized turns, so a default would refuse requests that currently succeed. Set it when your gateway has a known request-size limit and you would rather see an actionable local error than an opaque upstream failure. | | `maxInboundBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a decompressed **inbound** data-plane request body — the mirror of `maxUpstreamBodyBytes` above. `0` or omitted keeps the built-in 256 MiB default. Raise it when a large-context session can no longer compact: Codex replays the whole history to the compaction model, so on the 922k-token opt-in window the compaction request is itself the one that crosses the limit, and the session is stuck at the only operation that would have shrunk it. Clamped to 1 MiB–512 MiB. The ceiling is not negotiable: the reader materializes the body several times over (wire bytes, decoded bytes, the decoded string, and the parsed object graph), so peak memory is a multiple of whatever is admitted, and an unbounded value would be a memory exhaustion lever. Applies to `/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and `/v1/messages`. The listener's accept size is fixed when the proxy binds, so a change takes effect on restart. A body above the limit is refused locally with HTTP 413 and `code: "inbound_body_too_large"`, which is deliberately distinct from the `context_length_exceeded` 413 a provider size refusal produces. | +| `upstreamRequestCompression?` | `"off" \| "gzip" \| "zstd"` | `"off"` | Opt-in compression of native ChatGPT Responses HTTP request bodies. Codex re-sends the whole conversation on every turn, and when that turn goes over HTTP OpenCodex otherwise forwards it to `chatgpt.com` uncompressed; on a metered or long-distance egress that upload is the dominant cost. When set, string bodies from 1 KiB to 16 MiB sent to `https://chatgpt.com/backend-api/codex/responses` and `/compact` are compressed at the final send and labelled with `Content-Encoding`. WebSocket turns, every other destination, bodies that already declare a `Content-Encoding`, and bodies outside that size window are sent unchanged, and a codec failure falls back to the uncompressed send. `zstd` is the encoding Codex CLI itself uses with this backend and is the faster choice for image-heavy turns; `gzip` (level 1) is also available. In a local measurement, code-heavy history shrank about 2–4×, while embedded images barely shrink. Compression runs synchronously on the proxy's event loop, so each eligible send briefly delays other in-flight requests — on the order of 2–3 ms per MiB for text on a recent laptop CPU, more for incompressible data with `gzip`. Byte limits such as `maxUpstreamBodyBytes` still measure the uncompressed body. Set it with `ocx config set upstreamRequestCompression zstd` (an unknown value is refused) or by editing `config.json`, where an unknown value is ignored, then restart the proxy to apply. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints all ask for `ttl: "1h"` is treated as `"long"`; a request that mixes 1h and 5m breakpoints keeps the 5-minute default, and an explicit value always wins. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4949715da9..ca9decdee3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1535,7 +1535,7 @@ "responses-custom-tool-repair.test.ts": "responses", "responses-custom-tool-stream-consistency.test.ts": "responses", "responses-default-namespace-emit-normalize.test.ts": "responses", - "responses-fetch-helpers-boundary.test.ts": "responses", "responses-run-turn-key-pacing.test.ts": "responses", + "responses-fetch-helpers-boundary.test.ts": "responses", "responses-run-turn-key-pacing.test.ts": "responses", "responses-request-compression.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index bde0840784..eedb61adf9 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -267,6 +267,16 @@ function upstreamHostCircuitThresholdError(value: unknown): string | null { return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; } +// The schema degrades an unknown codec to identity so a hand edit cannot break startup; a live write +// or import must instead be refused, or it would report success and then drop the value (#7247). +function upstreamRequestCompressionError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "upstreamRequestCompression")) return null; + const mode = raw.upstreamRequestCompression; + if (mode === undefined || mode === "off" || mode === "gzip" || mode === "zstd") return null; + return "schema_invalid: upstreamRequestCompression: must be \"off\", \"gzip\" or \"zstd\""; +} + function plaintextV2AgentMessagesError(value: unknown): string | null { return malformedPlaintextV2AgentMessagesWarning(value) ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted" @@ -726,6 +736,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) ?? upstreamHostCircuitThresholdError(value) + ?? upstreamRequestCompressionError(value) ?? plaintextV2AgentMessagesError(value) ?? agentTaskRecoveryError(value) ?? quotaResetNotifyError(value) diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 8b3939f4d6..9975aa3106 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -163,6 +163,9 @@ export const configSchema = z.object({ .min(0) .optional() .catch(undefined), + // Opt-in outbound request compression (#7247). An unknown codec degrades to identity, matching + // the guards above: a hand-edit typo must not change what the proxy sends or refuse traffic. + upstreamRequestCompression: z.enum(["off", "gzip", "zstd"]).optional().catch(undefined), // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default // rather than failing the parse, matching the outbound guard above: a malformed number must // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index d6aee4d2be..97df70ed6c 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -10,6 +10,7 @@ import { reconcileModelCacheGeneration } from "../codex/model-cache"; import { reconcilePoolRotationState } from "../codex/pool-rotation"; import { reconcileCodexQuotaAccounts } from "../codex/quota"; import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery"; +import { bindUpstreamRequestCompressionConfig } from "../server/responses/request-compression"; import { listLiveCodexAccountIds, reconcileCodexRoutingHealth, @@ -59,6 +60,9 @@ let liveServerConfig: OcxConfig | null = null; export function setLiveStateStoreConfig(config: OcxConfig): void { liveServerConfig = config; + // The one place the server's live config object is installed; detached configs (catalog sync) + // never come through here, so the outbound compression opt-in follows the running server only. + bindUpstreamRequestCompressionConfig(config); } export function reconcileLiveStateStores() { diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 3dd4568dba..0d6edb60de 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -31,6 +31,7 @@ import { } from "../../lib/provider-egress"; import { redactSecretString } from "../../lib/redact"; import { outboundCredentials } from "../../lib/outbound-credential-registry"; +import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression"; export { withUpstreamHttpVersion }; @@ -206,10 +207,18 @@ export function sendWithConnectionPolicy( // Bun can reset large native Codex string uploads before receiving headers while // the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so // WebSocket selection still sees the serialized string and no retry is added. + // Opt-in compression (#7247) takes precedence at the same boundary and also yields bytes. Every + // retry and HTTP fallback re-enters here with the caller's original string, so each send derives + // its own coded bytes; `headers` is already this send's private copy. const target = input instanceof Request ? input.url : String(input); const body = init?.body; - const largeCodexBody = typeof body === "string" - && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target) + const compressed = compressUpstreamRequestBody(target, body, headers); + if (compressed) { + headers.set("Content-Encoding", compressed.encoding); + headers.delete("Content-Length"); + } + const largeCodexBody = !compressed && typeof body === "string" + && isCanonicalCodexResponsesUrl(target) && Buffer.byteLength(body, "utf8") >= 1024 * 1024; (init as Record void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.(); // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the @@ -217,7 +226,7 @@ export function sendWithConnectionPolicy( outboundCredentials().remember(headers); return physicalFetch(input, { ...init, - ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}), + ...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}), headers, redirect: "manual", ...(fresh ? { keepalive: false } : {}), diff --git a/src/server/responses/request-compression.ts b/src/server/responses/request-compression.ts new file mode 100644 index 0000000000..312555b3b4 --- /dev/null +++ b/src/server/responses/request-compression.ts @@ -0,0 +1,98 @@ +import { Buffer } from "node:buffer"; +import { constants, gzipSync, zstdCompressSync } from "node:zlib"; +import type { OcxConfig } from "../../types"; + +/** + * Opt-in compression of outbound native ChatGPT Responses HTTP request bodies (#7247). + * + * Codex re-sends the whole conversation on every turn. When that turn travels over HTTP the body + * reaches `chatgpt.com` identity-encoded, which on a metered egress is the dominant cost of + * running Codex through the proxy. The backend already accepts coded request bodies: Codex CLI + * itself sends `content-encoding: zstd` there (see `src/server/request-decompress.ts`). + * + * Applied only at the final HTTP send (`sendWithConnectionPolicy`), after the WebSocket decision + * and plugin rewrites, so WebSocket frames and every other destination are untouched. + * + * Compression is synchronous on purpose. An await at the physical-send boundary would open a gap + * between dispatch admission (credential selection, attempt receipts, live reserve policy) and the + * send itself. The cost is bounded instead: bodies above {@link MAX_COMPRESSIBLE_BODY_BYTES} are sent + * as they are, which keeps the event-loop block to tens of milliseconds on ordinary hardware. + */ +export type UpstreamRequestCompression = "off" | "gzip" | "zstd"; + +/** Below this the header overhead outweighs any saving. */ +export const MIN_COMPRESSIBLE_BODY_BYTES = 1024; +/** Above this the synchronous compression block is no longer negligible; send identity. */ +export const MAX_COMPRESSIBLE_BODY_BYTES = 16 * 1024 * 1024; + +const CANONICAL_CODEX_RESPONSES = /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/; + +/** True for the native ChatGPT Responses and compact endpoints, the only compressed destinations. */ +export function isCanonicalCodexResponsesUrl(url: string): boolean { + return CANONICAL_CODEX_RESPONSES.test(url); +} + +/** Anything other than an explicit codec, including a malformed hand edit, means off. */ +export function resolveUpstreamRequestCompression(value: unknown): UpstreamRequestCompression { + return value === "gzip" || value === "zstd" ? value : "off"; +} + +// The live server config object, installed once at startup by `setLiveStateStoreConfig`. Live +// reconciliation mutates that object in place, so the field is read per send rather than copied. +let boundConfig: Pick | null = null; + +export function bindUpstreamRequestCompressionConfig(config: Pick | null): void { + boundConfig = config; +} + +export function currentUpstreamRequestCompression(): UpstreamRequestCompression { + return resolveUpstreamRequestCompression(boundConfig?.upstreamRequestCompression); +} + +// ArrayBuffer-backed, which is what node:zlib returns and what a fetch BodyInit accepts. +type CodedBytes = Buffer; +type Compressor = (mode: Exclude, bytes: Buffer) => CodedBytes; + +const defaultCompressor: Compressor = (mode, bytes) => mode === "gzip" + ? gzipSync(bytes, { level: 1 }) as CodedBytes + : zstdCompressSync(bytes, { params: { [constants.ZSTD_c_compressionLevel]: 3 } }) as CodedBytes; + +let compressor: Compressor = defaultCompressor; +let maxBodyBytes = MAX_COMPRESSIBLE_BODY_BYTES; + +/** Test seam: replace the codec (failure injection) and the size ceiling; null restores defaults. */ +export function setUpstreamRequestCompressionForTests(next: { compressor?: Compressor | null; maxBodyBytes?: number | null }): void { + if (next.compressor !== undefined) compressor = next.compressor ?? defaultCompressor; + if (next.maxBodyBytes !== undefined) maxBodyBytes = next.maxBodyBytes ?? MAX_COMPRESSIBLE_BODY_BYTES; +} + +export interface CompressedUpstreamBody { + body: CodedBytes; + encoding: Exclude; +} + +/** + * Compress one final HTTP send body when the operator opted in and the send is eligible. + * + * Returns null — send unchanged — for any other destination, a non-string body (an already coded + * buffer or a stream, which also makes nested passes idempotent), a request that already declares + * a `content-encoding`, a body outside the size window, or a codec failure. A failure never fails + * the request: identity is what the proxy sent before this option existed. + */ +export function compressUpstreamRequestBody( + target: string, + body: unknown, + headers: Headers, + mode: UpstreamRequestCompression = currentUpstreamRequestCompression(), +): CompressedUpstreamBody | null { + if (mode === "off" || typeof body !== "string" || !isCanonicalCodexResponsesUrl(target)) return null; + if (headers.has("content-encoding")) return null; + const size = Buffer.byteLength(body, "utf8"); + if (size < MIN_COMPRESSIBLE_BODY_BYTES || size > maxBodyBytes) return null; + try { + return { body: compressor(mode, Buffer.from(body, "utf8")), encoding: mode }; + } catch { + // Deliberately silent: the error could only describe the body, and identity is still correct. + return null; + } +} diff --git a/src/types/config.ts b/src/types/config.ts index 21b6033e4f..cc7e634652 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1293,6 +1293,13 @@ export interface OcxConfig { * that work today — on Azure and custom Responses gateways as well, whose limits are unknown. */ maxUpstreamBodyBytes?: number; + /** + * Opt-in compression of native ChatGPT Responses HTTP request bodies (#7247): "gzip" or "zstd". + * Omitted, "off", or any other value = identity, the default. Applies only at the final HTTP send + * to chatgpt.com Codex responses/compact, to string bodies from 1 KiB to 16 MiB; WebSocket turns + * and every other destination are unchanged. Compression runs synchronously on the event loop. + */ + upstreamRequestCompression?: "off" | "gzip" | "zstd"; /** * Opt-in ceiling, in bytes, on a decompressed INBOUND data-plane request body (#3573). * diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 0c39fc5ff9..2c11779f71 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -56,6 +56,10 @@ The final native ChatGPT Responses HTTP send in `src/server/responses/fetch-help strings of at least 1 MiB as a UTF-8 buffer for Bun upload compatibility. This is a transport copy, not a counting allocation or retained continuation. Existing body admission limits still apply; the serialization observation keeps its existing lifetime, and nested dispatch reuses the buffer. +With the opt-in `upstreamRequestCompression`, an eligible body (1 KiB–16 MiB) is instead encoded and +compressed there by `src/server/responses/request-compression.ts`; the UTF-8 input and coded output +overlap briefly for that one send and neither is retained. Admission limits and every byte observation +still measure the uncompressed body. ## Raised HTTP body admission diff --git a/structure/transports/responses.md b/structure/transports/responses.md index dd674c1d7b..deec7280d1 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -49,7 +49,7 @@ keep-alive reuse with `Connection: close` and `keepalive: false`; exact hosts an match case-insensitively. `sendWithConnectionPolicy` applies the policy around the fetch that performs the physical send, after a dispatch override has selected or rebuilt the destination, so matching follows the URL sent on the wire rather than the URL supplied before credential -revalidation; prepaid initial-send receipts follow the [spend contract](responses-spend.md#prepaid-initial-sends). At this final HTTP boundary, native ChatGPT Responses and compact JSON strings of at least 1 MiB (UTF-8) become byte buffers to avoid Bun's large-string upload resets. Content, headers, abort signals and retry policy are preserved; WebSocket selection still receives the original string. Other destinations, small strings and existing byte/stream bodies retain their representation. +revalidation; prepaid initial-send receipts follow the [spend contract](responses-spend.md#prepaid-initial-sends). At this final HTTP boundary, native ChatGPT Responses and compact JSON strings of at least 1 MiB (UTF-8) become byte buffers to avoid Bun's large-string upload resets. Content, headers, abort signals and retry policy are preserved; WebSocket selection still receives the original string. Other destinations, small strings and existing byte/stream bodies retain their representation. The one exception is the opt-in `upstreamRequestCompression` (`"gzip"` / `"zstd"`, #7247), owned by `src/server/responses/request-compression.ts`. When set, the same canonical destinations' string bodies from 1 KiB to 16 MiB are compressed at this boundary instead: the send's private header copy gains `Content-Encoding` and loses any stale `Content-Length`, and the body becomes the coded bytes. A body that already declares a coding, a non-string body (so a nested pass never codes twice), any other destination, a plugin rewrite away from `chatgpt.com`, and a codec failure all fall through to the representation above. Compression is synchronous so no await separates dispatch admission and the receipt from the physical send; the 16 MiB ceiling bounds the event-loop block. The setting is read per send from the live server config object, which `setLiveStateStoreConfig` installs at startup; detached configs never bind it. Direct-fetch sends that bypass this boundary (`agent-task-recovery.ts`, `codex/warmup.ts`) stay uncompressed. Opt-in per-key pacing and round-robin use HTTP/SSE so their final dispatch override cannot be bypassed by a WebSocket upgrade. Transport-owned response markers and stage metadata survive owned pacing body rewraps. Replacement-key pacing admission retains client cancellation but pauses the header deadline; the full header interval restarts after admission. The wrapped executor alone is not that boundary. An override that revalidates credentials re-reads `route.provider.fetch` at send time, because reselection can install a different provider transport diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index eb8f478052..67e74a4965 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1066,7 +1066,7 @@ "responses-custom-tool-stream-consistency.test.ts": "responses", "responses-default-namespace-emit-normalize.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", - "responses-run-turn-key-pacing.test.ts": "responses", + "responses-run-turn-key-pacing.test.ts": "responses", "responses-request-compression.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", "responses-forward-posit-continuation.test.ts": "responses", "responses-forward-prompt-envelope.test.ts": "responses", "responses-freeform-wrapper-keys.test.ts": "responses", "responses-function-tool-repair.test.ts": "responses", diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 6e10829342..c048cf6063 100644 --- a/tests/responses/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses/responses-fetch-helpers-boundary.test.ts @@ -190,6 +190,8 @@ describe("Responses fetch-helper import boundary", () => { // Import-free plugin rewrite slot (src/plugins/upstream-hooks.ts). "../../plugins/upstream-hooks", "../../providers/request-pacing", + // Opt-in request-body codec at the final send (#7247); runtime imports are node: built-ins only. + "./request-compression", "./ws-upstream", ]); }); diff --git a/tests/responses/responses-request-compression.test.ts b/tests/responses/responses-request-compression.test.ts new file mode 100644 index 0000000000..aebecd9d4c --- /dev/null +++ b/tests/responses/responses-request-compression.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { gunzipSync, gzipSync, zstdDecompressSync } from "node:zlib"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { applyProxyEnvWith } from "../../src/config/proxy-env"; +import { setLiveStateStoreConfig } from "../../src/lib/state-store-registrations"; +import { OutboundCredentialRegistry, setOutboundCredentialRegistryForTests } from "../../src/lib/outbound-credential-registry"; +import { registerUpstreamRewriter, resetUpstreamRewritersForTests } from "../../src/plugins/upstream-hooks"; +import { providerFetch, sendWithConnectionPolicy } from "../../src/server/responses/fetch-helpers"; +import { + bindUpstreamRequestCompressionConfig, + compressUpstreamRequestBody, + currentUpstreamRequestCompression, + resolveUpstreamRequestCompression, + setUpstreamRequestCompressionForTests, +} from "../../src/server/responses/request-compression"; +import type { OcxConfig } from "../../src/types"; + +const endpoint = "https://chatgpt.com/backend-api/codex/responses"; +type Mode = "off" | "gzip" | "zstd"; + +function bind(mode: Mode | undefined): { upstreamRequestCompression?: Mode } { + const config = mode === undefined ? {} : { upstreamRequestCompression: mode }; + bindUpstreamRequestCompressionConfig(config); + return config; +} + +function captureFetch() { + const calls: { input: Parameters[0]; init?: RequestInit }[] = []; + const execute = (async (input, init) => { + calls.push({ input, init }); + return new Response("ok"); + }) as typeof fetch; + return { calls, execute }; +} + +function sentHeaders(init?: RequestInit): Headers { + return new Headers(init?.headers); +} + +function decode(encoding: string | null, body: unknown): string { + const bytes = Buffer.from(body as Uint8Array); + if (encoding === "gzip") return gunzipSync(bytes).toString("utf8"); + if (encoding === "zstd") return zstdDecompressSync(bytes).toString("utf8"); + throw new Error(`unexpected encoding ${encoding}`); +} + +const json = (chars: number) => JSON.stringify({ input: "a".repeat(chars) }); + +afterEach(() => { + bindUpstreamRequestCompressionConfig(null); + setUpstreamRequestCompressionForTests({ compressor: null, maxBodyBytes: null }); + resetUpstreamRewritersForTests(); +}); + +describe("upstreamRequestCompression config", () => { + test("only an explicit codec enables it; a malformed hand edit parses to identity", () => { + expect(resolveUpstreamRequestCompression(undefined)).toBe("off"); + expect(resolveUpstreamRequestCompression("brotli")).toBe("off"); + expect(resolveUpstreamRequestCompression("zstd")).toBe("zstd"); + const field = configSchema.shape.upstreamRequestCompression; + expect(field.parse("brotli")).toBeUndefined(); + expect(field.parse(7)).toBeUndefined(); + expect(field.parse("gzip")).toBe("gzip"); + expect(field.parse(undefined)).toBeUndefined(); + }); + + test("live writes and imports refuse an unknown codec instead of silently dropping it", () => { + for (const value of ["off", "gzip", "zstd", undefined]) { + expect(validateConfigCandidate({ ...getDefaultConfig(), upstreamRequestCompression: value as never }).ok).toBe(true); + } + for (const value of ["brotli", "GZIP", 1, null, true]) { + const result = validateConfigCandidate({ ...getDefaultConfig(), upstreamRequestCompression: value as never }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("upstreamRequestCompression"); + } + }); + + test("the running server's config object is installed at startup and read per send", async () => { + const config = { providers: {} } as unknown as OcxConfig; + setLiveStateStoreConfig(config); + const capture = captureFetch(); + const body = json(4096); + + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + expect(capture.calls[0]!.init?.body).toBe(body); + + // Live reconciliation mutates this same object; no rebinding happens or is needed. + config.upstreamRequestCompression = "zstd"; + // A detached config (catalog sync calls applyProxyEnv with one) must not take the binding over. + applyProxyEnvWith({ upstreamRequestCompression: "off", providers: {} } as unknown as OcxConfig); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + expect(sentHeaders(capture.calls[1]!.init).get("content-encoding")).toBe("zstd"); + expect(decode("zstd", capture.calls[1]!.init?.body)).toBe(body); + expect(currentUpstreamRequestCompression()).toBe("zstd"); + }); +}); + +describe("final HTTP send compression", () => { + test("off or omitted sends exactly what it sent before, including the large-body UTF-8 buffer", async () => { + for (const mode of [undefined, "off"] as const) { + bind(mode); + const capture = captureFetch(); + const small = json(4096); + const large = JSON.stringify({ input: "é".repeat(600_000) }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body: small }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body: large }); + expect(capture.calls[0]!.init?.body).toBe(small); + expect(sentHeaders(capture.calls[0]!.init).has("content-encoding")).toBe(false); + const sentLarge = capture.calls[1]!.init?.body; + expect(Buffer.isBuffer(sentLarge)).toBe(true); + expect((sentLarge as Buffer).equals(Buffer.from(large, "utf8"))).toBe(true); + } + }); + + for (const mode of ["gzip", "zstd"] as const) { + test(`${mode} codes responses and compact bodies without touching the caller's init`, async () => { + bind(mode); + const capture = captureFetch(); + const body = JSON.stringify({ input: "é🦊 history ".repeat(2000) }); + const headers = { "content-type": "application/json", "content-length": "999", authorization: "Bearer test-token" }; + const headersBefore = structuredClone(headers); + const signal = new AbortController().signal; + const init: RequestInit = { method: "POST", body, headers, signal }; + + await sendWithConnectionPolicy(capture.execute, endpoint, init); + await sendWithConnectionPolicy(capture.execute, `${endpoint}/compact`, init); + + for (const call of capture.calls) { + const sent = sentHeaders(call.init); + expect(sent.get("content-encoding")).toBe(mode); + expect(sent.has("content-length")).toBe(false); + expect(sent.get("authorization")).toBe("Bearer test-token"); + expect(sent.get("content-type")).toBe("application/json"); + expect(call.init?.signal).toBe(signal); + expect(decode(mode, call.init?.body)).toBe(body); + } + expect(init.body).toBe(body); + expect(init.headers).toBe(headers); + expect(headers).toEqual(headersBefore); + }); + } + + test("the 1 KiB threshold counts UTF-8 bytes, not code units", async () => { + bind("gzip"); + const capture = captureFetch(); + // "é" is two UTF-8 bytes: 510 of them plus the 4-byte wrapper is 1024 bytes in 514 code units. + const atThreshold = `["${"é".repeat(510)}"]`; + const belowThreshold = `["${"é".repeat(509)}a"]`; + expect(Buffer.byteLength(atThreshold, "utf8")).toBe(1024); + expect(Buffer.byteLength(belowThreshold, "utf8")).toBe(1023); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body: atThreshold }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body: belowThreshold }); + expect(sentHeaders(capture.calls[0]!.init).get("content-encoding")).toBe("gzip"); + expect(capture.calls[1]!.init?.body).toBe(belowThreshold); + }); + + test("ineligible sends keep their representation", async () => { + bind("zstd"); + const capture = captureFetch(); + const body = json(4096); + const bytes = new TextEncoder().encode(body); + await sendWithConnectionPolicy(capture.execute, "https://api.openai.com/v1/responses", { method: "POST", body }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body, headers: { "content-encoding": "gzip" } }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body: bytes }); + setUpstreamRequestCompressionForTests({ maxBodyBytes: 2048 }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + + expect(capture.calls[0]!.init?.body).toBe(body); + expect(sentHeaders(capture.calls[0]!.init).has("content-encoding")).toBe(false); + expect(capture.calls[1]!.init?.body).toBe(body); + expect(sentHeaders(capture.calls[1]!.init).get("content-encoding")).toBe("gzip"); + expect(capture.calls[2]!.init?.body).toBe(bytes); + expect(capture.calls[3]!.init?.body).toBe(body); + expect(sentHeaders(capture.calls[3]!.init).has("content-encoding")).toBe(false); + }); + + test("a Request input with its own coding or body is left alone", async () => { + bind("gzip"); + const capture = captureFetch(); + const body = json(4096); + const coded = new Request(endpoint, { method: "POST", headers: { "content-encoding": "zstd" } }); + await sendWithConnectionPolicy(capture.execute, coded, { method: "POST", body }); + const owned = new Request(endpoint, { method: "POST", body }); + await sendWithConnectionPolicy(capture.execute, owned); + expect(capture.calls[0]!.init?.body).toBe(body); + expect(sentHeaders(capture.calls[0]!.init).get("content-encoding")).toBe("zstd"); + expect(capture.calls[1]!.init?.body).toBeUndefined(); + expect(sentHeaders(capture.calls[1]!.init).has("content-encoding")).toBe(false); + }); + + test("a nested pass and a rebuild compress once, receipt first, and only toward the canonical destination", async () => { + bind("gzip"); + let compressions = 0; + setUpstreamRequestCompressionForTests({ compressor: (_mode, bytes) => { compressions++; return gzipSync(bytes) as Buffer; } }); + const events: string[] = []; + const capture = captureFetch(); + const recording = (async (input, init) => { events.push("executor"); return capture.execute(input, init); }) as typeof fetch; + // Barely compressible, so the coded output stays above the threshold and a second pass would code it again. + const body = JSON.stringify({ input: randomBytes(8192).toString("hex") }); + const toCanonical = providerFetch({ adapter: "openai-responses", baseUrl: "https://gateway.example/v1", fetch: recording }, undefined, { + httpOnly: true, + onPhysicalDispatch: () => events.push("receipt"), + // Hands the send back through the boundary a second time, as a credential-revalidating override does. + dispatchOverride: (_input, init, execute) => sendWithConnectionPolicy(execute, new URL(`${endpoint}/compact`), init), + }); + await toCanonical("https://gateway.example/v1/responses", { method: "POST", body }); + expect(compressions).toBe(1); + expect(events).toEqual(["receipt", "executor"]); + const awayFromCanonical = providerFetch({ adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", fetch: capture.execute }, undefined, { + httpOnly: true, + dispatchOverride: (_input, init, execute) => execute("https://gateway.example/v1/responses", init), + }); + await awayFromCanonical(endpoint, { method: "POST", body }); + expect(compressions).toBe(1); + + expect(capture.calls).toHaveLength(2); + expect(sentHeaders(capture.calls[0]!.init).get("content-encoding")).toBe("gzip"); + expect(decode("gzip", capture.calls[0]!.init?.body)).toBe(body); + expect(capture.calls[1]!.init?.body).toBe(body); + expect(sentHeaders(capture.calls[1]!.init).has("content-encoding")).toBe(false); + }); + + test("a plugin rewrite away from chatgpt.com sends identity", async () => { + bind("zstd"); + const capture = captureFetch(); + const body = json(4096); + registerUpstreamRewriter("relay", target => { target.url = target.url.replace("https://chatgpt.com", "https://relay.example.net"); }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + resetUpstreamRewritersForTests(); + registerUpstreamRewriter("sidecar", target => { target.url = target.url.replace("https://chatgpt.com", "http://127.0.0.1:8787"); }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + for (const call of capture.calls) { + expect(call.init?.body).toBe(body); + expect(sentHeaders(call.init).has("content-encoding")).toBe(false); + } + }); + + test("every retry derives its own coded bytes from the original string", async () => { + bind("zstd"); + const capture = captureFetch(); + const body = json(4096); + const init: RequestInit = { method: "POST", body }; + await sendWithConnectionPolicy(capture.execute, endpoint, init); + await sendWithConnectionPolicy(capture.execute, endpoint, init); + expect(capture.calls).toHaveLength(2); + expect(capture.calls[0]!.init?.body).not.toBe(capture.calls[1]!.init?.body); + for (const call of capture.calls) expect(decode("zstd", call.init?.body)).toBe(body); + }); + + test("a codec failure falls back to the identity send, silently, keeping the large-body buffer", async () => { + bind("gzip"); + let attempts = 0; + setUpstreamRequestCompressionForTests({ compressor: () => { attempts++; throw new Error("codec exploded"); } }); + const errors = spyOn(console, "error"); + const warnings = spyOn(console, "warn"); + try { + const capture = captureFetch(); + const body = JSON.stringify({ input: "x".repeat(1024 * 1024) }); + await sendWithConnectionPolicy(capture.execute, endpoint, { method: "POST", body }); + expect(capture.calls).toHaveLength(1); + expect(sentHeaders(capture.calls[0]!.init).has("content-encoding")).toBe(false); + expect(attempts).toBe(1); + const sent = capture.calls[0]!.init?.body; + expect(Buffer.isBuffer(sent)).toBe(true); + expect((sent as Buffer).equals(Buffer.from(body, "utf8"))).toBe(true); + expect(errors).not.toHaveBeenCalled(); + expect(warnings).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + warnings.mockRestore(); + } + }); + + test("the final coded headers still reach the outbound credential registry", async () => { + bind("gzip"); + const remembered: Headers[] = []; + class Capturing extends OutboundCredentialRegistry { + override remember(headers: Headers): void { + remembered.push(new Headers(headers)); + super.remember(headers); + } + } + const restore = setOutboundCredentialRegistryForTests(new Capturing()); + try { + const capture = captureFetch(); + await sendWithConnectionPolicy(capture.execute, endpoint, { + method: "POST", body: json(4096), headers: { authorization: "Bearer test-token" }, + }); + expect(remembered).toHaveLength(1); + expect(remembered[0]!.get("content-encoding")).toBe("gzip"); + expect(remembered[0]!.get("authorization")).toBe("Bearer test-token"); + } finally { + restore(); + } + }); + + test("compressUpstreamRequestBody reports nothing for off", () => { + expect(compressUpstreamRequestBody(endpoint, json(4096), new Headers(), "off")).toBeNull(); + }); +}); From 12760bb0e1d366caa251b7c7716d3056b561da1c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 15:25:51 -0700 Subject: [PATCH 08/29] feat(accounts): auto-pause repeated permanent account refusals (#7153) * feat(accounts): opt in to auto-pause permanent account refusals * fix(accounts): register pause route and extracted Responses owners * fix(accounts): inject refusal observer across transport leaf boundary * Preserve pause ownership through distributed revisions and pacing rewraps * test(responses): cover replay ownership after auto-pause rotation * test(responses): supply the replay conversation header * fix(responses): rebind replay owners after auto-pause rotation * test(responses): require the exact sibling replay identity * fix(accounts): fence auto-pause against policy changes and address review Persist a per-provider accountAutoPausePolicyRevisions UUID, minted only by persistConfigUnlocked from the pre-write file when a tracked provider's effective policy fingerprint changes. Attempts capture revision, local generation, threshold and route; OAuth, API-key and Codex pauses commit only while the in-memory and fresh persisted fence still match, with the OAuth automatic publication serialized under the config mutation lock. Also preserve key pause fields across dual-auth OAuth upserts, keep an omitted accountAutoPause on full provider saves, accept the policy as a canonical-provider overlay, reject non-object pause bodies, degrade malformed pause display metadata, and fix CLI/dashboard pause messages, translations and accessible names. * fix(accounts): count auto-pause refusals only for the credential on the wire sendWithConnectionPolicy now reports the final destination and headers, after plugin rewrites and queued rebuilds, through an init symbol. Auto-pause observers count a response only when that wire credential and destination match the captured attempt; a Codex WebSocket send, which has no HTTP final-send report, counts only while no plugin upstream rewriter is registered. Native Chat, native Messages and the Responses providerFetch path all report the final send. * fix(accounts): report oauthDispatch final sends and fence WebSocket attribution The Responses oauthDispatch send now attaches its auto-pause observer's final-send report. A send without an HTTP report (Codex WebSocket) counts only when no upstream rewriter was registered at capture and the rewriter registration epoch is unchanged at the response, so a rewriter that registers or unregisters mid-send can no longer make a rewritten dial look attributable. * fix(accounts): count a nested physical response once per account A providerFetch observer and the dispatchOverride observer inside it see the same physical response. Count it once per account key, and never let an observer without a final-send report attribute a response that another observer reported as sent with a different credential. * fix(accounts): keep live auto-pause fences across detached config loads A loadConfig() snapshot now adopts only the disk revision; fingerprints and local generations start there only for providers not yet observed, so a detached snapshot that differs from the live config can no longer keep invalidating in-flight fences. API-key quota rows also keep the pause flag and known pause details, matching the plain key listing. * fix(accounts): fence observed disk policy changes across detached loads Track the last observed disk fingerprint separately from the live one. A loadConfig() snapshot advances the local generation when the disk policy differs from the previously observed disk policy, so an external change followed by a restore still invalidates older attempts, while a snapshot that merely differs from the live config does not. * fix(accounts): bind the Codex workspace into auto-pause ownership and reclaim deleted-provider epochs Codex pool and main-pool attempts now own a response only when the final wire request carries the captured bearer, the context's chatgpt-account-id and unchanged organization/project selectors, at capture and in the final-send report. Policy revision records are kept only for providers that exist after disk-only preservation, observed records for deleted providers are dropped, and every new record or advance draws from one process-wide generation counter so a recreated provider cannot revive an older fence. * fix(accounts): never re-enroll an observed-deleted provider from a stale capture Once a config path has been observed, captureAccountAutoPauseFence no longer seeds a missing provider from the caller's config object; only load, reload or save adoption can enroll it. A capture taken from a stale live config while its provider is observed deleted therefore cannot survive an identical restore. * test(accounts): auto-pause attribution reads post-compression wire headers With opt-in gzip request compression enabled, the auto-pause final-send report sees the compressed send's headers (content-encoding set, stale content-length removed) and still attributes a refusal to the credential actually sent. --- .../docs/reference/cli/providers-accounts.md | 4 +- .../docs/reference/configuration/providers.md | 39 ++ gui/src/account-pause-display.ts | 20 + .../components/codex-account-pool-cards.tsx | 3 +- .../provider-workspace/ProviderAuthPanel.tsx | 11 +- .../components/provider-workspace/types.ts | 3 + gui/src/hooks/useCodexAccountPool.ts | 1 + gui/src/hooks/useProviderAccountPools.ts | 16 +- gui/src/i18n/de.ts | 7 + gui/src/i18n/en.ts | 7 + gui/src/i18n/fr.ts | 7 + gui/src/i18n/ja.ts | 7 + gui/src/i18n/ko.ts | 7 + gui/src/i18n/pt.ts | 7 + gui/src/i18n/ru.ts | 7 + gui/src/i18n/tr.ts | 7 + gui/src/i18n/vi.ts | 7 + gui/src/i18n/zh-TW.ts | 7 + gui/src/i18n/zh.ts | 7 + gui/tests/account-pause-display.test.tsx | 78 +++ .../provider-account-pause-refresh.test.tsx | 41 ++ scripts/test-layout/layout.json | 4 +- skills/ocx/references/01_surface_accounts.md | 10 +- src/adapters/google-http.ts | 5 +- src/adapters/upstream-http-error.ts | 5 +- src/cli/account-api.ts | 14 +- src/cli/account-extended.ts | 22 +- src/cli/account-key-quota.ts | 5 +- src/cli/account.ts | 2 +- src/cli/capabilities-base.ts | 10 +- src/codex/account-pause.ts | 7 +- src/codex/auth-api/account-list.ts | 3 + src/codex/auth-context.ts | 4 +- src/config.ts | 9 +- src/config/account-auto-pause-revisions.ts | 136 +++++ src/config/live-reconcile.ts | 7 + src/config/persist-unlocked.ts | 11 +- src/config/schema/account-auto-pause.ts | 14 + src/config/schema/config-schema.ts | 12 + src/config/schema/leaf-validators.ts | 3 + src/lib/account-auto-pause-response.ts | 16 + src/oauth/account-auto-pause-policy.ts | 66 +++ src/oauth/account-auto-pause.ts | 171 +++++++ src/oauth/anthropic-account-refusal.ts | 6 + src/oauth/api-key-pool-preserve.ts | 37 ++ src/oauth/index.ts | 40 +- src/oauth/store.ts | 30 +- src/oauth/types.ts | 2 + src/plugins/upstream-hooks.ts | 15 +- src/providers/api-key-pause.ts | 32 ++ src/providers/api-key-selection.ts | 7 +- src/providers/api-keys.ts | 12 +- src/providers/key-failover.ts | 22 +- src/providers/model-rename-fields.ts | 1 + src/server/auth-cors.ts | 7 + src/server/chat-native.ts | 30 +- src/server/management/oauth-account-routes.ts | 11 +- src/server/management/provider-routes.ts | 6 + src/server/management/route-registry.ts | 1 + src/server/messages-native.ts | 19 +- .../responses/account-auto-pause-recovery.ts | 26 + src/server/responses/adapter-dispatch.ts | 23 +- src/server/responses/compact.ts | 13 +- src/server/responses/core-codex-account.ts | 8 +- src/server/responses/fetch-helpers.ts | 24 +- src/server/responses/passthrough-delivery.ts | 5 +- src/server/responses/passthrough-dispatch.ts | 63 ++- .../responses/passthrough-observations.ts | 38 ++ src/server/responses/request-transport.ts | 33 +- src/types/account-auto-pause.ts | 15 + src/types/config.ts | 3 + src/types/provider.ts | 5 +- structure/INDEX.md | 14 + structure/account-auto-pause.md | 112 +++++ structure/config.md | 2 +- structure/dashboard-and-usage.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/manifest.json | 21 + structure/providers-and-adapters.md | 2 +- structure/providers/anthropic-account-pool.md | 9 + structure/providers/openai-accounts.md | 11 + structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- tests/cli/cli-account-key-pause.test.ts | 60 +++ tests/cli/cli-account-key-quota.test.ts | 13 + ...onfig-account-auto-pause-revisions.test.ts | 396 +++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/helpers/responses-core-source.ts | 2 + .../oauth-account-auto-pause-revision.test.ts | 209 ++++++++ tests/oauth/oauth-account-auto-pause.test.ts | 278 ++++++++++ .../oauth/oauth-upsert-paused-api-key.test.ts | 61 +++ .../api-key-auto-pause-distribution.test.ts | 78 +++ ...nses-account-auto-pause-wire-owner.test.ts | 473 ++++++++++++++++++ .../responses-account-auto-pause.test.ts | 230 +++++++++ .../responses-auto-pause-replay-owner.test.ts | 130 +++++ .../management-account-auto-pause.test.ts | 135 +++++ .../server/management-route-registry.test.ts | 4 +- 97 files changed, 3455 insertions(+), 159 deletions(-) create mode 100644 gui/src/account-pause-display.ts create mode 100644 gui/tests/account-pause-display.test.tsx create mode 100644 src/config/account-auto-pause-revisions.ts create mode 100644 src/config/schema/account-auto-pause.ts create mode 100644 src/lib/account-auto-pause-response.ts create mode 100644 src/oauth/account-auto-pause-policy.ts create mode 100644 src/oauth/account-auto-pause.ts create mode 100644 src/oauth/api-key-pool-preserve.ts create mode 100644 src/providers/api-key-pause.ts create mode 100644 src/server/responses/account-auto-pause-recovery.ts create mode 100644 src/server/responses/passthrough-observations.ts create mode 100644 src/types/account-auto-pause.ts create mode 100644 structure/account-auto-pause.md create mode 100644 tests/cli/cli-account-key-pause.test.ts create mode 100644 tests/config/config-account-auto-pause-revisions.test.ts create mode 100644 tests/oauth/oauth-account-auto-pause-revision.test.ts create mode 100644 tests/oauth/oauth-account-auto-pause.test.ts create mode 100644 tests/oauth/oauth-upsert-paused-api-key.test.ts create mode 100644 tests/providers/api-key-auto-pause-distribution.test.ts create mode 100644 tests/responses/responses-account-auto-pause-wire-owner.test.ts create mode 100644 tests/responses/responses-account-auto-pause.test.ts create mode 100644 tests/responses/responses-auto-pause-replay-owner.test.ts create mode 100644 tests/server/management-account-auto-pause.test.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f4ba4e6f11..ba5e4187e0 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -795,7 +795,9 @@ Clear the manual Codex account selection without resolving an account id, so it ### `ocx account pause|resume [--json]` -Pause or resume one account in the Codex, Anthropic, or generic OAuth provider pool, including +Accounts paused by the opt-in [per-provider auto-pause policy](/reference/configuration/providers/#auto-pause-permanently-refused-accounts) use the same commands. Listing shows the fixed pause reason; resume clears it. + +Pause or resume one account in the Codex, Anthropic, generic OAuth, or API-key provider pool, including `google-antigravity`. For the Codex pool, `main` identifies only the built-in Codex account; OAuth accounts must be identified by id or a unique alias. A paused OAuth account is excluded from request selection, 429 failover, and proactive token refresh, and cannot be diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 12ccbf51df..3752356b52 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1904,3 +1904,42 @@ original reasoning. Other model families require the preserve declaration. Tool-enabled DeepSeek requests also require reasoning on earlier assistant answers without tool calls. The configured placeholder fills these omissions; no original reasoning can be recovered from the tool-call cache for those turns. Preserve their supplied reasoning for full fidelity. + +## Auto-pause permanently refused accounts + +`providers..accountAutoPause` is opt-in and defaults to off. It applies to that +provider's OAuth accounts, Codex pool accounts (including the main account in pool mode), +and configured API-key pool entries. For example: + +```sh +ocx config set providers.anthropic.accountAutoPause.enabled true +ocx config set providers.anthropic.accountAutoPause.consecutiveFailures 2 +``` + +`consecutiveFailures` is an integer from 1 to 100; omitting it uses 2. Only exact, +account-scoped HTTP 401/403 refusals qualify: an organization forbidding OAuth, an inactive +or expired subscription, an account denied API/Claude Code access, or a downgrade to a free +plan with no credits. A model permission error, expired access token, quota window, 429, +5xx, malformed response or network failure never pauses an account. + +The counter is process-local, serialized per account, and resets on a successful HTTP +response, explicit resume or a new login. Token refresh alone does not reset an OAuth +account's streak. Transient errors leave the counter unchanged. Restarting the proxy clears +counters; completed pauses, their fixed reason codes and timestamps persist. Independent +proxy processes maintain independent counters. Changing, disabling or re-enabling the policy, disabling the provider, +or changing its endpoint discards in-flight refusal observations, so a refusal from a request +sent under the earlier policy can never pause an account afterwards. A refusal counts only for the credential that actually +reached the upstream: when a local plugin rewrites the credential or destination of a send, that +send never counts toward a pause. For Codex pool accounts this includes the workspace +(`chatgpt-account-id`) the request was sent for. + +At the threshold, OpenCodex excludes the account using its existing pause mechanism and +attempts an eligible sibling for the current request within the existing retry/send budget. +Fixed Codex account selectors and account-bound uploaded files retain their restrictions. +A pause never cancels already dispatched work or changes credentials. It emits one generic +log line without account identifiers or upstream error text. + +`ocx account list ` shows the pause reason. The dashboard's Paused badge exposes +the reason and time. Use `ocx account resume ` to clear the +reason and allow selection again. On an API-key row, clicking Resume also activates the key. +This policy never resumes an account automatically. diff --git a/gui/src/account-pause-display.ts b/gui/src/account-pause-display.ts new file mode 100644 index 0000000000..3a656bfe89 --- /dev/null +++ b/gui/src/account-pause-display.ts @@ -0,0 +1,20 @@ +import type { AccountPauseReason } from "../../src/types/account-auto-pause"; +import type { Locale, TFn, TKey } from "./i18n/shared"; + +const REASON_KEYS = { + oauth_organization_disabled: "accountPause.reason.organizationDisabled", + account_permission_denied: "accountPause.reason.permissionDenied", + subscription_inactive: "accountPause.reason.subscriptionInactive", + subscription_expired: "accountPause.reason.subscriptionExpired", + plan_downgraded: "accountPause.reason.planDowngraded", +} satisfies Record; + +/** Display fixed reason labels and timestamps in the selected dashboard language. */ +export function formatAccountPauseTooltip( + t: TFn, locale: Locale, details: { reason: string; pausedAt: number }, +): string { + const reason = Object.hasOwn(REASON_KEYS, details.reason) + ? t(REASON_KEYS[details.reason as AccountPauseReason]) : t("codexAuth.paused"); + const date = new Date(details.pausedAt); + return Number.isNaN(date.getTime()) ? reason : t("accountPause.tooltip", { reason, date: date.toLocaleString(locale) }); +} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index e607afbf1b..d6d015dff1 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { formatAccountPauseTooltip } from "../account-pause-display"; import { useT, useI18n } from "../i18n/shared"; import { useCopyFeedback } from "./use-copy-feedback"; import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; @@ -118,7 +119,7 @@ export function CodexAccountPoolCards({ )} {a.paused && ( - + {t("codexAuth.paused")} )} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index e21f627d5e..d6f6b0c833 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -5,7 +5,8 @@ import { anthropicInstanceRowShapeMatches, isAnthropicInstanceId } from "../../. * handlers via props-down; no internal auth machinery. */ import { useEffect, useMemo, useRef, useState } from "react"; -import { useT } from "../../i18n/shared"; +import { useT, useI18n } from "../../i18n/shared"; +import { formatAccountPauseTooltip } from "../../account-pause-display"; import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; @@ -201,6 +202,7 @@ export default function ProviderAuthPanel({ codexController?: CodexAccountPoolController; }) { const t = useT(); + const { locale } = useI18n(); const [addingKey, setAddingKey] = useState(false); const [kiroChooser, setKiroChooser] = useState<{ addAccount: boolean } | null>(null); const kiroLoginTriggerRef = useRef(null); @@ -589,7 +591,7 @@ export default function ProviderAuthPanel({ )} {showReauth && !healthLabel && {t("pws.reauth")}} {kiroSkipReasonKey(account, item.name) && {t(kiroSkipReasonKey(account, item.name)!)}} - {account.paused && {t("codexAuth.paused")}} + {account.paused && {t("codexAuth.paused")}} {account.active && !account.paused && {t("prov.accountActive")}} {switching && {t("pws.accountSwitching")}} @@ -722,13 +724,14 @@ export default function ProviderAuthPanel({