Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/first-class-providers/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
7 changes: 5 additions & 2 deletions src/auth/codex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 0 additions & 1 deletion src/auth/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export {
export {
startCodexLogin,
openInBrowser,
codexProviderSurface,
type CodexLoginHandle,
type StartCodexLoginOptions,
} from "./login.js";
7 changes: 0 additions & 7 deletions src/auth/codex/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
type StartOAuthLoginOptions,
} from "@corbits/oauth-core";
import {
CODEX_BASE_URL,
codexOAuthConfig,
exchangeCodexCode,
type CodexTokens,
Expand All @@ -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 };
Expand All @@ -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;
3 changes: 3 additions & 0 deletions src/auth/xai/constants.ts
Original file line number Diff line number Diff line change
@@ -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 alias
// 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,
Expand Down
1 change: 0 additions & 1 deletion src/auth/xai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export {
} from "./session.js";
export {
startXaiLogin,
xaiProviderSurface,
type XaiLoginHandle,
type StartXaiLoginOptions,
} from "./login.js";
7 changes: 0 additions & 7 deletions src/auth/xai/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
146 changes: 146 additions & 0 deletions src/provider/identity-divergence.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
2 changes: 2 additions & 0 deletions src/tui/provider/choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions tests/unit/codex-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down
Loading