From a5fb8b78206327c755a58de37759f0ea52abd895 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 19:04:37 -0700 Subject: [PATCH 1/2] Unify provider identity lists around the first-class registry The ChatGPT-OAuth fallback defaulted to a different model than the OpenAI API-key path while both serve OpenAI, so the picker and the catalog disagreed per auth path. The live-fetch fallbacks stay separate - they back live calls - and a divergence test now pins the agreement instead of merging the lists. --- .../first-class-providers/src/providers.ts | 6 + src/auth/codex/constants.ts | 7 +- src/auth/codex/index.ts | 1 - src/auth/codex/login.ts | 7 - src/auth/xai/constants.ts | 3 + src/auth/xai/index.ts | 1 - src/auth/xai/login.ts | 7 - src/provider/identity-divergence.test.ts | 146 ++++++++++++++++++ src/tui/provider/choices.ts | 2 + tests/unit/codex-providers.test.ts | 7 +- 10 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 src/provider/identity-divergence.test.ts diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 3e26ffcc3..17b58a026 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -47,6 +47,12 @@ export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [ /** * First-class providers shown in the models-surface Connect list. * Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom. + * + * Canonical static registry for provider identity (CL-5691): base URLs, model + * lists, and defaults live here. The Codex/xAI OAuth fallbacks stay separate + * — they back live catalog calls, not static identity — but must agree with + * this registry; src/provider/identity-divergence.test.ts pins that. + * Do not add another edge list: reference this registry or the fallbacks. */ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ { diff --git a/src/auth/codex/constants.ts b/src/auth/codex/constants.ts index bb31ce679..c18861f58 100644 --- a/src/auth/codex/constants.ts +++ b/src/auth/codex/constants.ts @@ -26,14 +26,17 @@ export const CODEX_ORIGINATOR = "codex_cli_rs"; // unavailable — e.g. while rate-limited it returns an empty list. The Codex // backend rotates its serving set (codex-rs no longer hardcodes presets), so // the live fetch is authoritative and these are just a current-generation -// default so the picker is never empty. +// default so the picker is never empty. The first entry doubles as the +// ChatGPT-OAuth default model: it must stay the model shared with the OpenAI +// API-key path's default in FIRST_CLASS_PROVIDERS (gpt-5.4), so both auth +// paths serving OpenAI agree. identity-divergence.test.ts pins this. export const CODEX_DEFAULT_MODELS = [ + "gpt-5.4", "gpt-5.5", "gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", - "gpt-5.4", "gpt-5.4-mini", ] as const; diff --git a/src/auth/codex/index.ts b/src/auth/codex/index.ts index 3c98533bf..1e29b9a04 100644 --- a/src/auth/codex/index.ts +++ b/src/auth/codex/index.ts @@ -23,7 +23,6 @@ export { export { startCodexLogin, openInBrowser, - codexProviderSurface, type CodexLoginHandle, type StartCodexLoginOptions, } from "./login.js"; diff --git a/src/auth/codex/login.ts b/src/auth/codex/login.ts index b76a1ad53..2c3f3f03f 100644 --- a/src/auth/codex/login.ts +++ b/src/auth/codex/login.ts @@ -6,7 +6,6 @@ import { type StartOAuthLoginOptions, } from "@corbits/oauth-core"; import { - CODEX_BASE_URL, codexOAuthConfig, exchangeCodexCode, type CodexTokens, @@ -15,7 +14,6 @@ import { import type { CallbackPageCopy } from "../callback-page.js"; import { saveCodexProfile } from "../../config/oauth-stores.js"; import { startCodexCallbackServer } from "./callback-server.js"; -import { CODEX_DEFAULT_MODELS } from "./constants.js"; import { withDefaultCodexExpiry } from "./store.js"; export { openInBrowser }; @@ -39,8 +37,3 @@ export async function startCodexLogin( saveProfile: (profile) => saveCodexProfile(profile, home), }); } - -export const codexProviderSurface = { - baseURL: CODEX_BASE_URL, - models: [...CODEX_DEFAULT_MODELS], -} as const; diff --git a/src/auth/xai/constants.ts b/src/auth/xai/constants.ts index d5afa5cb8..1c30becd9 100644 --- a/src/auth/xai/constants.ts +++ b/src/auth/xai/constants.ts @@ -1,5 +1,8 @@ import { XAI_REDIRECT_URI } from "@corbits/xai-provider"; +// Local alias over the vendor fallback list (CL-5691): XAI_DEFAULT_MODELS +// backs live xAI catalog calls and stays in the vendor package — this shim +// only renames the proxy URL. identity-divergence.test.ts pins the agreement. export { XAI_DEFAULT_MODELS, XAI_OAUTH_PROXY_BASE_URL as XAI_BASE_URL, diff --git a/src/auth/xai/index.ts b/src/auth/xai/index.ts index acf9c704e..94ba2e6ce 100644 --- a/src/auth/xai/index.ts +++ b/src/auth/xai/index.ts @@ -17,7 +17,6 @@ export { } from "./session.js"; export { startXaiLogin, - xaiProviderSurface, type XaiLoginHandle, type StartXaiLoginOptions, } from "./login.js"; diff --git a/src/auth/xai/login.ts b/src/auth/xai/login.ts index d6ebda5ce..7659f103f 100644 --- a/src/auth/xai/login.ts +++ b/src/auth/xai/login.ts @@ -6,8 +6,6 @@ import { } from "@corbits/oauth-core"; import { exchangeXaiCode, - XAI_DEFAULT_MODELS, - XAI_OAUTH_PROXY_BASE_URL, xaiOAuthConfig, type XaiTokens, } from "@corbits/xai-provider"; @@ -34,8 +32,3 @@ export async function startXaiLogin( saveProfile: (profile) => saveXaiProfile(profile, home), }); } - -export const xaiProviderSurface = { - baseURL: XAI_OAUTH_PROXY_BASE_URL, - models: [...XAI_DEFAULT_MODELS], -} as const; diff --git a/src/provider/identity-divergence.test.ts b/src/provider/identity-divergence.test.ts new file mode 100644 index 000000000..92e5e998c --- /dev/null +++ b/src/provider/identity-divergence.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { XAI_OAUTH_PROXY_BASE_URL } from "@corbits/xai-provider"; +import { + FIRST_CLASS_PROVIDERS, + OPENAI_API_MAX_COMPLETION_TOKENS_MODELS, + firstClassProviderById, +} from "../../packages/first-class-providers/src/index.js"; +import { + OPENCODE_GO_DEFAULT_MODEL, + OPENCODE_GO_MODEL_IDS, + isKnownGoModel, +} from "../../packages/opencode-go/src/index.js"; +import { + ZEN_DEFAULT_MODEL, + ZEN_MODEL_IDS, + isKnownZenModel, +} from "../../packages/zen/src/index.js"; +import { + CODEX_BASE_URL, + CODEX_DEFAULT_MODELS, +} from "../auth/codex/constants.js"; +import type { CodexProfile } from "../auth/codex/store.js"; +import type { XaiProfile } from "../auth/xai/store.js"; +import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../auth/xai/constants.js"; +import { + codexProfilesToCatalogEntries, + codexProviderName, +} from "../config/codex-providers.js"; +import { + xaiProfilesToCatalogEntries, + xaiProviderName, +} from "../config/xai-providers.js"; +import { OAUTH_SURFACES } from "../tui/provider/choices.js"; +import { + resetGoModelDiscoveryForTests, + selectableGoModelIds, +} from "./opencode-go-models.js"; +import { + resetZenModelDiscoveryForTests, + selectableZenModelIds, +} from "./zen-models.js"; + +// CL-5691: provider/model identity unification. FIRST_CLASS_PROVIDERS is the +// canonical static registry; the Codex/xAI live-fetch fallbacks stay separate +// (they back live calls) but every auth path serving the same provider must +// agree on identity metadata. These tests fail loudly on drift instead of +// letting another list quietly diverge. +describe("provider identity divergence", () => { + beforeEach(() => { + resetZenModelDiscoveryForTests(); + resetGoModelDiscoveryForTests(); + }); + + test("OpenAI API-key default and ChatGPT-OAuth fallback default agree", () => { + const apiPath = firstClassProviderById("openai")?.paths?.find( + (p) => p.id === "api", + ); + if (apiPath?.defaultModel === undefined) + throw new Error("Expected the OpenAI API path to declare a defaultModel"); + // Both auth paths serve OpenAI models, so the shared model must lead both + // lists — the fallback default is models[0] by construction. + const codexFallbacks: readonly string[] = CODEX_DEFAULT_MODELS; + const codexDefault: string | undefined = codexFallbacks[0]; + expect(codexDefault).toBe(apiPath.defaultModel); + expect(codexFallbacks).toContain(apiPath.defaultModel); + }); + + test("OpenAI API-key entry is self-consistent", () => { + const apiPath = firstClassProviderById("openai")?.paths?.find( + (p) => p.id === "api", + ); + expect(apiPath?.models).toContain(apiPath?.defaultModel); + for (const model of apiPath?.maxCompletionTokensModels ?? []) { + expect(apiPath?.models).toContain(model); + } + // The quirks reader resolves through the registry entry, so the exported + // list must stay identical to it — never a second copy. + expect([...OPENAI_API_MAX_COMPLETION_TOKENS_MODELS]).toEqual([ + ...(apiPath?.maxCompletionTokensModels ?? []), + ]); + }); + + test("every first-class api-key default is a member of its models", () => { + for (const def of FIRST_CLASS_PROVIDERS) { + // Keyless entries (e.g. Ollama) carry an empty default and no models. + if (def.defaultModel === undefined || def.defaultModel === "") continue; + expect(def.models ?? []).toContain(def.defaultModel); + } + }); + + test("Codex OAuth projection and surfaces track the live-fetch fallback", () => { + expect(OAUTH_SURFACES.codex.baseURL).toBe(CODEX_BASE_URL); + expect([...OAUTH_SURFACES.codex.models]).toEqual([...CODEX_DEFAULT_MODELS]); + expect(OAUTH_SURFACES.codex.providerName("probe")).toBe( + codexProviderName("probe"), + ); + const profile = { + name: "probe", + tokens: { access: "probe", refresh: "probe", expiresAt: 0 }, + } as CodexProfile; + const [entry] = codexProfilesToCatalogEntries([profile]); + expect(entry?.baseURL).toBe(CODEX_BASE_URL); + expect(entry?.models).toEqual([...CODEX_DEFAULT_MODELS]); + expect(entry?.defaultModel).toBe(CODEX_DEFAULT_MODELS[0]); + }); + + test("xAI OAuth projection and surfaces track the vendor fallback", () => { + expect(XAI_BASE_URL).toBe(XAI_OAUTH_PROXY_BASE_URL); + expect(OAUTH_SURFACES.xai.baseURL).toBe(XAI_OAUTH_PROXY_BASE_URL); + expect([...OAUTH_SURFACES.xai.models]).toEqual([...XAI_DEFAULT_MODELS]); + expect(OAUTH_SURFACES.xai.providerName("probe")).toBe( + xaiProviderName("probe"), + ); + const profile = { + name: "probe", + tokens: { access: "probe", refresh: "probe", expiresAt: 0 }, + } as XaiProfile; + const [entry] = xaiProfilesToCatalogEntries([profile]); + expect(entry?.baseURL).toBe(XAI_OAUTH_PROXY_BASE_URL); + expect(entry?.models).toEqual([...XAI_DEFAULT_MODELS]); + expect(entry?.defaultModel).toBe(XAI_DEFAULT_MODELS[0]); + }); + + test("Zen registry entry tracks the packaged seed catalog", () => { + const zen = firstClassProviderById("zen"); + expect([...(zen?.models ?? [])]).toEqual([...ZEN_MODEL_IDS]); + expect(zen?.defaultModel).toBe(ZEN_DEFAULT_MODEL); + expect(ZEN_MODEL_IDS).toContain(ZEN_DEFAULT_MODEL); + // Cold picker (no live snapshot yet) falls back to the same seed. + expect([...selectableZenModelIds()]).toEqual([...ZEN_MODEL_IDS]); + for (const id of ZEN_MODEL_IDS) { + expect(isKnownZenModel(id)).toBe(true); + } + }); + + test("OpenCode Go registry entry tracks the packaged seed catalog", () => { + const go = firstClassProviderById("opencode-go"); + expect([...(go?.models ?? [])]).toEqual([...OPENCODE_GO_MODEL_IDS]); + expect(go?.defaultModel).toBe(OPENCODE_GO_DEFAULT_MODEL); + expect(OPENCODE_GO_MODEL_IDS).toContain(OPENCODE_GO_DEFAULT_MODEL); + expect([...selectableGoModelIds()]).toEqual([...OPENCODE_GO_MODEL_IDS]); + for (const id of OPENCODE_GO_MODEL_IDS) { + expect(isKnownGoModel(id)).toBe(true); + } + }); +}); diff --git a/src/tui/provider/choices.ts b/src/tui/provider/choices.ts index beae03699..c48741d9a 100644 --- a/src/tui/provider/choices.ts +++ b/src/tui/provider/choices.ts @@ -64,6 +64,8 @@ export const TYPE_MODEL_ID = "__type_model__"; * What a signed-in subscription provider resolves to. The endpoint and model * list are the same constants the auth stack projects into the catalog, so a * first run and a later `/model` connect land on the same provider entry. + * These are views over the Codex/xAI live-fetch fallbacks, not a separate + * list — identity-divergence.test.ts pins them to the fallback constants. */ export const OAUTH_SURFACES: Record< OAuthKind, diff --git a/tests/unit/codex-providers.test.ts b/tests/unit/codex-providers.test.ts index c78b6cce2..a1c72db7c 100644 --- a/tests/unit/codex-providers.test.ts +++ b/tests/unit/codex-providers.test.ts @@ -26,12 +26,15 @@ describe("codex provider naming", () => { }); describe("CODEX_DEFAULT_MODELS", () => { - test("includes the gpt-5.6 model family while keeping gpt-5.5 as the default", () => { + test("includes the gpt-5.6 model family while defaulting to the shared OpenAI model", () => { expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-sol"); expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-terra"); expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-luna"); expect(CODEX_DEFAULT_MODELS).toContain("gpt-6-astra"); - expect(CODEX_DEFAULT_MODELS[0]).toBe("gpt-5.5"); + expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.5"); + // CL-5691: the ChatGPT-OAuth default agrees with the OpenAI API-key + // path default (gpt-5.4) — both auth paths serve OpenAI. + expect(CODEX_DEFAULT_MODELS[0]).toBe("gpt-5.4"); }); }); From 1147b4a5917c55bd7f1c62f71c013d337c7e31ae Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 21:49:19 -0700 Subject: [PATCH 2/2] Use alias instead of shim in xAI constants comment --- src/auth/xai/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/xai/constants.ts b/src/auth/xai/constants.ts index 1c30becd9..91f56b3ad 100644 --- a/src/auth/xai/constants.ts +++ b/src/auth/xai/constants.ts @@ -1,7 +1,7 @@ import { XAI_REDIRECT_URI } from "@corbits/xai-provider"; // Local alias over the vendor fallback list (CL-5691): XAI_DEFAULT_MODELS -// backs live xAI catalog calls and stays in the vendor package — this shim +// backs live xAI catalog calls and stays in the vendor package — this alias // only renames the proxy URL. identity-divergence.test.ts pins the agreement. export { XAI_DEFAULT_MODELS,