From f1812b1605954b28a7b27a6091817a69e6f5d09a Mon Sep 17 00:00:00 2001 From: zie Date: Fri, 17 Jul 2026 06:39:37 +0700 Subject: [PATCH 1/7] test(models): propose safe Combo metadata contract --- docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md | 76 +++++++++ ...combo-safe-model-metadata.contract.test.js | 149 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md create mode 100644 tests/unit/combo-safe-model-metadata.contract.test.js diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md new file mode 100644 index 0000000000..c0a3d21c82 --- /dev/null +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -0,0 +1,76 @@ +# Safe logical Combo metadata proposal + +Status: proposal only. Production changes are intentionally deferred because +[PR #2242](https://github.com/decolua/9router/pull/2242) already modifies the +same capability helper and `/v1/models` route. + +## Upstream overlap checked + +PR #2242 adds physical-model capabilities and nested Combo aggregation. Its +current aggregation is optimistic for fallback routing: + +- input modalities use union, so a Combo can advertise vision when a fallback + member cannot accept an image; +- `maxOutput` uses maximum, so a later member can receive a request beyond its + safe output limit; +- reasoning metadata follows the first member even though fallback can select a + different member; +- a missing or cyclic nested Combo falls through to model-name pattern matching; +- `/v1/models` has no `ETag` or `If-None-Match` handling. + +## Proposed public contract + +A Combo remains one logical OpenAI model entry: + +```json +{ + "id": "coding-pro", + "object": "model", + "owned_by": "combo", + "contextWindow": 120000, + "capabilities": { + "vision": false, + "tools": true, + "reasoning": false + } +} +``` + +The response must not expose members, a representative physical model, provider +credentials, route order, or operator policy names. + +## Conservative aggregation + +Resolve nested Combos to physical leaves with cycle and missing-member checks. +If resolution is incomplete, omit the aggregate metadata for that Combo rather +than guessing from its name. + +- input modalities and request features: intersection across every leaf; +- `contextWindow`: minimum verified window across every leaf; +- `maxOutput`: minimum verified output limit across every leaf; +- reasoning format/range: publish only when all leaves agree; +- unknown capability values: fail closed and omit the aggregate. + +This matches fallback semantics: advertised input must remain valid whichever +member ultimately handles the request. + +## Validator contract + +Return a strong standard `ETag` and expose it through CORS. Honor `If-None-Match` +lists, weak comparison, and `*` with an empty `304` response. + +Hash a canonical public representation plus an opaque HMAC revision of private +Combo membership. Keep the HMAC key process-local (injectable in tests), and +never expose the membership input. This invalidates clients when routing order +changes even if the conservative public aggregate is unchanged, without leaking +physical member identities. + +`tests/unit/combo-safe-model-metadata.contract.test.js` records the desired +behavior. Its `it.fails` cases are executable evidence of current gaps; remove +`.fails` only after implementation. + +## Merge strategy + +Offer the conservative rules and tests to #2242 first. Land ETag behavior as a +small follow-up PR after the aggregation shape stabilizes, so the route does not +carry two competing Combo projections. diff --git a/tests/unit/combo-safe-model-metadata.contract.test.js b/tests/unit/combo-safe-model-metadata.contract.test.js new file mode 100644 index 0000000000..f51fe9b626 --- /dev/null +++ b/tests/unit/combo-safe-model-metadata.contract.test.js @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + comboModels: ["provider/model-a", "provider/model-b"], +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: async () => [{ + id: "connection-a", + provider: "provider", + isActive: true, + providerSpecificData: { + enabledModels: ["model-a", "model-b"], + prefix: "provider", + }, + }], + getCombos: async () => [{ + name: "coding-pro", + models: [...state.comboModels], + }], + getCustomModels: async () => [], + getModelAliases: async () => ({}), +})); + +vi.mock("@/shared/constants/models", () => ({ + PROVIDER_MODELS: {}, + PROVIDER_ID_TO_ALIAS: {}, + getModelKind: () => "llm", +})); + +vi.mock("@/shared/constants/providers", () => ({ + AI_PROVIDERS: {}, + getProviderAlias: (provider) => provider, + isAnthropicCompatibleProvider: () => false, + isOpenAICompatibleProvider: () => false, +})); + +vi.mock("@/lib/disabledModelsDb", () => ({ + getDisabledModels: async () => ({}), +})); + +vi.mock("@/sse/services/tokenRefresh", () => ({ + updateProviderCredentials: async () => {}, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: async () => ({}), +})); + +import { GET } from "../../src/app/api/v1/models/route.js"; + +async function modelsResponse(headers = {}) { + return GET(new Request("http://localhost/v1/models", { headers })); +} + +async function comboEntry(response) { + const payload = await response.json(); + return payload.data.find((model) => model.id === "coding-pro"); +} + +beforeEach(() => { + state.comboModels = ["provider/model-a", "provider/model-b"]; +}); + +describe("proposed safe Combo /v1/models metadata contract", () => { + it("never exposes Combo membership or a representative physical model", async () => { + const combo = await comboEntry(await modelsResponse()); + + expect(combo).toMatchObject({ + id: "coding-pro", + object: "model", + owned_by: "combo", + }); + expect(combo).not.toHaveProperty("models"); + expect(combo).not.toHaveProperty("members"); + expect(combo).not.toHaveProperty("representativeModel"); + }); + + it.fails("projects only capabilities safe across every resolved Combo leaf", async () => { + const { aggregateComboCapabilities } = await import("../../open-sse/providers/capabilities.js"); + const capabilitiesById = { + "provider/model-a": { + vision: true, + tools: true, + reasoning: true, + contextWindow: 200000, + maxOutput: 64000, + }, + "provider/model-b": { + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }, + }; + + const caps = aggregateComboCapabilities(state.comboModels, { + resolveCapabilities: (modelId) => capabilitiesById[modelId], + }); + + expect(caps).toMatchObject({ + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }); + }); + + it.fails("adds conservative public metadata to the logical Combo entry", async () => { + const combo = await comboEntry(await modelsResponse()); + + expect(combo.contextWindow).toBeGreaterThan(0); + expect(combo.capabilities).toEqual(expect.objectContaining({ + vision: expect.any(Boolean), + tools: expect.any(Boolean), + reasoning: expect.any(Boolean), + })); + }); + + it.fails("returns a strong ETag and honors If-None-Match with 304", async () => { + const first = await modelsResponse(); + const etag = first.headers.get("etag"); + expect(etag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + + const conditional = await modelsResponse({ "If-None-Match": etag }); + expect(conditional.status).toBe(304); + expect(conditional.headers.get("etag")).toBe(etag); + expect(await conditional.text()).toBe(""); + }); + + it.fails("changes the opaque validator when private routing membership changes", async () => { + const first = await modelsResponse(); + const firstPayload = await first.clone().json(); + const firstTag = first.headers.get("etag"); + + state.comboModels = ["provider/model-b", "provider/model-a"]; + const second = await modelsResponse(); + const secondPayload = await second.clone().json(); + const secondTag = second.headers.get("etag"); + + expect(secondPayload).toEqual(firstPayload); + expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + expect(secondTag).not.toBe(firstTag); + expect(secondTag).not.toContain("provider/model-a"); + expect(secondTag).not.toContain("provider/model-b"); + }); +}); From 23c93baf037459d0ae92f495ea5f7b714d1bcb30 Mon Sep 17 00:00:00 2001 From: zie Date: Fri, 17 Jul 2026 06:47:54 +0700 Subject: [PATCH 2/7] docs(models): draft provider-scoped catalog proposal --- docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md | 4 + docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md | 91 ++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md index c0a3d21c82..429b6383ba 100644 --- a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -74,3 +74,7 @@ behavior. Its `it.fails` cases are executable evidence of current gaps; remove Offer the conservative rules and tests to #2242 first. Land ETag behavior as a small follow-up PR after the aggregation shape stabilizes, so the route does not carry two competing Combo projections. + +Provider catalog freshness is a separate source-of-truth concern. See +`OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md` for the non-duplicate OpenAI/Codex issue +draft and its explicit Codex Desktop boundary. diff --git a/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md b/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md new file mode 100644 index 0000000000..d163b9837c --- /dev/null +++ b/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md @@ -0,0 +1,91 @@ +# Draft issue: reuse provider-scoped OpenAI and Codex catalogs in `/v1/models` + +Proposed title: + +> feat(models): reuse authenticated OpenAI/Codex catalogs in `/v1/models` + +## Problem + +9Router already has two provider-scoped authoritative catalog paths in +`src/app/api/providers/[id]/models/route.js`: + +- OpenAI API-key connections fetch the documented + `https://api.openai.com/v1/models` endpoint. +- Codex OAuth connections fetch 9Router's existing authenticated ChatGPT Codex + catalog endpoint and normalize review variants. + +The client-facing `src/app/api/v1/models/route.js` does not reuse either path. +Its `LIVE_MODEL_RESOLVERS` allowlist covers Kiro, Qoder, Kimchi, GitHub, +ClinePass, and Grok CLI, while OpenAI and Codex fall back to the static registry. +As a result, the provider detail page can show a current per-connection catalog +while `/v1/models`, Combo selection, and downstream clients see stale or +unavailable entries. + +This is especially visible when an account gains or loses a model between +9Router releases. Static model names should remain a resilient fallback, not +the primary truth when an authenticated provider catalog is available. + +## Why this is not a duplicate + +- #2552 asks for a manual dashboard refresh/update control. +- #2645 fixes discovery for generic OpenAI-compatible nodes. +- #2459 parallelizes existing live resolvers. +- #2242 adds capability metadata and Combo aggregation. +- #1908 asks for a Codex-specific `{ "models": [...] }` response schema. + +This proposal is narrower: make the existing provider-scoped OpenAI and Codex +remote catalogs the source for callable IDs in the standard 9Router +`{ "object": "list", "data": [...] }` response. It can compose with those +changes without replacing their schema, performance, or capability work. + +## Proposed behavior + +1. Extract the authenticated catalog fetch/parse logic from the dashboard route + into a server-only service shared by provider detail and `/v1/models`. +2. Resolve each active connection independently; never share one account's + bearer token, catalog response, or cache entry with another connection. +3. Cache successful normalized results for a short bounded TTL keyed by provider + and connection ID. Deduplicate only after applying the connection's public + provider prefix. +4. On timeout, auth failure, malformed data, or an empty catalog, retain the + static registry for that connection and emit a diagnostic warning. Do not + turn catalog refresh into an inference outage. +5. Preserve the upstream model ID as the callable physical model. Do not invent + a representative model for a Combo or leak connection/provider credentials. +6. Feed remote capability metadata into `/v1/models` only after validating its + shape; otherwise use the existing conservative capability floor. +7. Run independent catalog requests concurrently with bounded per-provider + deadlines, preserving deterministic output ordering. + +## Acceptance tests + +- An OpenAI connection whose remote catalog contains `provider/model-a` exposes + the correctly prefixed callable ID without editing a static registry. +- A Codex connection exposes only IDs returned for that authenticated account, + plus deterministic 9Router review aliases where supported. +- Two connections with different entitlements do not contaminate each other's + cache or results. +- A remote 401/403, timeout, malformed body, or empty list falls back to the + static catalog without failing `/v1/models`. +- Disabled models and inactive connections remain excluded. +- No access token, connection ID, raw membership list, or upstream response is + included in the public response or validator. +- Output ordering and ETag are deterministic for identical public state. + +## Codex client boundary + +This improves 9Router's catalog truth but cannot by itself guarantee a model +picker in Codex Desktop. OpenAI tracks custom-provider picker/discovery support +in [openai/codex#10867](https://github.com/openai/codex/issues/10867). That issue +remains the authoritative client-side boundary even if some Desktop builds now +appear improved. Likewise, #1908 covers Codex's richer `{ "models": [...] }` +catalog schema. Do not change the standard OpenAI-compatible `/v1/models` shape +or claim Desktop parity as part of this server-side issue. + +## Suggested PR split + +1. Shared provider-scoped catalog service plus OpenAI/Codex resolver tests. +2. Wire the service into `/v1/models` with isolated caches and fail-open static + fallback. +3. Separately address #1908 after Codex's remote catalog contract is confirmed + against current official source and client tests. From c88ca9813677fc4a62216eb44641df49e851cdfb Mon Sep 17 00:00:00 2001 From: zie Date: Fri, 17 Jul 2026 07:04:46 +0700 Subject: [PATCH 3/7] test(models): cover nested metadata validators --- docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md | 7 + ...combo-safe-model-metadata.contract.test.js | 145 +++++++++++++++--- 2 files changed, 131 insertions(+), 21 deletions(-) diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md index 429b6383ba..8185f567d3 100644 --- a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -65,6 +65,13 @@ never expose the membership input. This invalidates clients when routing order changes even if the conservative public aggregate is unchanged, without leaking physical member identities. +Expose small pure helpers for tests: aggregation accepts a nested-Combo lookup +and capability resolver, while validator creation accepts an explicit 32-byte +revision key. Runtime supplies a random process-local key. Tests must prove that +equivalent public ordering is byte/ETag stable, membership changes invalidate, +and neither raw membership hashes nor a small model-name dictionary reproduce +the HMAC-backed validator. + `tests/unit/combo-safe-model-metadata.contract.test.js` records the desired behavior. Its `it.fails` cases are executable evidence of current gaps; remove `.fails` only after implementation. diff --git a/tests/unit/combo-safe-model-metadata.contract.test.js b/tests/unit/combo-safe-model-metadata.contract.test.js index f51fe9b626..19ed12f6a5 100644 --- a/tests/unit/combo-safe-model-metadata.contract.test.js +++ b/tests/unit/combo-safe-model-metadata.contract.test.js @@ -1,7 +1,10 @@ +import crypto from "node:crypto"; + import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ comboModels: ["provider/model-a", "provider/model-b"], + connectionModels: ["model-a", "model-b"], })); vi.mock("@/lib/localDb", () => ({ @@ -10,7 +13,7 @@ vi.mock("@/lib/localDb", () => ({ provider: "provider", isActive: true, providerSpecificData: { - enabledModels: ["model-a", "model-b"], + enabledModels: [...state.connectionModels], prefix: "provider", }, }], @@ -60,8 +63,35 @@ async function comboEntry(response) { beforeEach(() => { state.comboModels = ["provider/model-a", "provider/model-b"]; + state.connectionModels = ["model-a", "model-b"]; }); +const capabilitiesById = { + "provider/model-a": { + vision: true, + tools: true, + reasoning: true, + contextWindow: 200000, + maxOutput: 64000, + }, + "provider/model-b": { + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }, +}; + +async function aggregateCombo(comboModels, comboLookup = {}, resolveCapabilities = (modelId) => capabilitiesById[modelId]) { + const capabilities = await import("../../open-sse/providers/capabilities.js"); + expect(capabilities.aggregateComboCapabilities).toBeTypeOf("function"); + return capabilities.aggregateComboCapabilities(comboModels, { + comboLookup, + resolveCapabilities, + }); +} + describe("proposed safe Combo /v1/models metadata contract", () => { it("never exposes Combo membership or a representative physical model", async () => { const combo = await comboEntry(await modelsResponse()); @@ -77,26 +107,20 @@ describe("proposed safe Combo /v1/models metadata contract", () => { }); it.fails("projects only capabilities safe across every resolved Combo leaf", async () => { - const { aggregateComboCapabilities } = await import("../../open-sse/providers/capabilities.js"); - const capabilitiesById = { - "provider/model-a": { - vision: true, - tools: true, - reasoning: true, - contextWindow: 200000, - maxOutput: 64000, - }, - "provider/model-b": { - vision: false, - tools: true, - reasoning: false, - contextWindow: 120000, - maxOutput: 32000, - }, - }; - - const caps = aggregateComboCapabilities(state.comboModels, { - resolveCapabilities: (modelId) => capabilitiesById[modelId], + const caps = await aggregateCombo(state.comboModels); + + expect(caps).toMatchObject({ + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }); + }); + + it.fails("resolves nested Combos to leaves before applying conservative floors", async () => { + const caps = await aggregateCombo(["nested-combo"], { + "nested-combo": ["provider/model-a", "provider/model-b"], }); expect(caps).toMatchObject({ @@ -108,6 +132,28 @@ describe("proposed safe Combo /v1/models metadata contract", () => { }); }); + it.fails.each([ + ["cyclic nested membership", ["nested-a"], { "nested-a": ["nested-b"], "nested-b": ["nested-a"] }, undefined], + ["a missing member", ["provider/missing"], {}, undefined], + [ + "an unknown capability value", + ["provider/model-a"], + {}, + () => ({ ...capabilitiesById["provider/model-a"], vision: "unknown" }), + ], + ])("returns no aggregate for %s", async (_label, members, lookup, resolver) => { + const caps = await aggregateCombo(members, lookup, resolver); + expect(caps).toBeNull(); + }); + + it("omits public aggregate metadata when a Combo member cannot be resolved", async () => { + state.comboModels = ["provider/missing"]; + const combo = await comboEntry(await modelsResponse()); + + expect(combo).not.toHaveProperty("capabilities"); + expect(combo).not.toHaveProperty("contextWindow"); + }); + it.fails("adds conservative public metadata to the logical Combo entry", async () => { const combo = await comboEntry(await modelsResponse()); @@ -130,6 +176,36 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(await conditional.text()).toBe(""); }); + it.fails.each([ + ["a comma-separated validator list", (etag) => `\"unrelated\", ${etag}`], + ["a weak validator", (etag) => `W/${etag}`], + ["the wildcard", () => "*"], + ])("honors If-None-Match with %s", async (_label, headerValue) => { + const first = await modelsResponse(); + const etag = first.headers.get("etag"); + expect(etag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + + const conditional = await modelsResponse({ "If-None-Match": headerValue(etag) }); + expect(conditional.status).toBe(304); + expect(conditional.headers.get("etag")).toBe(etag); + expect(await conditional.text()).toBe(""); + }); + + it.fails("canonicalizes equivalent public model ordering to stable bytes and ETag", async () => { + const first = await modelsResponse(); + const firstPayload = await first.clone().json(); + const firstTag = first.headers.get("etag"); + + state.connectionModels = ["model-b", "model-a"]; + const second = await modelsResponse(); + const secondPayload = await second.clone().json(); + const secondTag = second.headers.get("etag"); + + expect(secondPayload).toEqual(firstPayload); + expect(secondTag).toBe(firstTag); + expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + }); + it.fails("changes the opaque validator when private routing membership changes", async () => { const first = await modelsResponse(); const firstPayload = await first.clone().json(); @@ -146,4 +222,31 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(secondTag).not.toContain("provider/model-a"); expect(secondTag).not.toContain("provider/model-b"); }); + + it.fails("uses an injectable HMAC key and cannot be reproduced by raw membership hashing", async () => { + const route = await import("../../src/app/api/v1/models/route.js"); + expect(route.createModelsValidator).toBeTypeOf("function"); + const publicModels = [{ id: "coding-pro", object: "model", owned_by: "combo" }]; + const combos = [{ name: "coding-pro", models: ["provider/model-a", "provider/model-b"] }]; + const keyA = Buffer.from("11".repeat(32), "hex"); + const keyB = Buffer.from("22".repeat(32), "hex"); + + const first = route.createModelsValidator({ publicModels, combos, revisionKey: keyA }); + const repeated = route.createModelsValidator({ publicModels, combos, revisionKey: keyA }); + const otherKey = route.createModelsValidator({ publicModels, combos, revisionKey: keyB }); + const dictionaryCandidates = [ + JSON.stringify(publicModels), + JSON.stringify(combos), + "provider/model-a", + "provider/model-b", + "coding-pro", + ].map((candidate) => `"sha256:${crypto.createHash("sha256").update(candidate).digest("hex")}"`); + + expect(first).toMatch(/^"sha256:[a-f0-9]{64}"$/); + expect(repeated).toBe(first); + expect(otherKey).not.toBe(first); + expect(first).not.toContain("provider/model-a"); + expect(first).not.toContain("provider/model-b"); + expect(dictionaryCandidates).not.toContain(first); + }); }); From fe99b98bd4396bcebcc37332386a661dedd03a39 Mon Sep 17 00:00:00 2001 From: zie Date: Fri, 17 Jul 2026 07:04:47 +0700 Subject: [PATCH 4/7] docs(models): prepare provider catalog proposals --- ...AI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md | 77 +++++++++++++++++++ docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md | 6 -- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md diff --git a/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md b/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md new file mode 100644 index 0000000000..14f9234bf6 --- /dev/null +++ b/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md @@ -0,0 +1,77 @@ +I rechecked this against Codex CLI `0.144.5`. The current source now has most of +the transport needed for a custom provider to own its remote model catalog, but +not yet the provider-scoped authoritative semantics a gateway needs. + +Current `0.144.5` behavior: + +- `ModelProviderInfo` supports command-backed bearer auth through `auth`, and + command-auth providers are eligible for remote model refresh. +- Codex requests `{base_url}/models?client_version=...` with the provider's auth, + expects the Codex `{ "models": [ModelInfo...] }` schema, captures the response + `ETag`, and applies a five-second request deadline. +- `OnlineIfUncached` uses `models_cache.json` with a five-minute TTL; an ETag + change can trigger an online refresh. +- Remote catalogs replace bundled models only for ChatGPT account auth. For a + custom command-auth provider, remote entries are merged into the bundled + OpenAI catalog. +- The cache is not keyed by provider identity. The source contains a TODO noting + that switching providers can reuse another provider's fresh cache entry. +- `model_catalog_json` is global and startup-only. It is not a catalog contract + attached to one `model_providers.` entry. + +Pinned source evidence: + +- [`ModelProviderInfo` and command auth](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/model-provider-info/src/lib.rs) +- [remote `/models` transport and five-second timeout](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/model-provider/src/models_endpoint.rs) +- [`{ models }` decoding and ETag capture](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/codex-api/src/endpoint/models.rs) +- [merge-versus-replace and provider-cache TODO](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/models-manager/src/manager.rs) +- [cache schema and five-minute TTL inputs](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/models-manager/src/cache.rs) +- [`0.144.5` generated config schema](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/core/config.schema.json) + +Could custom providers opt into an explicit provider-scoped mode, for example: + +```toml +[model_providers.gateway] +name = "Internal gateway" +base_url = "https://gateway.example/v1" +wire_api = "responses" +model_catalog_mode = "authoritative_remote" + +[model_providers.gateway.auth] +command = "gateway-token" +refresh_interval_ms = 300000 +``` + +Suggested semantics for `authoritative_remote`: + +1. It applies only to the selected custom provider. A successful schema-valid + remote catalog replaces bundled models for that provider instead of merging + unrelated OpenAI presets. +2. Cache identity includes the provider config key, normalized base URL, catalog + mode, and Codex client version. It never includes or persists bearer tokens. +3. Startup with `OnlineIfUncached` may use only a fresh matching provider cache. + On a cache miss, run command auth and fetch within the existing deadline. A + failure must not fall back to another provider's cache or bundled catalog; + retain the explicit configured model and surface the catalog error instead. +4. Persist the provider-scoped ETag with the snapshot. A same-ETag notification + renews that cache's TTL; a changed ETag forces an online refresh. +5. Send `If-None-Match` during refresh. `304` retains the scoped snapshot and + renews its TTL; a valid `200` atomically replaces models and ETag. +6. Reject an ambiguous combination with global `model_catalog_json`, or document + one deterministic precedence rule. + +Useful acceptance tests: + +- two command-auth providers sharing one `CODEX_HOME` never reuse each other's + models or ETag; +- a restart with a fresh matching cache does not invoke the auth command; +- a stale/missing cache invokes the command once and refreshes the selected + provider; +- `304` preserves the snapshot, while `200` replaces rather than merges it; +- a network/auth/schema failure never exposes unrelated bundled models; +- switching back to the first provider restores only its scoped snapshot. + +This complements, but does not by itself close, #10867. Provider-scoped catalog +truth is the backend requirement; Desktop still needs to render and select the +models returned for a custom provider. If #10867 should remain focused on the +picker, this catalog-mode work could be tracked as a linked model-provider issue. diff --git a/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md b/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md index d163b9837c..86178abcf2 100644 --- a/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md +++ b/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md @@ -1,9 +1,3 @@ -# Draft issue: reuse provider-scoped OpenAI and Codex catalogs in `/v1/models` - -Proposed title: - -> feat(models): reuse authenticated OpenAI/Codex catalogs in `/v1/models` - ## Problem 9Router already has two provider-scoped authoritative catalog paths in From 690b0370c3fbc5a0a2033ce0c22a3fa91886409c Mon Sep 17 00:00:00 2001 From: zie Date: Fri, 17 Jul 2026 07:08:24 +0700 Subject: [PATCH 5/7] test(models): assert canonical catalog bytes --- tests/unit/combo-safe-model-metadata.contract.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/combo-safe-model-metadata.contract.test.js b/tests/unit/combo-safe-model-metadata.contract.test.js index 19ed12f6a5..82c17730e3 100644 --- a/tests/unit/combo-safe-model-metadata.contract.test.js +++ b/tests/unit/combo-safe-model-metadata.contract.test.js @@ -193,15 +193,16 @@ describe("proposed safe Combo /v1/models metadata contract", () => { it.fails("canonicalizes equivalent public model ordering to stable bytes and ETag", async () => { const first = await modelsResponse(); - const firstPayload = await first.clone().json(); + const firstBody = await first.text(); const firstTag = first.headers.get("etag"); state.connectionModels = ["model-b", "model-a"]; const second = await modelsResponse(); - const secondPayload = await second.clone().json(); + const secondBody = await second.text(); const secondTag = second.headers.get("etag"); - expect(secondPayload).toEqual(firstPayload); + expect(secondBody).toBe(firstBody); + expect(JSON.parse(secondBody)).toEqual(JSON.parse(firstBody)); expect(secondTag).toBe(firstTag); expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); }); From 21b650f0c5670541a784d3c2e56178368552d6cf Mon Sep 17 00:00:00 2001 From: zie Date: Thu, 23 Jul 2026 08:35:46 +0700 Subject: [PATCH 6/7] feat(models): project safe Combo metadata --- docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md | 19 ++-- open-sse/providers/capabilities.js | 49 +++++++++++ src/app/api/v1/models/route.js | 86 +++++++++++++++++-- ...combo-safe-model-metadata.contract.test.js | 18 ++-- 4 files changed, 146 insertions(+), 26 deletions(-) diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md index 8185f567d3..3285e3fcbd 100644 --- a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -1,8 +1,8 @@ # Safe logical Combo metadata proposal -Status: proposal only. Production changes are intentionally deferred because -[PR #2242](https://github.com/decolua/9router/pull/2242) already modifies the -same capability helper and `/v1/models` route. +Status: implemented as the conservative projection and conditional-cache gap +around [PR #2242](https://github.com/decolua/9router/pull/2242). That PR remains +the owner of physical-model capability discovery. ## Upstream overlap checked @@ -48,7 +48,7 @@ than guessing from its name. - input modalities and request features: intersection across every leaf; - `contextWindow`: minimum verified window across every leaf; - `maxOutput`: minimum verified output limit across every leaf; -- reasoning format/range: publish only when all leaves agree; +- reasoning format/range: omit until an exact-agreement projection is defined; - unknown capability values: fail closed and omit the aggregate. This matches fallback semantics: advertised input must remain valid whichever @@ -72,15 +72,14 @@ equivalent public ordering is byte/ETag stable, membership changes invalidate, and neither raw membership hashes nor a small model-name dictionary reproduce the HMAC-backed validator. -`tests/unit/combo-safe-model-metadata.contract.test.js` records the desired -behavior. Its `it.fails` cases are executable evidence of current gaps; remove -`.fails` only after implementation. +`tests/unit/combo-safe-model-metadata.contract.test.js` records the behavior and +passes without expected-failure markers. ## Merge strategy -Offer the conservative rules and tests to #2242 first. Land ETag behavior as a -small follow-up PR after the aggregation shape stabilizes, so the route does not -carry two competing Combo projections. +Keep physical-model capability discovery in #2242. This change owns only +recursive conservative aggregation, public projection, canonical response +ordering, and privacy-preserving conditional ETags. Provider catalog freshness is a separate source-of-truth concern. See `OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md` for the non-duplicate OpenAI/Codex issue diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5bd7f6d837..e5b8580e7b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -334,3 +334,52 @@ export function getCapabilitiesForModel(provider, model) { // 4. Floor return { ...DEFAULT_CAPABILITIES }; } + +const COMBO_BOOLEAN_CAPABILITIES = [ + "vision", "pdf", "audioInput", "videoInput", "imageOutput", "audioOutput", + "search", "tools", "reasoning", "thinkingCanDisable", +]; +const COMBO_LIMIT_CAPABILITIES = ["contextWindow", "maxOutput"]; + +export function aggregateComboCapabilities( + models, + { comboLookup = {}, resolveCapabilities = () => null } = {}, +) { + if (!Array.isArray(models) || models.length === 0) return null; + const flatten = (members, stack = new Set()) => { + const leaves = []; + for (const member of members) { + const nested = comboLookup[member]; + if (!nested) { + leaves.push(member); + continue; + } + if (!Array.isArray(nested) || nested.length === 0 || stack.has(member)) return null; + const next = new Set(stack); + next.add(member); + const resolvedNested = flatten(nested, next); + if (!resolvedNested) return null; + leaves.push(...resolvedNested); + } + return leaves; + }; + const leaves = flatten(models); + if (!leaves) return null; + const resolved = leaves.map(resolveCapabilities); + if (resolved.some((capabilities) => !capabilities || typeof capabilities !== "object")) return null; + + const aggregate = {}; + for (const field of COMBO_BOOLEAN_CAPABILITIES) { + const values = resolved.map((capabilities) => capabilities[field]); + if (values.every((value) => value === undefined)) continue; + if (!values.every((value) => typeof value === "boolean")) return null; + aggregate[field] = values.every(Boolean); + } + for (const field of COMBO_LIMIT_CAPABILITIES) { + const values = resolved.map((capabilities) => capabilities[field]); + if (values.every((value) => value === undefined)) continue; + if (!values.every((value) => Number.isFinite(value) && value > 0)) return null; + aggregate[field] = Math.min(...values); + } + return aggregate; +} diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index e07745e30d..3ab81f44ff 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -1,3 +1,5 @@ +import { createHash, createHmac, randomBytes } from "node:crypto"; + import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models"; import { AI_PROVIDERS, @@ -16,7 +18,11 @@ import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; +import { + aggregateComboCapabilities, + capabilitiesFromServiceKind, + getCapabilitiesForModel, +} from "open-sse/providers/capabilities.js"; // Per-provider live model resolvers. Each receives a connection record and // returns { models: [{ id, name? }, ...] } | null on failure. @@ -118,6 +124,7 @@ const INTERNAL_MODELS_FETCH_HEADER = "x-9r-internal-models-fetch"; // LLM kind sentinel — combos/models with no explicit kind default to LLM const LLM_KIND = "llm"; +const MODELS_REVISION_KEY = randomBytes(32); // Map per-model `type` field (in PROVIDER_MODELS) to service kind. // Models without `type` are treated as LLM. @@ -224,7 +231,7 @@ function comboMatchesKinds(combo, kindFilter) { * Build OpenAI-format models list filtered by service kinds. * @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]). */ -export async function buildModelsList(kindFilter, options = {}) { +async function buildModelsListWithState(kindFilter, options = {}) { // When this header is present, the /v1/models request came from another // 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break // cross-instance recursive loops. @@ -500,7 +507,64 @@ export async function buildModelsList(kindFilter, options = {}) { dedupedModels.push(model); } - return dedupedModels; + const comboLookup = Object.fromEntries(combos.map((combo) => [combo.name, combo.models])); + const publicModels = new Map(dedupedModels.map((model) => [model.id, model])); + for (const combo of combos) { + const entry = publicModels.get(combo.name); + if (!entry || !Array.isArray(combo.models)) continue; + const capabilities = aggregateComboCapabilities(combo.models, { + comboLookup, + resolveCapabilities: (modelId) => publicModels.get(modelId)?.capabilities || null, + }); + if (!capabilities) continue; + entry.capabilities = capabilities; + entry.contextWindow = capabilities.contextWindow; + } + + return { models: dedupedModels, combos }; +} + +export async function buildModelsList(kindFilter, options = {}) { + return (await buildModelsListWithState(kindFilter, options)).models; +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function compareStrings(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} + +function canonicalModels(models) { + return [...models].sort((a, b) => compareStrings(String(a.id), String(b.id))); +} + +export function createModelsValidator({ publicModels, combos, revisionKey }) { + if (!Array.isArray(publicModels) || !Array.isArray(combos) || !Buffer.isBuffer(revisionKey) || revisionKey.length < 32) { + throw new TypeError("Models validator requires model arrays and a 32-byte revision key"); + } + const privateMembership = [...combos] + .map(({ name, models }) => ({ name, models })) + .sort((a, b) => compareStrings(String(a.name), String(b.name))); + const privateRevision = createHmac("sha256", revisionKey) + .update(canonicalJson(privateMembership)) + .digest("hex"); + const digest = createHash("sha256") + .update(canonicalJson([canonicalModels(publicModels), privateRevision])) + .digest("hex"); + return `"sha256:${digest}"`; +} + +export function ifNoneMatchMatches(header, etag) { + return String(header || "").split(",").some((candidate) => { + const tag = candidate.trim(); + return tag === "*" || tag.replace(/^W\//, "") === etag; + }); } /** @@ -524,10 +588,18 @@ export async function GET(request) { try { // Detect cross-instance recursive /models fetch (another 9router fetching our /models) const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; - const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); - return Response.json({ object: "list", data }, { - headers: { "Access-Control-Allow-Origin": "*" }, - }); + const { models, combos } = await buildModelsListWithState([LLM_KIND], { skipDynamicFetch }); + const data = canonicalModels(models); + const etag = createModelsValidator({ publicModels: data, combos, revisionKey: MODELS_REVISION_KEY }); + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "ETag", + ETag: etag, + }; + if (ifNoneMatchMatches(request?.headers?.get("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + return Response.json({ object: "list", data }, { headers }); } catch (error) { console.log("Error fetching models:", error); return Response.json( diff --git a/tests/unit/combo-safe-model-metadata.contract.test.js b/tests/unit/combo-safe-model-metadata.contract.test.js index 82c17730e3..13b1e60452 100644 --- a/tests/unit/combo-safe-model-metadata.contract.test.js +++ b/tests/unit/combo-safe-model-metadata.contract.test.js @@ -106,7 +106,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(combo).not.toHaveProperty("representativeModel"); }); - it.fails("projects only capabilities safe across every resolved Combo leaf", async () => { + it("projects only capabilities safe across every resolved Combo leaf", async () => { const caps = await aggregateCombo(state.comboModels); expect(caps).toMatchObject({ @@ -118,7 +118,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { }); }); - it.fails("resolves nested Combos to leaves before applying conservative floors", async () => { + it("resolves nested Combos to leaves before applying conservative floors", async () => { const caps = await aggregateCombo(["nested-combo"], { "nested-combo": ["provider/model-a", "provider/model-b"], }); @@ -132,7 +132,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { }); }); - it.fails.each([ + it.each([ ["cyclic nested membership", ["nested-a"], { "nested-a": ["nested-b"], "nested-b": ["nested-a"] }, undefined], ["a missing member", ["provider/missing"], {}, undefined], [ @@ -154,7 +154,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(combo).not.toHaveProperty("contextWindow"); }); - it.fails("adds conservative public metadata to the logical Combo entry", async () => { + it("adds conservative public metadata to the logical Combo entry", async () => { const combo = await comboEntry(await modelsResponse()); expect(combo.contextWindow).toBeGreaterThan(0); @@ -165,7 +165,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { })); }); - it.fails("returns a strong ETag and honors If-None-Match with 304", async () => { + it("returns a strong ETag and honors If-None-Match with 304", async () => { const first = await modelsResponse(); const etag = first.headers.get("etag"); expect(etag).toMatch(/^"sha256:[a-f0-9]{64}"$/); @@ -176,7 +176,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(await conditional.text()).toBe(""); }); - it.fails.each([ + it.each([ ["a comma-separated validator list", (etag) => `\"unrelated\", ${etag}`], ["a weak validator", (etag) => `W/${etag}`], ["the wildcard", () => "*"], @@ -191,7 +191,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(await conditional.text()).toBe(""); }); - it.fails("canonicalizes equivalent public model ordering to stable bytes and ETag", async () => { + it("canonicalizes equivalent public model ordering to stable bytes and ETag", async () => { const first = await modelsResponse(); const firstBody = await first.text(); const firstTag = first.headers.get("etag"); @@ -207,7 +207,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); }); - it.fails("changes the opaque validator when private routing membership changes", async () => { + it("changes the opaque validator when private routing membership changes", async () => { const first = await modelsResponse(); const firstPayload = await first.clone().json(); const firstTag = first.headers.get("etag"); @@ -224,7 +224,7 @@ describe("proposed safe Combo /v1/models metadata contract", () => { expect(secondTag).not.toContain("provider/model-b"); }); - it.fails("uses an injectable HMAC key and cannot be reproduced by raw membership hashing", async () => { + it("uses an injectable HMAC key and cannot be reproduced by raw membership hashing", async () => { const route = await import("../../src/app/api/v1/models/route.js"); expect(route.createModelsValidator).toBeTypeOf("function"); const publicModels = [{ id: "coding-pro", object: "model", owned_by: "combo" }]; From 801816145809e8d220262a4582e59ac89649e3f9 Mon Sep 17 00:00:00 2001 From: zie Date: Sun, 26 Jul 2026 15:46:01 +0700 Subject: [PATCH 7/7] feat(combo): enforce portable context eligibility --- docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md | 38 ++++-- ...AI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md | 77 ------------ docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md | 85 ------------- open-sse/services/combo.js | 115 +++++++++++++++++- tests/unit/combo-context-window.test.js | 104 ++++++++++++++++ 5 files changed, 248 insertions(+), 171 deletions(-) delete mode 100644 docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md delete mode 100644 docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md create mode 100644 tests/unit/combo-context-window.test.js diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md index 3285e3fcbd..0d11ba4b6b 100644 --- a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -54,6 +54,26 @@ than guessing from its name. This matches fallback semantics: advertised input must remain valid whichever member ultimately handles the request. +## Context-aware dispatch + +The public minimum is the portable client contract. Runtime preflight adds a +second layer for requests that can still reach 9Router above that contract: + +- estimate input tokens with the existing format-neutral estimator; +- add the largest requested output limit, or a conservative default allowance; +- add a small context-error buffer; +- preserve routing order while skipping members whose known window is smaller + than the estimated request budget; +- keep members with unknown runtime capability metadata eligible for backward + compatibility, but never let unknown metadata contribute to the public + aggregate; +- return `combo_context_window_exceeded` without provider dispatch when every + known member is undersized. + +This preflight is deliberately described as an estimate, not exact tokenizer +proof. Providers use different tokenizers, so callers should still size and +compact conversations against the logical Combo's advertised minimum window. + ## Validator contract Return a strong standard `ETag` and expose it through CORS. Honor `If-None-Match` @@ -72,15 +92,17 @@ equivalent public ordering is byte/ETag stable, membership changes invalidate, and neither raw membership hashes nor a small model-name dictionary reproduce the HMAC-backed validator. -`tests/unit/combo-safe-model-metadata.contract.test.js` records the behavior and -passes without expected-failure markers. +`tests/unit/combo-safe-model-metadata.contract.test.js` and +`tests/unit/combo-context-window.test.js` record the behavior and pass without +expected-failure markers. ## Merge strategy -Keep physical-model capability discovery in #2242. This change owns only -recursive conservative aggregation, public projection, canonical response -ordering, and privacy-preserving conditional ETags. +Keep physical-model capability discovery in #2242. This change owns recursive +conservative aggregation, context-aware eligibility, public projection, +canonical response ordering, and privacy-preserving conditional ETags. It +addresses the Combo context-routing requirement in #1089. -Provider catalog freshness is a separate source-of-truth concern. See -`OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md` for the non-duplicate OpenAI/Codex issue -draft and its explicit Codex Desktop boundary. +Provider catalog freshness remains a separate source-of-truth concern. In +particular, #2760 owns Claude Opus 5 and current Claude 4.6+ catalog limits; this +change consumes capability metadata and does not duplicate that catalog work. diff --git a/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md b/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md deleted file mode 100644 index 14f9234bf6..0000000000 --- a/docs/OPENAI_CODEX_10867_PROVIDER_CATALOG_COMMENT.md +++ /dev/null @@ -1,77 +0,0 @@ -I rechecked this against Codex CLI `0.144.5`. The current source now has most of -the transport needed for a custom provider to own its remote model catalog, but -not yet the provider-scoped authoritative semantics a gateway needs. - -Current `0.144.5` behavior: - -- `ModelProviderInfo` supports command-backed bearer auth through `auth`, and - command-auth providers are eligible for remote model refresh. -- Codex requests `{base_url}/models?client_version=...` with the provider's auth, - expects the Codex `{ "models": [ModelInfo...] }` schema, captures the response - `ETag`, and applies a five-second request deadline. -- `OnlineIfUncached` uses `models_cache.json` with a five-minute TTL; an ETag - change can trigger an online refresh. -- Remote catalogs replace bundled models only for ChatGPT account auth. For a - custom command-auth provider, remote entries are merged into the bundled - OpenAI catalog. -- The cache is not keyed by provider identity. The source contains a TODO noting - that switching providers can reuse another provider's fresh cache entry. -- `model_catalog_json` is global and startup-only. It is not a catalog contract - attached to one `model_providers.` entry. - -Pinned source evidence: - -- [`ModelProviderInfo` and command auth](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/model-provider-info/src/lib.rs) -- [remote `/models` transport and five-second timeout](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/model-provider/src/models_endpoint.rs) -- [`{ models }` decoding and ETag capture](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/codex-api/src/endpoint/models.rs) -- [merge-versus-replace and provider-cache TODO](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/models-manager/src/manager.rs) -- [cache schema and five-minute TTL inputs](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/models-manager/src/cache.rs) -- [`0.144.5` generated config schema](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/core/config.schema.json) - -Could custom providers opt into an explicit provider-scoped mode, for example: - -```toml -[model_providers.gateway] -name = "Internal gateway" -base_url = "https://gateway.example/v1" -wire_api = "responses" -model_catalog_mode = "authoritative_remote" - -[model_providers.gateway.auth] -command = "gateway-token" -refresh_interval_ms = 300000 -``` - -Suggested semantics for `authoritative_remote`: - -1. It applies only to the selected custom provider. A successful schema-valid - remote catalog replaces bundled models for that provider instead of merging - unrelated OpenAI presets. -2. Cache identity includes the provider config key, normalized base URL, catalog - mode, and Codex client version. It never includes or persists bearer tokens. -3. Startup with `OnlineIfUncached` may use only a fresh matching provider cache. - On a cache miss, run command auth and fetch within the existing deadline. A - failure must not fall back to another provider's cache or bundled catalog; - retain the explicit configured model and surface the catalog error instead. -4. Persist the provider-scoped ETag with the snapshot. A same-ETag notification - renews that cache's TTL; a changed ETag forces an online refresh. -5. Send `If-None-Match` during refresh. `304` retains the scoped snapshot and - renews its TTL; a valid `200` atomically replaces models and ETag. -6. Reject an ambiguous combination with global `model_catalog_json`, or document - one deterministic precedence rule. - -Useful acceptance tests: - -- two command-auth providers sharing one `CODEX_HOME` never reuse each other's - models or ETag; -- a restart with a fresh matching cache does not invoke the auth command; -- a stale/missing cache invokes the command once and refreshes the selected - provider; -- `304` preserves the snapshot, while `200` replaces rather than merges it; -- a network/auth/schema failure never exposes unrelated bundled models; -- switching back to the first provider restores only its scoped snapshot. - -This complements, but does not by itself close, #10867. Provider-scoped catalog -truth is the backend requirement; Desktop still needs to render and select the -models returned for a custom provider. If #10867 should remain focused on the -picker, this catalog-mode work could be tracked as a linked model-provider issue. diff --git a/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md b/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md deleted file mode 100644 index 86178abcf2..0000000000 --- a/docs/OPENAI_CODEX_REMOTE_CATALOG_ISSUE.md +++ /dev/null @@ -1,85 +0,0 @@ -## Problem - -9Router already has two provider-scoped authoritative catalog paths in -`src/app/api/providers/[id]/models/route.js`: - -- OpenAI API-key connections fetch the documented - `https://api.openai.com/v1/models` endpoint. -- Codex OAuth connections fetch 9Router's existing authenticated ChatGPT Codex - catalog endpoint and normalize review variants. - -The client-facing `src/app/api/v1/models/route.js` does not reuse either path. -Its `LIVE_MODEL_RESOLVERS` allowlist covers Kiro, Qoder, Kimchi, GitHub, -ClinePass, and Grok CLI, while OpenAI and Codex fall back to the static registry. -As a result, the provider detail page can show a current per-connection catalog -while `/v1/models`, Combo selection, and downstream clients see stale or -unavailable entries. - -This is especially visible when an account gains or loses a model between -9Router releases. Static model names should remain a resilient fallback, not -the primary truth when an authenticated provider catalog is available. - -## Why this is not a duplicate - -- #2552 asks for a manual dashboard refresh/update control. -- #2645 fixes discovery for generic OpenAI-compatible nodes. -- #2459 parallelizes existing live resolvers. -- #2242 adds capability metadata and Combo aggregation. -- #1908 asks for a Codex-specific `{ "models": [...] }` response schema. - -This proposal is narrower: make the existing provider-scoped OpenAI and Codex -remote catalogs the source for callable IDs in the standard 9Router -`{ "object": "list", "data": [...] }` response. It can compose with those -changes without replacing their schema, performance, or capability work. - -## Proposed behavior - -1. Extract the authenticated catalog fetch/parse logic from the dashboard route - into a server-only service shared by provider detail and `/v1/models`. -2. Resolve each active connection independently; never share one account's - bearer token, catalog response, or cache entry with another connection. -3. Cache successful normalized results for a short bounded TTL keyed by provider - and connection ID. Deduplicate only after applying the connection's public - provider prefix. -4. On timeout, auth failure, malformed data, or an empty catalog, retain the - static registry for that connection and emit a diagnostic warning. Do not - turn catalog refresh into an inference outage. -5. Preserve the upstream model ID as the callable physical model. Do not invent - a representative model for a Combo or leak connection/provider credentials. -6. Feed remote capability metadata into `/v1/models` only after validating its - shape; otherwise use the existing conservative capability floor. -7. Run independent catalog requests concurrently with bounded per-provider - deadlines, preserving deterministic output ordering. - -## Acceptance tests - -- An OpenAI connection whose remote catalog contains `provider/model-a` exposes - the correctly prefixed callable ID without editing a static registry. -- A Codex connection exposes only IDs returned for that authenticated account, - plus deterministic 9Router review aliases where supported. -- Two connections with different entitlements do not contaminate each other's - cache or results. -- A remote 401/403, timeout, malformed body, or empty list falls back to the - static catalog without failing `/v1/models`. -- Disabled models and inactive connections remain excluded. -- No access token, connection ID, raw membership list, or upstream response is - included in the public response or validator. -- Output ordering and ETag are deterministic for identical public state. - -## Codex client boundary - -This improves 9Router's catalog truth but cannot by itself guarantee a model -picker in Codex Desktop. OpenAI tracks custom-provider picker/discovery support -in [openai/codex#10867](https://github.com/openai/codex/issues/10867). That issue -remains the authoritative client-side boundary even if some Desktop builds now -appear improved. Likewise, #1908 covers Codex's richer `{ "models": [...] }` -catalog schema. Do not change the standard OpenAI-compatible `/v1/models` shape -or claim Desktop parity as part of this server-side issue. - -## Suggested PR split - -1. Shared provider-scoped catalog service plus OpenAI/Codex resolver tests. -2. Wire the service into `/v1/models` with isolated caches and fail-open static - fallback. -3. Separately address #1908 after Codex's remote catalog contract is confirmed - against current official source and client tests. diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2fcf..f4dc0dcd04 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -6,6 +6,7 @@ import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; import { unavailableResponse } from "../utils/error.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { extractTextContent } from "../translator/formats/gemini.js"; +import { estimateInputTokens } from "../utils/usageTracking.js"; // Hard capabilities = input modalities; missing one drops request data (e.g. image // stripped). Must be prioritized. Soft (e.g. search) only degrades a feature. @@ -14,6 +15,74 @@ const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]); // Prefixes used when flattening tool turns into plain prose for panel models. const TOOL_CALL_PREFIX = "[Called tools: "; const TOOL_RESULT_PREFIX = "[Tool result: "; +const DEFAULT_OUTPUT_BUDGET = 4096; +const CONTEXT_BUFFER_TOKENS = 2000; + +function requestedOutputBudget(body) { + const values = [ + body?.max_output_tokens, + body?.max_completion_tokens, + body?.max_tokens, + body?.generationConfig?.maxOutputTokens, + body?.request?.generationConfig?.maxOutputTokens, + ] + .map(Number) + .filter((value) => Number.isFinite(value) && value >= 0); + return values.length > 0 ? Math.max(...values) : DEFAULT_OUTPUT_BUDGET; +} + +/** + * Remove Combo members whose known context window cannot fit the estimated + * request budget. Unknown capability metadata stays eligible for backwards + * compatibility, but must not contribute to public Combo metadata. + * + * This is a conservative preflight guard, not exact tokenizer proof: providers + * use different tokenizers and estimateInputTokens is intentionally format + * neutral. The public minimum Combo context window remains the portable client + * contract. + */ +export function selectContextEligibleModels( + models, + body, + { + resolveCapabilities = getCapabilitiesForModel, + estimateTokens = estimateInputTokens, + bufferTokens = CONTEXT_BUFFER_TOKENS, + } = {}, +) { + if (!Array.isArray(models) || models.length === 0) { + return { models, skipped: [], requiredTokens: null }; + } + const estimatedInput = Number(estimateTokens(body)); + if (!Number.isFinite(estimatedInput) || estimatedInput <= 0) { + return { models, skipped: [], requiredTokens: null }; + } + const normalizedBuffer = Number.isFinite(Number(bufferTokens)) + ? Math.max(0, Number(bufferTokens)) + : CONTEXT_BUFFER_TOKENS; + const requiredTokens = Math.ceil( + estimatedInput + requestedOutputBudget(body) + normalizedBuffer, + ); + const eligible = []; + const skipped = []; + + for (const modelId of models) { + const slash = typeof modelId === "string" ? modelId.indexOf("/") : -1; + const provider = slash > 0 ? modelId.slice(0, slash) : ""; + const model = slash > 0 ? modelId.slice(slash + 1) : modelId; + const capabilities = resolveCapabilities(provider, model); + const contextWindow = Number(capabilities?.contextWindow); + if (!Number.isFinite(contextWindow) || contextWindow <= 0) { + eligible.push(modelId); + } else if (contextWindow >= requiredTokens) { + eligible.push(modelId); + } else { + skipped.push({ model: modelId, contextWindow }); + } + } + + return { models: eligible, skipped, requiredTokens }; +} // Flatten tool turns into prose so panel models keep the context but can't loop // on tools: drop the request's tools, turn tool/function results into assistant @@ -226,7 +295,18 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { +export async function handleComboChat({ + body, + models, + handleSingleModel, + log, + comboName, + comboStrategy, + comboStickyLimit = 1, + autoSwitch = true, + resolveCapabilities = getCapabilitiesForModel, + estimateTokens = estimateInputTokens, +}) { // Apply rotation strategy if enabled let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); @@ -241,6 +321,39 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co rotatedModels = reordered; } } + + const contextEligibility = selectContextEligibleModels(rotatedModels, body, { + resolveCapabilities, + estimateTokens, + }); + if (contextEligibility.skipped.length > 0) { + log.info( + "COMBO", + `context preflight skipped ${contextEligibility.skipped.length} undersized model(s)`, + { requiredTokensEstimate: contextEligibility.requiredTokens }, + ); + } + rotatedModels = contextEligibility.models; + if (rotatedModels.length === 0 && contextEligibility.skipped.length > 0) { + const largestContextWindow = Math.max( + ...contextEligibility.skipped.map(({ contextWindow }) => contextWindow), + ); + log.warn("COMBO", "No model passed context preflight", { + requiredTokensEstimate: contextEligibility.requiredTokens, + largestContextWindow, + }); + return new Response( + JSON.stringify({ + error: { + code: "combo_context_window_exceeded", + message: "No Combo member has a known context window large enough for this request.", + required_tokens_estimate: contextEligibility.requiredTokens, + largest_context_window: largestContextWindow, + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } let lastError = null; let earliestRetryAfter = null; diff --git a/tests/unit/combo-context-window.test.js b/tests/unit/combo-context-window.test.js new file mode 100644 index 0000000000..d001e3df6e --- /dev/null +++ b/tests/unit/combo-context-window.test.js @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + handleComboChat, + selectContextEligibleModels, +} from "../../open-sse/services/combo.js"; + +const CAPABILITIES = { + "small/model": { contextWindow: 100000 }, + "large/model": { contextWindow: 300000 }, +}; + +const resolveCapabilities = (provider, model) => CAPABILITIES[`${provider}/${model}`] ?? null; +const estimateTokens = () => 120000; + +describe("selectContextEligibleModels", () => { + it("drops known undersized members and preserves eligible order", () => { + const result = selectContextEligibleModels( + ["small/model", "large/model", "unknown/model"], + { max_output_tokens: 10000 }, + { resolveCapabilities, estimateTokens, bufferTokens: 2000 }, + ); + + expect(result).toEqual({ + models: ["large/model", "unknown/model"], + skipped: [{ model: "small/model", contextWindow: 100000 }], + requiredTokens: 132000, + }); + }); + + it("uses a conservative default output allowance when no output limit is requested", () => { + const result = selectContextEligibleModels( + ["small/model", "large/model"], + {}, + { resolveCapabilities, estimateTokens: () => 95000, bufferTokens: 2000 }, + ); + + expect(result.requiredTokens).toBe(101096); + expect(result.models).toEqual(["large/model"]); + }); + + it("preserves compatibility when request size cannot be estimated", () => { + const models = ["small/model", "large/model"]; + const result = selectContextEligibleModels( + models, + {}, + { resolveCapabilities, estimateTokens: () => 0 }, + ); + + expect(result).toEqual({ + models, + skipped: [], + requiredTokens: null, + }); + }); +}); + +describe("handleComboChat context eligibility", () => { + const log = { + info: vi.fn(), + warn: vi.fn(), + }; + + it("never dispatches a request to a known undersized member", async () => { + const handleSingleModel = vi.fn(async () => Response.json({ ok: true })); + + const response = await handleComboChat({ + body: { messages: [{ role: "user", content: "large prompt" }], max_tokens: 10000 }, + models: ["small/model", "large/model"], + handleSingleModel, + log, + resolveCapabilities, + estimateTokens, + }); + + expect(response.ok).toBe(true); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + expect(handleSingleModel).toHaveBeenCalledWith(expect.any(Object), "large/model"); + }); + + it("returns a typed client error without provider dispatch when every known member is undersized", async () => { + const handleSingleModel = vi.fn(); + + const response = await handleComboChat({ + body: { messages: [{ role: "user", content: "large prompt" }], max_tokens: 10000 }, + models: ["small/model"], + handleSingleModel, + log, + resolveCapabilities, + estimateTokens, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { + code: "combo_context_window_exceeded", + message: "No Combo member has a known context window large enough for this request.", + required_tokens_estimate: 132000, + largest_context_window: 100000, + }, + }); + expect(handleSingleModel).not.toHaveBeenCalled(); + }); +});