Skip to content

Commit a5fb8b7

Browse files
committed
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.
1 parent bf79cc0 commit a5fb8b7

10 files changed

Lines changed: 167 additions & 20 deletions

File tree

‎packages/first-class-providers/src/providers.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [
4747
/**
4848
* First-class providers shown in the models-surface Connect list.
4949
* Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom.
50+
*
51+
* Canonical static registry for provider identity (CL-5691): base URLs, model
52+
* lists, and defaults live here. The Codex/xAI OAuth fallbacks stay separate
53+
* — they back live catalog calls, not static identity — but must agree with
54+
* this registry; src/provider/identity-divergence.test.ts pins that.
55+
* Do not add another edge list: reference this registry or the fallbacks.
5056
*/
5157
export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
5258
{

‎src/auth/codex/constants.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@ export const CODEX_ORIGINATOR = "codex_cli_rs";
2626
// unavailable — e.g. while rate-limited it returns an empty list. The Codex
2727
// backend rotates its serving set (codex-rs no longer hardcodes presets), so
2828
// the live fetch is authoritative and these are just a current-generation
29-
// default so the picker is never empty.
29+
// default so the picker is never empty. The first entry doubles as the
30+
// ChatGPT-OAuth default model: it must stay the model shared with the OpenAI
31+
// API-key path's default in FIRST_CLASS_PROVIDERS (gpt-5.4), so both auth
32+
// paths serving OpenAI agree. identity-divergence.test.ts pins this.
3033
export const CODEX_DEFAULT_MODELS = [
34+
"gpt-5.4",
3135
"gpt-5.5",
3236
"gpt-6-astra",
3337
"gpt-5.6-sol",
3438
"gpt-5.6-terra",
3539
"gpt-5.6-luna",
36-
"gpt-5.4",
3740
"gpt-5.4-mini",
3841
] as const;
3942

‎src/auth/codex/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export {
2323
export {
2424
startCodexLogin,
2525
openInBrowser,
26-
codexProviderSurface,
2726
type CodexLoginHandle,
2827
type StartCodexLoginOptions,
2928
} from "./login.js";

‎src/auth/codex/login.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
type StartOAuthLoginOptions,
77
} from "@corbits/oauth-core";
88
import {
9-
CODEX_BASE_URL,
109
codexOAuthConfig,
1110
exchangeCodexCode,
1211
type CodexTokens,
@@ -15,7 +14,6 @@ import {
1514
import type { CallbackPageCopy } from "../callback-page.js";
1615
import { saveCodexProfile } from "../../config/oauth-stores.js";
1716
import { startCodexCallbackServer } from "./callback-server.js";
18-
import { CODEX_DEFAULT_MODELS } from "./constants.js";
1917
import { withDefaultCodexExpiry } from "./store.js";
2018

2119
export { openInBrowser };
@@ -39,8 +37,3 @@ export async function startCodexLogin(
3937
saveProfile: (profile) => saveCodexProfile(profile, home),
4038
});
4139
}
42-
43-
export const codexProviderSurface = {
44-
baseURL: CODEX_BASE_URL,
45-
models: [...CODEX_DEFAULT_MODELS],
46-
} as const;

‎src/auth/xai/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { XAI_REDIRECT_URI } from "@corbits/xai-provider";
22

3+
// Local alias over the vendor fallback list (CL-5691): XAI_DEFAULT_MODELS
4+
// backs live xAI catalog calls and stays in the vendor package — this shim
5+
// only renames the proxy URL. identity-divergence.test.ts pins the agreement.
36
export {
47
XAI_DEFAULT_MODELS,
58
XAI_OAUTH_PROXY_BASE_URL as XAI_BASE_URL,

‎src/auth/xai/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export {
1717
} from "./session.js";
1818
export {
1919
startXaiLogin,
20-
xaiProviderSurface,
2120
type XaiLoginHandle,
2221
type StartXaiLoginOptions,
2322
} from "./login.js";

‎src/auth/xai/login.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import {
66
} from "@corbits/oauth-core";
77
import {
88
exchangeXaiCode,
9-
XAI_DEFAULT_MODELS,
10-
XAI_OAUTH_PROXY_BASE_URL,
119
xaiOAuthConfig,
1210
type XaiTokens,
1311
} from "@corbits/xai-provider";
@@ -34,8 +32,3 @@ export async function startXaiLogin(
3432
saveProfile: (profile) => saveXaiProfile(profile, home),
3533
});
3634
}
37-
38-
export const xaiProviderSurface = {
39-
baseURL: XAI_OAUTH_PROXY_BASE_URL,
40-
models: [...XAI_DEFAULT_MODELS],
41-
} as const;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { beforeEach, describe, expect, test } from "bun:test";
2+
import { XAI_OAUTH_PROXY_BASE_URL } from "@corbits/xai-provider";
3+
import {
4+
FIRST_CLASS_PROVIDERS,
5+
OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
6+
firstClassProviderById,
7+
} from "../../packages/first-class-providers/src/index.js";
8+
import {
9+
OPENCODE_GO_DEFAULT_MODEL,
10+
OPENCODE_GO_MODEL_IDS,
11+
isKnownGoModel,
12+
} from "../../packages/opencode-go/src/index.js";
13+
import {
14+
ZEN_DEFAULT_MODEL,
15+
ZEN_MODEL_IDS,
16+
isKnownZenModel,
17+
} from "../../packages/zen/src/index.js";
18+
import {
19+
CODEX_BASE_URL,
20+
CODEX_DEFAULT_MODELS,
21+
} from "../auth/codex/constants.js";
22+
import type { CodexProfile } from "../auth/codex/store.js";
23+
import type { XaiProfile } from "../auth/xai/store.js";
24+
import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../auth/xai/constants.js";
25+
import {
26+
codexProfilesToCatalogEntries,
27+
codexProviderName,
28+
} from "../config/codex-providers.js";
29+
import {
30+
xaiProfilesToCatalogEntries,
31+
xaiProviderName,
32+
} from "../config/xai-providers.js";
33+
import { OAUTH_SURFACES } from "../tui/provider/choices.js";
34+
import {
35+
resetGoModelDiscoveryForTests,
36+
selectableGoModelIds,
37+
} from "./opencode-go-models.js";
38+
import {
39+
resetZenModelDiscoveryForTests,
40+
selectableZenModelIds,
41+
} from "./zen-models.js";
42+
43+
// CL-5691: provider/model identity unification. FIRST_CLASS_PROVIDERS is the
44+
// canonical static registry; the Codex/xAI live-fetch fallbacks stay separate
45+
// (they back live calls) but every auth path serving the same provider must
46+
// agree on identity metadata. These tests fail loudly on drift instead of
47+
// letting another list quietly diverge.
48+
describe("provider identity divergence", () => {
49+
beforeEach(() => {
50+
resetZenModelDiscoveryForTests();
51+
resetGoModelDiscoveryForTests();
52+
});
53+
54+
test("OpenAI API-key default and ChatGPT-OAuth fallback default agree", () => {
55+
const apiPath = firstClassProviderById("openai")?.paths?.find(
56+
(p) => p.id === "api",
57+
);
58+
if (apiPath?.defaultModel === undefined)
59+
throw new Error("Expected the OpenAI API path to declare a defaultModel");
60+
// Both auth paths serve OpenAI models, so the shared model must lead both
61+
// lists — the fallback default is models[0] by construction.
62+
const codexFallbacks: readonly string[] = CODEX_DEFAULT_MODELS;
63+
const codexDefault: string | undefined = codexFallbacks[0];
64+
expect(codexDefault).toBe(apiPath.defaultModel);
65+
expect(codexFallbacks).toContain(apiPath.defaultModel);
66+
});
67+
68+
test("OpenAI API-key entry is self-consistent", () => {
69+
const apiPath = firstClassProviderById("openai")?.paths?.find(
70+
(p) => p.id === "api",
71+
);
72+
expect(apiPath?.models).toContain(apiPath?.defaultModel);
73+
for (const model of apiPath?.maxCompletionTokensModels ?? []) {
74+
expect(apiPath?.models).toContain(model);
75+
}
76+
// The quirks reader resolves through the registry entry, so the exported
77+
// list must stay identical to it — never a second copy.
78+
expect([...OPENAI_API_MAX_COMPLETION_TOKENS_MODELS]).toEqual([
79+
...(apiPath?.maxCompletionTokensModels ?? []),
80+
]);
81+
});
82+
83+
test("every first-class api-key default is a member of its models", () => {
84+
for (const def of FIRST_CLASS_PROVIDERS) {
85+
// Keyless entries (e.g. Ollama) carry an empty default and no models.
86+
if (def.defaultModel === undefined || def.defaultModel === "") continue;
87+
expect(def.models ?? []).toContain(def.defaultModel);
88+
}
89+
});
90+
91+
test("Codex OAuth projection and surfaces track the live-fetch fallback", () => {
92+
expect(OAUTH_SURFACES.codex.baseURL).toBe(CODEX_BASE_URL);
93+
expect([...OAUTH_SURFACES.codex.models]).toEqual([...CODEX_DEFAULT_MODELS]);
94+
expect(OAUTH_SURFACES.codex.providerName("probe")).toBe(
95+
codexProviderName("probe"),
96+
);
97+
const profile = {
98+
name: "probe",
99+
tokens: { access: "probe", refresh: "probe", expiresAt: 0 },
100+
} as CodexProfile;
101+
const [entry] = codexProfilesToCatalogEntries([profile]);
102+
expect(entry?.baseURL).toBe(CODEX_BASE_URL);
103+
expect(entry?.models).toEqual([...CODEX_DEFAULT_MODELS]);
104+
expect(entry?.defaultModel).toBe(CODEX_DEFAULT_MODELS[0]);
105+
});
106+
107+
test("xAI OAuth projection and surfaces track the vendor fallback", () => {
108+
expect(XAI_BASE_URL).toBe(XAI_OAUTH_PROXY_BASE_URL);
109+
expect(OAUTH_SURFACES.xai.baseURL).toBe(XAI_OAUTH_PROXY_BASE_URL);
110+
expect([...OAUTH_SURFACES.xai.models]).toEqual([...XAI_DEFAULT_MODELS]);
111+
expect(OAUTH_SURFACES.xai.providerName("probe")).toBe(
112+
xaiProviderName("probe"),
113+
);
114+
const profile = {
115+
name: "probe",
116+
tokens: { access: "probe", refresh: "probe", expiresAt: 0 },
117+
} as XaiProfile;
118+
const [entry] = xaiProfilesToCatalogEntries([profile]);
119+
expect(entry?.baseURL).toBe(XAI_OAUTH_PROXY_BASE_URL);
120+
expect(entry?.models).toEqual([...XAI_DEFAULT_MODELS]);
121+
expect(entry?.defaultModel).toBe(XAI_DEFAULT_MODELS[0]);
122+
});
123+
124+
test("Zen registry entry tracks the packaged seed catalog", () => {
125+
const zen = firstClassProviderById("zen");
126+
expect([...(zen?.models ?? [])]).toEqual([...ZEN_MODEL_IDS]);
127+
expect(zen?.defaultModel).toBe(ZEN_DEFAULT_MODEL);
128+
expect(ZEN_MODEL_IDS).toContain(ZEN_DEFAULT_MODEL);
129+
// Cold picker (no live snapshot yet) falls back to the same seed.
130+
expect([...selectableZenModelIds()]).toEqual([...ZEN_MODEL_IDS]);
131+
for (const id of ZEN_MODEL_IDS) {
132+
expect(isKnownZenModel(id)).toBe(true);
133+
}
134+
});
135+
136+
test("OpenCode Go registry entry tracks the packaged seed catalog", () => {
137+
const go = firstClassProviderById("opencode-go");
138+
expect([...(go?.models ?? [])]).toEqual([...OPENCODE_GO_MODEL_IDS]);
139+
expect(go?.defaultModel).toBe(OPENCODE_GO_DEFAULT_MODEL);
140+
expect(OPENCODE_GO_MODEL_IDS).toContain(OPENCODE_GO_DEFAULT_MODEL);
141+
expect([...selectableGoModelIds()]).toEqual([...OPENCODE_GO_MODEL_IDS]);
142+
for (const id of OPENCODE_GO_MODEL_IDS) {
143+
expect(isKnownGoModel(id)).toBe(true);
144+
}
145+
});
146+
});

‎src/tui/provider/choices.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export const TYPE_MODEL_ID = "__type_model__";
6464
* What a signed-in subscription provider resolves to. The endpoint and model
6565
* list are the same constants the auth stack projects into the catalog, so a
6666
* first run and a later `/model` connect land on the same provider entry.
67+
* These are views over the Codex/xAI live-fetch fallbacks, not a separate
68+
* list — identity-divergence.test.ts pins them to the fallback constants.
6769
*/
6870
export const OAUTH_SURFACES: Record<
6971
OAuthKind,

‎tests/unit/codex-providers.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,15 @@ describe("codex provider naming", () => {
2626
});
2727

2828
describe("CODEX_DEFAULT_MODELS", () => {
29-
test("includes the gpt-5.6 model family while keeping gpt-5.5 as the default", () => {
29+
test("includes the gpt-5.6 model family while defaulting to the shared OpenAI model", () => {
3030
expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-sol");
3131
expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-terra");
3232
expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.6-luna");
3333
expect(CODEX_DEFAULT_MODELS).toContain("gpt-6-astra");
34-
expect(CODEX_DEFAULT_MODELS[0]).toBe("gpt-5.5");
34+
expect(CODEX_DEFAULT_MODELS).toContain("gpt-5.5");
35+
// CL-5691: the ChatGPT-OAuth default agrees with the OpenAI API-key
36+
// path default (gpt-5.4) — both auth paths serve OpenAI.
37+
expect(CODEX_DEFAULT_MODELS[0]).toBe("gpt-5.4");
3538
});
3639
});
3740

0 commit comments

Comments
 (0)