From c65c0599a879522ac2c3cbbc2b296af5ab6cd7d4 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:06:10 +0200 Subject: [PATCH] fix(codex): gate gpt-5.6 native models by entitlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol/Terra/Luna shipped as static native rows, so an account without upstream entitlement saw them advertised (Codex catalog, Claude gateway discovery, management rows) and every request died upstream with "model is not supported when using Codex with a ChatGPT account", relaid to Claude Code as repeated 502 stream truncations. Add the GPT-5.6 family to ACCOUNT_GATED_NATIVE_OPENAI_MODELS so the existing per-account /models roster evidence gates catalog projection, gateway discovery, and Pool/Direct dispatch โ€” the same mechanism #2097 shipped for Daybreak Blue. Unconfirmed rosters fail closed. Tests reworked to seed rosters where they exercise window/effort/toggle mechanics rather than gating, and to use ungated stand-ins where the slug was only an ordinary-native fixture. Refs #2548 --- src/codex/catalog/native-models.ts | 7 +- tests/agent-task-recovery.test.ts | 2 +- .../bearer-admission-routed-provider.test.ts | 12 +- tests/claude-desktop-native-context.test.ts | 7 ++ tests/claude-models-discovery.test.ts | 22 ++++ tests/codex-auth-api.test.ts | 2 +- tests/codex-auth-context.test.ts | 40 +++--- tests/codex-catalog-restore.test.ts | 18 +++ tests/codex-catalog-sync-hardening.test.ts | 28 +++-- tests/codex-catalog.test.ts | 6 +- ...odex-convergence-account-selectors.test.ts | 115 +++++++++++------- ...odex-envkey-admission-substitution.test.ts | 6 +- tests/codex-model-entitlements.test.ts | 13 +- tests/codex-refresh.test.ts | 6 +- tests/grok-models-effort-list.test.ts | 6 + tests/grok-sync.test.ts | 9 +- tests/helpers/native-main-owner-child.ts | 2 +- tests/issue-452-empty-503.test.ts | 10 +- tests/issue-702-expired-replay-state.test.ts | 22 ++-- tests/management-client-config-route.test.ts | 9 +- tests/native-model-toggle.test.ts | 36 +++++- tests/native-profile-crash-boundaries.test.ts | 2 +- tests/native-profile-startup.test.ts | 2 +- tests/openai-provider-option-e2e.test.ts | 16 +++ tests/responses-account-label.test.ts | 2 +- tests/responses-compaction-routing.test.ts | 21 +++- tests/server-auth.test.ts | 2 +- tests/server-combo-failover-e2e.test.ts | 6 +- ...subagent-fallback-handle-responses.test.ts | 108 +++++++++++++--- tests/vision-reasoning-contract.test.ts | 8 ++ tests/ws-upstream.test.ts | 10 +- 31 files changed, 394 insertions(+), 161 deletions(-) diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 3fd673f84b..4c63d89ec1 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -3,6 +3,9 @@ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest"; /** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */ export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", NATIVE_DAYBREAK_BLUE_MODEL, ]); @@ -58,8 +61,8 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string { * discover it on a clean install. * * Availability is not static: catalog sync and Pool routing require the account's authenticated - * `/models` roster to contain the slug. An unconfirmed or unentitled account never receives the - * request. `disabledModels` remains the independent user visibility control. + * `/models` roster to contain account-gated slugs. An unconfirmed or unentitled account never + * receives the request. `disabledModels` remains the independent user visibility control. * * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 ยง4-bis. */ diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index 44f5fa3913..78647e1075 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -540,7 +540,7 @@ describe("agent task recovery (opt-in, default off)", () => { const response = await post( routedConfig(), - "gpt-5.6-sol", + "gpt-5.5", encryptedInput(), codexHeaders(), ); diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts index be4449c144..51f1fd99ee 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -57,7 +57,7 @@ function mixedConfig(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, gateway: { adapter: "openai-chat", @@ -153,7 +153,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route const server = startServer(0); try { - const response = await postResponses(server.url, "gpt-5.6-luna"); + const response = await postResponses(server.url, "gpt-5.5"); // This is the #1686 guarantee and it must survive: a native route genuinely needs the // stored credential, so it fails BEFORE any upstream I/O rather than forwarding ours. @@ -175,7 +175,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route const server = startServer(0); try { - const response = await postResponses(server.url, "gpt-5.6-luna"); + const response = await postResponses(server.url, "gpt-5.5"); expect(response.status).toBe(200); expect(nativeAuth).toEqual([`Bearer ${stored}`]); @@ -213,7 +213,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, }, } as OcxConfig; @@ -225,7 +225,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const server = startServer(0); try { - const response = await postResponses(server.url, "mirror/gpt-5.6-luna"); + const response = await postResponses(server.url, "mirror/gpt-5.5"); // Fail-before-I/O is the contract (src/codex/auth-context.ts): the only two acceptable // outcomes for an admission bearer are replaced-with-stored-main, or refused. Reaching @@ -247,7 +247,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const server = startServer(0); try { - await postResponses(server.url, "mirror/gpt-5.6-luna"); + await postResponses(server.url, "mirror/gpt-5.5"); expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); for (const sent of nativeAuth) expect(sent).toBe(`Bearer ${stored}`); diff --git a/tests/claude-desktop-native-context.test.ts b/tests/claude-desktop-native-context.test.ts index acc181b384..7c36a0ffe0 100644 --- a/tests/claude-desktop-native-context.test.ts +++ b/tests/claude-desktop-native-context.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { buildClaudeDesktopState } from "../src/server/management/shared"; import { nativeOpenAiContextWindow, visibleNativeSlugs } from "../src/codex/catalog"; import { generateDesktop3pModels } from "../src/claude/desktop-3p"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; /** @@ -24,6 +28,8 @@ const config = { } as unknown as OcxConfig; test("buildClaudeDesktopState gives native rows their real context window", async () => { + // Sol/Terra/Luna are account-gated; this test is about window metadata, so confirm them. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const home = tempHome(); const prev = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = home; @@ -44,6 +50,7 @@ test("buildClaudeDesktopState gives native rows their real context window", asyn if (prev === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = prev; rmSync(home, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); } }); diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 19f58ac795..0fd20b156f 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -3,6 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, +} from "../src/codex/model-entitlements"; import { handleManagementAPI } from "../src/server/management-api"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; @@ -25,6 +28,7 @@ beforeEach(() => { }); afterEach(() => { + resetCodexModelEntitlementCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -159,6 +163,23 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { }); test("Codex discovery applies the OpenAI context cap to native rows (#1430)", async () => { + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "context-cap-access", account_id: "context-cap-account" }, + }), "utf8"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const entitled = request.headers.get("authorization") === "Bearer context-cap-access" + && request.headers.get("chatgpt-account-id") === "context-cap-account"; + const slugs = entitled ? ["gpt-5.6-sol"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } + return originalFetch(request); + }) as typeof fetch; const config = configWithStaticModels(); config.providers.openai = { adapter: "openai-responses", @@ -186,6 +207,7 @@ test("Codex discovery applies the OpenAI context cap to native rows (#1430)", as }); } finally { await server.stop(true); + globalThis.fetch = originalFetch; } }); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 4bf7876312..c8e552771f 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3485,7 +3485,7 @@ describe("codex-auth API", () => { listOpenAiForwardSidecarCandidates(config), new Headers(), config, - { exactAccount: { accountId: "pool-delete", modelId: "gpt-5.6-sol" } }, + { exactAccount: { accountId: "pool-delete", modelId: "gpt-5.5" } }, ); expect(exactSidecar?.authContext).toMatchObject({ accountId: "pool-delete", diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 5a3372c137..da4d6cb5d0 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -450,7 +450,7 @@ describe("Codex auth context", () => { }); let discoveries = 0; await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", resolveCodexModelEntitlements: async () => { discoveries += 1; throw new Error("must not run"); @@ -479,7 +479,7 @@ describe("Codex auth context", () => { const exactContext = await resolveCodexAuthContext(headers, cfg, "direct", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", }); expect(exactContext).toMatchObject({ kind: "pool", @@ -493,7 +493,7 @@ describe("Codex auth context", () => { _codexAccountOverride: { accessToken: "fixed_pool_token", chatgptAccountId: "fixed_pool_acc" }, }); expect(cfg.activeCodexAccountId).toBe("pool-b"); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-sol" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.5" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); @@ -706,7 +706,7 @@ describe("Codex auth context", () => { // The pool path honors the order: pool-b outranks pool-a, so an unbound request // prefers pool-b. - await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { modelId: "gpt-5.6-sol" })) + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { modelId: "gpt-5.5" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); // An exact selector names pool-a: it must resolve to pool-a even though pool-b is @@ -714,7 +714,7 @@ describe("Codex auth context", () => { // way to redirect a request that already named its account. await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a", @@ -735,7 +735,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN_CODEX_ACCOUNT_ID, - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).resolves.toMatchObject({ kind: "main-pool", accountId: MAIN_CODEX_ACCOUNT_ID, @@ -841,7 +841,7 @@ describe("Codex auth context", () => { expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account is unavailable"); expect(cfg.activeCodexAccountId).toBe("pool-a"); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) @@ -868,7 +868,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account needs reauthentication"); expect(cfg.activeCodexAccountId).toBe("pool-b"); }); @@ -885,7 +885,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account is unavailable"); expect(cfg.activeCodexAccountId).toBe("pool-a"); }); @@ -905,18 +905,18 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { resetAt: Math.floor((now + 60 * 60_000) / 1_000), now, - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", fixedAccount: true, }); Date.now = () => now + CODEX_QUOTA_PROBE_INTERVAL_MS; await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toBeInstanceOf(CodexAccountCooldownError); const ordinaryProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool", { - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", }); expect(ordinaryProbe).toMatchObject({ kind: "pool", accountId: "pool-a" }); expect(ordinaryProbe.kind === "pool" ? ordinaryProbe.probeLeaseId : undefined).toBeTruthy(); @@ -1079,7 +1079,7 @@ describe("Codex auth context", () => { }); // Spark owns a separate quota, so Terra can use the same account. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1087,12 +1087,12 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.6-terra", + modelId: "gpt-5.4", }); // Terra and Luna stay in the shared native quota group, while Spark keeps // its independent cooldown instead of being overwritten by Terra's 429. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1104,7 +1104,7 @@ describe("Codex auth context", () => { retryAfter: "60", modelId: "gpt-5.3-codex-spark", }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); } finally { Date.now = originalNow; @@ -1137,7 +1137,7 @@ describe("Codex auth context", () => { Date.now = () => now; // Establish the shared-scope binding first. The Spark fallback below must // create a second binding rather than replacing this one. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); recordCodexUpstreamOutcome(cfg, "pool-a", 429, { @@ -1149,7 +1149,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); expect(cfg.activeCodexAccountId).toBe("pool-a"); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); // This second Spark request proves routing retained the peer choice for // the Spark affinity instead of relying on an auth-layer substitution. @@ -1182,7 +1182,7 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.6-terra", + modelId: "gpt-5.4", }); const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; @@ -1205,7 +1205,7 @@ describe("Codex auth context", () => { Date.now = () => probeAt + 1; await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) .resolves.toMatchObject({ kind: "pool", probeQuotaScope: "shared" }); } finally { Date.now = originalNow; diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index f689d80eb2..253c42f449 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -270,6 +270,8 @@ describe("Codex catalog restore", () => { const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); + const { seedCodexModelEntitlementsForTests } = require("./src/codex/model-entitlements"); + seedCodexModelEntitlementsForTests("__main__", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); (async () => { const result = await syncCatalogModels({ port: 10100, @@ -291,6 +293,9 @@ describe("Codex catalog restore", () => { test("sync advertises documented Codex-native additions omitted by the bundled catalog", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "catalog-main-access", account_id: "catalog-main-account" }, + }), "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ { @@ -314,6 +319,19 @@ describe("Codex catalog restore", () => { const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method !== "GET" || url.pathname !== "/backend-api/codex/models") { + throw new Error("unexpected fetch: " + request.method + " " + url.href); + } + const entitled = request.headers.get("authorization") === "Bearer catalog-main-access" + && request.headers.get("chatgpt-account-id") === "catalog-main-account"; + const slugs = entitled ? ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + }; (async () => { const result = await syncCatalogModels({ port: 10100, diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 29d84ff661..c00e96923d 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -108,7 +108,7 @@ describe("Codex catalog sync hardening", () => { if (existsSync(opencodexHome)) rmSync(opencodexHome, { recursive: true, force: true }); }); - test("Gap B: drops legacy OpenAI-family natives but keeps supported + user natives", () => { + test("Gap B: drops legacy and unentitled account-gated natives but keeps supported + user natives", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -138,9 +138,11 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.4"); expect(slugs).toContain("gpt-5.4-mini"); expect(slugs).toContain("gpt-5.3-codex-spark"); - expect(slugs).toContain("gpt-5.6-sol"); - expect(slugs).toContain("gpt-5.6-terra"); - expect(slugs).toContain("gpt-5.6-luna"); + // This isolated fixture has no authenticated ChatGPT roster, so account-gated + // native models must fail closed rather than remain selectable. + expect(slugs).not.toContain("gpt-5.6-sol"); + expect(slugs).not.toContain("gpt-5.6-terra"); + expect(slugs).not.toContain("gpt-5.6-luna"); expect(slugs).toContain("user-native"); // genuine user native preserved expect(slugs).not.toContain("gpt-5.3-codex"); // legacy dropped expect(slugs).not.toContain("gpt-5.2"); // legacy dropped @@ -153,8 +155,8 @@ describe("Codex catalog sync hardening", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ { - ...nativeEntry("gpt-5.6-sol", 0), - display_name: "Original Sol", + ...nativeEntry("gpt-5.5", 0), + display_name: "Original GPT-5.5", comp_hash: "native-sol-hash", base_instructions: "Native Sol instructions", model_messages: { instructions_template: "Native Sol instructions" }, @@ -178,17 +180,17 @@ describe("Codex catalog sync hardening", () => { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", liveModels: false, - models: ["codex/gpt-5.6-sol"] + models: ["codex/gpt-5.5"] } }, codexAccounts: [{ id: "stored-team-account", isMain: false }], codexAccountNamespaces: { team: "stored-team-account" }, combos: { "nova-sol": { - alias: "gpt-5.6-sol", + alias: "gpt-5.5", nativeAlias: true, - displayName: "Nova Sol", - targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }] + displayName: "Nova GPT-5.5", + targets: [{ provider: "Nova1", model: "codex/gpt-5.5" }] } } }; @@ -205,13 +207,13 @@ describe("Codex catalog sync hardening", () => { tool_mode?: string | null; opencodex_catalog_kind?: string; }>; - expect(rows.filter(row => row.slug === "gpt-5.6-sol")).toEqual([ + expect(rows.filter(row => row.slug === "gpt-5.5")).toEqual([ expect.objectContaining({ - display_name: "Nova Sol", + display_name: "Nova GPT-5.5", opencodex_catalog_kind: "combo-native-alias-v1", }), ]); - expect(rows.find(row => row.slug === "team/gpt-5.6-sol")).toMatchObject({ + expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ comp_hash: "native-sol-hash", base_instructions: "Native Sol instructions", model_messages: { instructions_template: "Native Sol instructions" }, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index bf5656779b..2cfdfa1ce5 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -18,6 +18,7 @@ import { cursorModelReasoningEfforts, } from "../src/adapters/cursor/discovery"; import { getModelMetadata, resolveMetadataProvider } from "../src/generated/model-metadata"; +import { resetCodexModelEntitlementCacheForTests, seedCodexModelEntitlementsForTests } from "../src/codex/model-entitlements"; import { clearModelCache, getProviderDiscoveryStatus, @@ -57,6 +58,7 @@ afterEach(() => { globalThis.fetch = originalFetch; clearModelCache(); resetOpenAiApiCatalogWarningStateForTests(); + resetCodexModelEntitlementCacheForTests(); }); function normalizedCombo( @@ -1258,7 +1260,9 @@ describe("combo catalog capability intersection", () => { // The "openai" provider uses forward-auth (Codex login passthrough) โ€” fetchProviderModels // returns [] for it, so native slugs only surface through nativeOpenAiSlugs(). Before the // fix, memberByKey never contained openai/, so combos with a native-openai target were - // silently dropped from the catalog. + // silently dropped from the catalog. Sol is account-gated now, so the combo's native member + // needs a confirmed roster to be visible at all. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; const config: OcxConfig = { port: 10100, diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index a0e367091b..9206aaa9e4 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -46,7 +46,7 @@ import { markModelsFetchFailure } from "../src/codex/model-cache"; import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; -import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { removeCodexAccountCredential, saveCodexAccountCredential } from "../src/codex/account-store"; // The canonical-bytes case spawns real syncs and runs ~2.5s in isolation, on this // tree and on a clean baseline alike. That is half of bun's 5s default, but full @@ -62,6 +62,16 @@ let catalogPath = ""; let previousCodexHome: string | undefined; let previousOpencodexHome: string | undefined; let previousCodexCliPath: string | undefined; +let previousFetch: typeof fetch; +let modelRostersByChatgptAccount: Map; + +const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; + +function grantGpt56NativeModels(...chatgptAccountIds: string[]): void { + for (const accountId of chatgptAccountIds) { + modelRostersByChatgptAccount.set(accountId, GPT56_NATIVE_MODELS); + } +} function nativeEntry(visibility = "list"): RawEntry { return { @@ -265,12 +275,38 @@ beforeEach(() => { mkdirSync(opencodexHome); process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = opencodexHome; + previousFetch = globalThis.fetch; + modelRostersByChatgptAccount = new Map(); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + })); + saveCodexAccountCredential("side-account-id", { + accessToken: "side-token", + refreshToken: "side-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "side-chatgpt-account", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + const accountId = new Headers(init?.headers).get("chatgpt-account-id") ?? ""; + return Response.json({ + models: (modelRostersByChatgptAccount.get(accountId) ?? []).map(slug => ({ + slug, + supported_in_api: true, + visibility: "list", + })), + }); + } + return previousFetch(input, init); + }) as typeof fetch; resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); resetCodexModelEntitlementCacheForTests(); }); afterEach(() => { + globalThis.fetch = previousFetch; const identity = resolveEffectiveUserIdentity(); const serializationDb = resolveCodexCatalogSerializationDatabasePath(identity, codexHome); for (const suffix of ["", "-journal", "-wal", "-shm"]) { @@ -287,6 +323,7 @@ afterEach(() => { }); test("convergence renders account-qualified rows and preserves only non-generated foreign rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([ nativeEntry(), accountEntry("stale-selector"), @@ -326,6 +363,7 @@ test("convergence renders account-qualified rows and preserves only non-generate }); test("convergence preserves one configured soft budget on bare and account-native rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([nativeEntry()]); const nextConfig = config(true); nextConfig.providers.openai!.modelAutoCompactTokenLimits = { "gpt-5.6-sol": 120_000 }; @@ -341,6 +379,7 @@ test("convergence preserves one configured soft budget on bare and account-nativ }); test("disabling the picker removes generated rows, restores bare rows, and retains foreign rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([ nativeEntry("hide"), accountEntry("desktop"), @@ -377,6 +416,7 @@ test("convergence drops unsupported bare native rows and never qualifies them", test("convergence projects the observed Daybreak row onto its selector and one bare row", async () => { writeCatalog([nativeEntry()]); + removeCodexAccountCredential("side-account-id"); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, })); @@ -394,24 +434,8 @@ test("convergence projects the observed Daybreak row onto its selector and one b }], }, null, 2) + "\n"); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); - if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { - return Response.json({ models: [{ - slug: "gpt-daybreak-blue-latest", - supported_in_api: true, - visibility: "list", - }] }); - } - return originalFetch(input, init); - }) as typeof fetch; - let catalog: RawCatalog; - try { - catalog = await convergeCatalog(config(true)); - } finally { - globalThis.fetch = originalFetch; - } + modelRostersByChatgptAccount.set("main-chatgpt-account", ["gpt-daybreak-blue-latest"]); + const catalog = await convergeCatalog(config(true)); const models = catalog.models ?? []; const daybreak = models.find(entry => entry.slug === "desktop/gpt-daybreak-blue-latest"); expect(daybreak).toMatchObject({ @@ -447,28 +471,14 @@ test("Direct convergence does not borrow a Pool-only Daybreak grant for the bare chatgptAccountId: "side-chatgpt-account", }); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); - if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { - const accountId = new Headers(init?.headers).get("chatgpt-account-id"); - return Response.json({ models: [ - { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, - ...(accountId === "side-chatgpt-account" - ? [{ slug: "gpt-daybreak-blue-latest", supported_in_api: true, visibility: "list" }] - : []), - ] }); - } - return originalFetch(input, init); - }) as typeof fetch; + modelRostersByChatgptAccount.set("main-chatgpt-account", ["gpt-5.6-sol"]); + modelRostersByChatgptAccount.set( + "side-chatgpt-account", + ["gpt-5.6-sol", "gpt-daybreak-blue-latest"], + ); const directConfig = config(true); directConfig.providers.openai!.codexAccountMode = "direct"; - let catalog: RawCatalog; - try { - catalog = await convergeCatalog(directConfig); - } finally { - globalThis.fetch = originalFetch; - } + const catalog = await convergeCatalog(directConfig); const models = catalog.models ?? []; expect(models.filter(entry => entry.slug === "gpt-daybreak-blue-latest")).toHaveLength(0); @@ -621,7 +631,7 @@ test("degraded preservation still honors explicit routed visibility policy", asy expect(models.some(entry => entry.slug === "offline/unselected-old")).toBe(false); }); -test("custom-catalog convergence reports network degradation without a fallback notice", async () => { + test("custom-catalog convergence reports network degradation without a fallback notice", async () => { catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync( join(codexHome, "config.toml"), @@ -630,6 +640,9 @@ test("custom-catalog convergence reports network degradation without a fallback primeCodexRuntimeFixture(); writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); const nextConfig = config(false); + nextConfig.codexAccounts = []; + nextConfig.codexAccountNamespaces = {}; + rmSync(join(codexHome, "auth.json"), { force: true }); nextConfig.providers.offline = { adapter: "openai-chat", baseUrl: "https://offline.example.test/v1", @@ -720,6 +733,9 @@ test("OAuth admission degradation is auth-only and does not masquerade as a netw primeCodexRuntimeFixture(); writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); const nextConfig = config(false); + nextConfig.codexAccounts = []; + nextConfig.codexAccountNamespaces = {}; + rmSync(join(codexHome, "auth.json"), { force: true }); nextConfig.providers.offline = { adapter: "openai-chat", baseUrl: "https://offline.example.test/v1", @@ -770,6 +786,7 @@ test("disabled-provider selections cannot delete a foreign row in either writer" }); test("convergence clamps native, routed, and account rows to observed runtime support", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); seedObservedRuntimeSupport(); writeCatalog([nativeEntry()]); const nextConfig = config(true); @@ -808,6 +825,7 @@ test("convergence clamps native, routed, and account rows to observed runtime su }); test("generated account rows silently win freshly gathered provider collisions", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([nativeEntry()]); const nextConfig = config(true); nextConfig.providers.team = { @@ -890,20 +908,26 @@ test("retained sync and convergence produce identical canonical bytes in either if (ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)) expect(slugs).not.toContain(slug); else expect(slugs).toContain(slug); } + for (const entry of models.filter(entry => ( + entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + ))) { + const baseSlug = entry.slug?.slice(entry.slug.indexOf("/") + 1); + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(baseSlug ?? "")).toBe(false); + } expect(slugs).not.toContain("gpt-legacy-unsupported"); expect(slugs).toContain("user-native"); - expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility) + expect(models.find(entry => entry.slug === "gpt-5.5")?.visibility) .toBe(pickerEnabled ? "hide" : "list"); expect(models.some(entry => ( entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND ))).toBe(pickerEnabled); if (pickerEnabled) { - expect(models.find(entry => entry.slug === "desktop/gpt-5.6-sol")).toMatchObject({ - display_name: "desktop / 5.6 Sol", + expect(models.find(entry => entry.slug === "desktop/gpt-5.5")).toMatchObject({ + display_name: "desktop / 5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, }); - expect(models.find(entry => entry.slug === "team/gpt-5.6-sol")).toMatchObject({ - display_name: "team / 5.6 Sol", + expect(models.find(entry => entry.slug === "team/gpt-5.5")).toMatchObject({ + display_name: "team / 5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, }); } @@ -1040,6 +1064,7 @@ test("convergence refuses a combo shadow when every backup target is present but }); test("both writers restore pristine native priorities after featured-model transitions", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); primeCodexRuntimeFixture(); catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync( diff --git a/tests/codex-envkey-admission-substitution.test.ts b/tests/codex-envkey-admission-substitution.test.ts index 55b1b2a63a..307c5f667c 100644 --- a/tests/codex-envkey-admission-substitution.test.ts +++ b/tests/codex-envkey-admission-substitution.test.ts @@ -48,7 +48,7 @@ function directConfig(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, }, apiKeys: [ @@ -105,7 +105,7 @@ async function postResponses(url: string | URL, authorization: string): Promise< return originalFetch(new URL("/v1/responses", url), { method: "POST", headers: { "content-type": "application/json", authorization }, - body: JSON.stringify({ model: "gpt-5.6-luna", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); } @@ -113,7 +113,7 @@ async function postCompact(url: string | URL, authorization: string): Promise { fetcher: (async (_input, init) => { const accountId = new Headers(init?.headers).get("chatgpt-account-id"); return accountId === "chatgpt-main" - ? roster("gpt-5.6-sol", DAYBREAK) - : roster("gpt-5.6-sol"); + ? roster(SOL, LUNA, DAYBREAK) + : roster(SOL, TERRA); }) as typeof fetch, now: 1_000, }); expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]).toEqual(["main"]); - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); - expect(entitledCodexAccountIdsForModel(snapshot, "gpt-5.6-sol")).toBeUndefined(); + expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main", "secondary"]); + expect([...entitledCodexAccountIdsForModel(snapshot, TERRA)!]).toEqual(["secondary"]); + expect([...entitledCodexAccountIdsForModel(snapshot, LUNA)!]).toEqual(["main"]); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA, DAYBREAK]); }); test("fails closed when an account roster cannot be confirmed", async () => { diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts index a234b7a401..7b5833383c 100644 --- a/tests/codex-refresh.test.ts +++ b/tests/codex-refresh.test.ts @@ -159,8 +159,10 @@ describe("Codex catalog refresh", () => { expect(result.path).toBe(join(realpathSync.native(home.codexHome), "nested", "catalog.json")); expect(result.catalogWritten).toBe(true); expect(after).not.toBe(before); - expect(rewritten.models[0].slug).toBe("gpt-5.6-sol"); - expect(rewritten.models[0].display_name).toBe("GPT-5.6-Sol"); + // The fixture seeds gated Sol rows, but this isolated home has no authenticated + // roster, so sync drops them and the first surviving row is gpt-5.5. + expect(rewritten.models[0].slug).toBe("gpt-5.5"); + expect(rewritten.models[0].display_name).toBe("gpt-5.5"); expect(rewritten.models[0].context_window).toBeGreaterThan(0); } finally { home.restore(); diff --git a/tests/grok-models-effort-list.test.ts b/tests/grok-models-effort-list.test.ts index 3e0a353662..e4e0d33d95 100644 --- a/tests/grok-models-effort-list.test.ts +++ b/tests/grok-models-effort-list.test.ts @@ -3,6 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; @@ -44,6 +48,7 @@ beforeEach(() => { }); afterEach(() => { + resetCodexModelEntitlementCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (testHome) rmSync(testHome, { recursive: true, force: true }); @@ -52,6 +57,7 @@ afterEach(() => { describe("raw /v1/models list reasoning-effort advertisement (Grok Build discovery)", () => { test("routed models with configured tiers advertise the Grok reasoning catalog shape", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const config = effortConfig(); config.providers.openai = { adapter: "openai-responses", diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index d1593a949a..0ed57bcfb9 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,10 +6,16 @@ import { injectGrokConfig } from "../src/grok/inject"; import { syncGrokConfig } from "../src/grok/sync"; import { nativeOpenAiContextWindow, visibleNativeSlugs } from "../src/codex/catalog"; import type { CatalogModel } from "../src/codex/catalog"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; const baseConfig = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; +afterEach(() => resetCodexModelEntitlementCacheForTests()); + function tempGrokHome(): { root: string; grokHome: string } { const root = mkdtempSync(join(tmpdir(), "ocx-grok-sync-")); const grokHome = join(root, ".grok"); @@ -44,6 +50,7 @@ describe("syncGrokConfig", () => { // and Grok fell back to its own 200k default โ€” understating gpt-5.6-sol, which is 372k. The // window comes from the same accessor the dashboard uses, so the two surfaces agree. test("native slugs carry their real context window, not Grok's 200k default", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const { root, grokHome } = tempGrokHome(); try { const result = await syncGrokConfig(10190, baseConfig, { grokHome }, { diff --git a/tests/helpers/native-main-owner-child.ts b/tests/helpers/native-main-owner-child.ts index 26d261ec0b..b70db3dde0 100644 --- a/tests/helpers/native-main-owner-child.ts +++ b/tests/helpers/native-main-owner-child.ts @@ -183,7 +183,7 @@ async function request(port: number, kind: string): Promise<{ status: number; te const response = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: kind, stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: kind, stream: false }), }); return { status: response.status, text: await response.text() }; } diff --git a/tests/issue-452-empty-503.test.ts b/tests/issue-452-empty-503.test.ts index 53ad74ff6e..7eac48b321 100644 --- a/tests/issue-452-empty-503.test.ts +++ b/tests/issue-452-empty-503.test.ts @@ -249,7 +249,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(503); const text = await response.text(); @@ -275,7 +275,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(418); expect(response.headers.get("content-type")).toContain("application/json"); @@ -297,7 +297,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(status); expect(response.headers.get("content-type")).toContain("application/json"); @@ -317,7 +317,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBeNull(); @@ -333,7 +333,7 @@ describe("passthrough empty 503 (#452)", () => { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", messages: [{ role: "user", content: "hi" }], stream: false, }), diff --git a/tests/issue-702-expired-replay-state.test.ts b/tests/issue-702-expired-replay-state.test.ts index 239d305746..5a618c4be7 100644 --- a/tests/issue-702-expired-replay-state.test.ts +++ b/tests/issue-702-expired-replay-state.test.ts @@ -93,7 +93,7 @@ function completedSse(responseId: string, text: string): string { response: { id: responseId, status: "completed", - model: "gpt-5.6-sol", + model: "gpt-5.5", output: [item], }, })}`, @@ -165,7 +165,7 @@ async function runForwardScenario( method: "POST", headers: requestHeaders, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", input: [inputMessage(HISTORICAL_USER_SENTINEL)], stream: true, store: false, @@ -192,7 +192,7 @@ async function runForwardScenario( method: "POST", headers: { ...requestHeaders, ...resumeHeaders }, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", previous_response_id: FIRST_RESPONSE_ID, input: [inputMessage(CURRENT_USER_SENTINEL)], stream: true, @@ -249,7 +249,7 @@ describe("Issue #702 expired forward replay state", () => { const responseId = "resp_issue_702_missing_spill"; setResponseStateByteCapForTests(1_024); rememberResponseState( - { model: "openai/gpt-5.6-sol", input: "x".repeat(8_000), store: false }, + { model: "openai/gpt-5.5", input: "x".repeat(8_000), store: false }, { id: responseId, status: "completed", output: [{ role: "assistant", content: "done" }] }, undefined, { force: true }, @@ -265,7 +265,7 @@ describe("Issue #702 expired forward replay state", () => { throw new Error("upstream must not be called"); }) as typeof fetch; const routeClasses: Array<{ config: OcxConfig; model: string }> = [ - { config: forwardConfig(), model: "gpt-5.6-sol" }, + { config: forwardConfig(), model: "gpt-5.5" }, { config: { port: 0, @@ -278,11 +278,11 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: "https://runtime.us-east-1.kiro.dev", authMode: "key", apiKey: "synthetic-token", - models: ["gpt-5.6-sol"], + models: ["gpt-5.5"], }, }, } as OcxConfig, - model: "kiro-test/gpt-5.6-sol", + model: "kiro-test/gpt-5.5", }, { config: { @@ -296,11 +296,11 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "provider-key", - models: ["gpt-5.6-sol"], + models: ["gpt-5.5"], }, }, } as OcxConfig, - model: "test-openai/gpt-5.6-sol", + model: "test-openai/gpt-5.5", }, ]; @@ -418,7 +418,7 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, apiKey: "provider-key", - defaultModel: "gpt-5.6-sol", + defaultModel: "gpt-5.5", }, }, } as OcxConfig); @@ -428,7 +428,7 @@ describe("Issue #702 expired forward replay state", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "test-openai/gpt-5.6-sol", + model: "test-openai/gpt-5.5", previous_response_id: "resp_upstream_native_state", input: [inputMessage(CURRENT_USER_SENTINEL)], stream: true, diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index baee2a34d2..0df9ba44b8 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { join } from "node:path"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import { handleManagementAPI } from "../src/server/management-api"; import { OPENCODE_API_KEY_ENV, @@ -24,6 +28,8 @@ import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; */ const REAL_LOOKING_KEY = "ocx_live_9f3c7a2b41d84e6fa05c8e17b3d92764"; +afterEach(() => resetCodexModelEntitlementCacheForTests()); + interface ClientConfigEnvelope { client: string; filename: string; @@ -190,6 +196,7 @@ describe("GET /api/client-config", () => { }, 15_000); test("DSH response keeps management reasoning metadata in the rc.6 model map", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-luna"]); const response = await clientConfigApi(baseConfig(), "?client=dsh"); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index b8d153db6c..fcf42e07dc 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeDisplayName, @@ -31,6 +31,13 @@ import { afterEach(() => resetCodexModelEntitlementCacheForTests()); +// Most of this file exercises visibility/window mechanics on Sol/Terra/Luna rows. They are +// account-gated now, so give them a confirmed main roster up front; the two gating-specific +// tests below reset the cache to assert the unconfirmed baseline first. +beforeEach(() => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); +}); + function makeConfig(overrides: Partial = {}): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", ...overrides } as OcxConfig; } @@ -71,16 +78,23 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("nativeModelRows hides account-gated ids until an authenticated roster confirms them", () => { + resetCodexModelEntitlementCacheForTests(); const rows = nativeModelRows({ disabledModels: ["gpt-5.6-sol"] }); expect(rows.map(r => r.slug)).toEqual( NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), ); - expect(rows.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); - expect(rows.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); + + seedCodexModelEntitlementsForTests( + "main", + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-daybreak-blue-latest"], + ); + const confirmed = nativeModelRows({ disabledModels: ["gpt-5.6-sol"] }); + expect(confirmed.map(r => r.slug)).toEqual(NATIVE_OPENAI_MODELS); + expect(confirmed.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); + expect(confirmed.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); // Known context metadata rides along for the dashboard. - expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); + expect(confirmed.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); - seedCodexModelEntitlementsForTests("main", ["gpt-daybreak-blue-latest"]); expect(nativeModelRows({ disabledModels: [] }).map(row => row.slug)) .toContain("gpt-daybreak-blue-latest"); }); @@ -648,6 +662,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs", async () => { + resetCodexModelEntitlementCacheForTests(); const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); const modelsRes = await handleManagementAPI( @@ -658,7 +673,16 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(nativeRows.map(r => r.namespaced)).toEqual( NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), ); - expect(nativeRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); + + // A confirmed roster makes the gated rows selectable again; a bare disable still wins. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const confirmedRes = await handleManagementAPI( + new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, + ); + const confirmedRows = (await confirmedRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>) + .filter(r => r.native); + expect(confirmedRows.map(r => r.namespaced)).toContain("gpt-5.6-sol"); + expect(confirmedRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); // Native rows lead the response so the GUI pins the group first. expect(rows[0]?.native).toBe(true); diff --git a/tests/native-profile-crash-boundaries.test.ts b/tests/native-profile-crash-boundaries.test.ts index 66bc308936..d54a85495f 100644 --- a/tests/native-profile-crash-boundaries.test.ts +++ b/tests/native-profile-crash-boundaries.test.ts @@ -184,7 +184,7 @@ function spawnStartup( } async function mainRequest(port: number) { - return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5.6-sol", input: "crash recovery", stream: false }) }); + return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5.5", input: "crash recovery", stream: false }) }); } const boundaries: Array<{ diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 247f2df7fa..921796a6fd 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -298,7 +298,7 @@ async function mainRequest(port: number): Promise { return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "startup gate", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "startup gate", stream: false }), }); } diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index e680675418..104119a9ba 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -176,6 +176,19 @@ describe("OpenAI provider-option integration spine", () => { rate_limit: { secondary_window: { used_percent: isAdded ? 10 : 90 } }, }); } + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const authorization = request.headers.get("authorization"); + const accountId = request.headers.get("chatgpt-account-id"); + const explicitlyEntitled = accountId === "fixture-main-account" + || accountId === "fixture-pool-account" + || authorization === "Bearer fixture-caller-main"; + const slugs = explicitlyEntitled + ? ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] + : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } const upstreamTuple = `${request.method} ${url.href}`; if (!upstreamTuples.has(upstreamTuple)) { throw new Error(`deny-by-default fetch blocked: ${upstreamTuple}`); @@ -214,6 +227,7 @@ describe("OpenAI provider-option integration spine", () => { websocketRegistry, requestLog, catalog, + modelEntitlements, serverModule, mainAccount, sidecar, @@ -227,6 +241,7 @@ describe("OpenAI provider-option integration spine", () => { import("../src/codex/websocket-registry"), import("../src/server/request-log"), import("../src/codex/catalog"), + import("../src/codex/model-entitlements"), import("../src/server"), import("../src/codex/main-account"), import("../src/providers/openai-sidecar"), @@ -235,6 +250,7 @@ describe("OpenAI provider-option integration spine", () => { resets.push( requestLog.clearRequestLogsForTests, catalog.resetCatalogRuntimeStateForTests, + modelEntitlements.resetCodexModelEntitlementCacheForTests, routing.clearThreadAccountMap, routing.clearCodexUpstreamHealth, authApi.clearAccountQuota, diff --git a/tests/responses-account-label.test.ts b/tests/responses-account-label.test.ts index ebb0f76636..f17b58b18c 100644 --- a/tests/responses-account-label.test.ts +++ b/tests/responses-account-label.test.ts @@ -48,7 +48,7 @@ function request(): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); } diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 6ec6473503..10d45dda23 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -232,11 +232,11 @@ describe("Codex auth-context error parity (#2392)", () => { ]; function regularAuthRequest(): Request { - return compactionRequest({ model: "gpt-5.6-sol", input: "hello", stream: false }); + return compactionRequest({ model: "gpt-5.5", input: "hello", stream: false }); } function compactAuthRequest(): Request { - return compactionRequest(baseCompactionBody({ model: "gpt-5.6-sol" })); + return compactionRequest(baseCompactionBody({ model: "gpt-5.5" })); } test.each(cases)("maps $label identically on regular and compact Responses", async testCase => { @@ -393,7 +393,16 @@ describe("native Codex pool compaction", () => { expiresAt: Date.now() + 300_000, chatgptAccountId: "pool_acc", }); - globalThis.fetch = (async () => { + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const accountId = request.headers.get("chatgpt-account-id"); + const slugs = accountId === "pool_acc" ? ["gpt-5.6-terra"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } if (sparkPhase) { return Response.json({ error: { message: "Spark quota exhausted" } }, { status: 429, @@ -907,7 +916,7 @@ describe("compact alternate-account attempt (#913)", () => { }) as typeof fetch; const res = await handleResponsesCompact( - compactionRequest(baseCompactionBody({ model: "side/gpt-5.6-sol" })), + compactionRequest(baseCompactionBody({ model: "side/gpt-5.5" })), config, { model: "", provider: "" }, ); @@ -1191,7 +1200,7 @@ describe("compact alternate-account attempt (#913)", () => { const request = new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); await expect(handleResponses(request, config, { model: "", provider: "" })) .rejects.toThrow("synthetic build failure"); @@ -1216,7 +1225,7 @@ describe("compact alternate-account attempt (#913)", () => { const request = () => new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); const authSpy = spyOn(authContextModule, "resolveCodexAuthContext"); try { diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index de2de9fc42..401fcde4af 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -150,7 +150,7 @@ afterEach(() => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); -const POOL_RETRY_MODEL = "gpt-5.6-sol"; +const POOL_RETRY_MODEL = "gpt-5.5"; function unsupportedModelBody(model = POOL_RETRY_MODEL): string { return JSON.stringify({ diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 96348e7401..bfcced0f35 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1123,7 +1123,7 @@ describe("server combo failover 030 activation matrix", () => { }, }, [ { provider: "openai", model: "gpt-5.3-codex-spark" }, - { provider: "openai", model: "gpt-5.6-terra" }, + { provider: "openai", model: "gpt-5.5" }, ]); config.codexAccounts = [{ id: rawAccountId, @@ -1150,13 +1150,13 @@ describe("server combo failover 030 activation matrix", () => { headers: { "x-codex-primary-reset-at": String(resetAt) }, }); } - return Response.json(responsesSuccess("Terra fallback", "gpt-5.6-terra")); + return Response.json(responsesSuccess("Shared-native fallback", "gpt-5.5")); }; const response = await post(config); expect(response.status).toBe(200); expect(upstreamCalls).toBe(2); - expect(await response.json()).toMatchObject({ model: "gpt-5.6-terra" }); + expect(await response.json()).toMatchObject({ model: "gpt-5.5" }); }); test("keeps a failed estimate on A without overwriting B reported usage", async () => { diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 937b0aadeb..288786f2d6 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -27,6 +27,9 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; +import { + resetCodexModelEntitlementCacheForTests, +} from "../src/codex/model-entitlements"; import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { isEagerRelaySseResponse } from "../src/server/relay"; @@ -52,6 +55,9 @@ beforeEach(() => { clearCodexUpstreamHealth(); clearAccountQuota(); resetSubagentModelFallbackStateForTests(); + // Gated-native negative rosters are cached process-wide for 15s; a real-network + // miss in one test must not fail-closed the next test's entitlement lookups. + resetCodexModelEntitlementCacheForTests(); }); afterEach(() => { @@ -77,6 +83,7 @@ function fernetFixture(ciphertextBytes = 16): string { } const FERNET_TASK = fernetFixture(); +const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; function encryptedAgentInput(): unknown[] { return [{ @@ -148,21 +155,57 @@ function installPoolCredential(accountId: string, chatgptAccountId: string, now: }); } +function isCodexModelsFetch(input: unknown): boolean { + try { + const url = new URL(String(input)); + return url.hostname === "chatgpt.com" && url.pathname.endsWith("/models"); + } catch { + return false; + } +} + +function codexRosterResponse(slugs: readonly string[]): Response { + return Response.json({ + models: slugs.map(slug => ({ + slug, supported_in_api: true, visibility: "list", + })), + }); +} + +function codexRosterKey(headers: Headers): string { + return headers.get("chatgpt-account-id") ?? headers.get("authorization") ?? ""; +} + +function installCodexRosterMock(rostersByCredential: Readonly>): void { + globalThis.fetch = (async (input, init) => { + if (isCodexModelsFetch(input)) { + const credential = codexRosterKey(new Headers(init?.headers)); + return codexRosterResponse(rostersByCredential[credential] ?? []); + } + return originalFetch(input, init); + }) as typeof fetch; +} + function mockUpstream(capture: { urls: string[]; bodies: string[]; auths: Array; -}): void { +}, rostersByCredential: Readonly> = {}): void { globalThis.fetch = (async (input, init) => { - capture.urls.push(String(input)); - capture.bodies.push(typeof init?.body === "string" ? init.body : ""); const headers = new Headers(init?.headers); + if (isCodexModelsFetch(input)) { + const credential = codexRosterKey(headers); + return codexRosterResponse(rostersByCredential[credential] ?? []); + } + const body = typeof init?.body === "string" ? init.body : ""; + capture.urls.push(String(input)); + capture.bodies.push(body); capture.auths.push(headers.get("authorization")); return Response.json({ id: "resp_test", object: "response", status: "completed", - model: "gpt-5.6-sol", + model: (JSON.parse(body) as { model?: string }).model, output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, }); @@ -231,7 +274,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { }) as typeof fetch; const response = await postSpawn(cfg, { - model: "side/gpt-5.6-sol", + model: "side/gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -244,7 +287,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { expect(urls.length).toBeGreaterThan(0); expect(urls.every(url => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); expect(new Set(accounts)).toEqual(new Set(["pool_acc"])); - expect(new Set(models)).toEqual(new Set(["gpt-5.6-sol"])); + expect(new Set(models)).toEqual(new Set(["gpt-5.5"])); }); test("cooled primary with no probe lease selects healthy routed fallback", async () => { @@ -264,7 +307,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { model: "gpt-5.5", input: readableAgentInput(), stream: false }, { onCodexAuthContextResolved: (ctx) => authPublications.push(ctx) }, ); @@ -294,7 +337,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { }) as typeof fetch; const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -317,7 +360,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1000); recordCodexUpstreamOutcome(cfg, "pool-a", 429, { resetAt, now }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "429", cfg, "pool-a"); + noteSubagentModelFailure("gpt-5.5", "429", cfg, "pool-a"); const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; Date.now = () => probeAt; @@ -329,7 +372,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { model: "gpt-5.5", input: readableAgentInput(), stream: false }, { onCodexAuthContextResolved: (ctx) => { authPublications.push(ctx); @@ -543,7 +586,7 @@ describe("subagent fallback final-route normalization", () => { noteSubagentModelFailure("grok-4.5", "429", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", @@ -576,13 +619,13 @@ describe("subagent fallback final-route normalization", () => { subagentModelFallback: ["xai/grok-4.5"], }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "rate limit exceeded", cfg); + noteSubagentModelFailure("gpt-5.5", "rate limit exceeded", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; mockUpstream(capture); const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, reasoning: { effort: "max" }, @@ -618,7 +661,7 @@ describe("subagent fallback final-route normalization", () => { }); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", @@ -653,13 +696,13 @@ describe("subagent fallback final-route normalization", () => { subagentModelFallback: ["xai/grok-4.5"], }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "rate limit exceeded", cfg); + noteSubagentModelFailure("gpt-5.5", "rate limit exceeded", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; mockUpstream(capture); const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -674,6 +717,12 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + // Sol/Terra/Luna are account-gated; grant them only to the accounts this + // preview test configured instead of giving every discovery caller a roster. + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -731,6 +780,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -779,6 +832,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -818,7 +875,10 @@ describe("native fallback account preview", () => { let finalAuth: CodexAuthContext | undefined; const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); const response = await postSpawn( cfg, @@ -836,6 +896,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -885,6 +949,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ activeCodexAccountId: "pool-a", @@ -1050,7 +1118,7 @@ describe("native passthrough terminal finalization", () => { try { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: true }, + { model: "gpt-5.5", input: readableAgentInput(), stream: true }, { onNativePassthroughTerminal: (status) => terminals.push(status), }, @@ -1060,7 +1128,7 @@ describe("native passthrough terminal finalization", () => { await Bun.sleep(20); return { terminals, - healthBlocked: isModelHealthBlocked("gpt-5.6-sol", cfg, "pool-a"), + healthBlocked: isModelHealthBlocked("gpt-5.5", cfg, "pool-a"), responseText, }; } finally { @@ -1119,7 +1187,7 @@ describe("darwin explicit eager-relay path selection", () => { mockSseUpstream(completedSse); return postSpawn( poolNativePlusRoutedConfig({ streamMode, activeCodexAccountId: "pool-a" }), - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: true }, + { model: "gpt-5.5", input: readableAgentInput(), stream: true }, ); } diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts index 813e462b1d..0627422d92 100644 --- a/tests/vision-reasoning-contract.test.ts +++ b/tests/vision-reasoning-contract.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { handleConfigCommand } from "../src/cli/config-command"; import { handleManagementAPI } from "../src/server/management-api"; import { listManagementModelRows } from "../src/server/management/model-rows"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; import { resolveOpenAiVisionModel } from "../src/vision"; import { ManagementRequest as Request } from "./helpers/management-auth"; @@ -54,6 +58,9 @@ function validCliConfig(visionSidecar: Record): Record { test("native management rows expose vision-safe reasoning ladders", async () => { + // This contract is about the effort ladders themselves; Sol/Luna are account-gated, + // so confirm a roster or their rows would be filtered before the ladder is read. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-luna"]); const config: OcxConfig = { port: 10100, defaultProvider: "none", providers: {} }; const rows = await listManagementModelRows(config); const efforts = (id: string) => (rows.find(row => row.native === true && row.id === id) as @@ -209,6 +216,7 @@ describe("vision reasoning capability contracts", () => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(isolatedHome, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); } }); }); diff --git a/tests/ws-upstream.test.ts b/tests/ws-upstream.test.ts index 2d7eba9a38..c16060c9f5 100644 --- a/tests/ws-upstream.test.ts +++ b/tests/ws-upstream.test.ts @@ -48,7 +48,7 @@ function streamingInit(body: Record = {}): RequestInit { return { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test" }, - body: JSON.stringify({ model: "gpt-5.6-luna", stream: true, ...body }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, ...body }), }; } @@ -109,7 +109,7 @@ describe("shouldUseCodexWsUpstream", () => { // Non-streaming turns keep HTTP: the WS path only speaks the event protocol. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: JSON.stringify({ model: "gpt-5.6-luna" }), + body: JSON.stringify({ model: "gpt-5.5" }), })).toBe(false); expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "GET" })).toBe(false); expect(shouldUseCodexWsUpstream("https://api.openai.com/v1/responses", streamingInit())).toBe(false); @@ -121,12 +121,12 @@ describe("shouldUseCodexWsUpstream", () => { // Nested stream:true must not flip the transport. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: JSON.stringify({ model: "gpt-5.6-luna", metadata: { stream: true } }), + body: JSON.stringify({ model: "gpt-5.5", metadata: { stream: true } }), })).toBe(false); // Whitespace-formatted JSON still routes. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: "{\n \"model\": \"gpt-5.6-luna\",\n \"stream\" : true\n}", + body: "{\n \"model\": \"gpt-5.5\",\n \"stream\" : true\n}", })).toBe(true); // Non-boolean stream values stay on HTTP. expect(shouldUseCodexWsUpstream(CODEX_URL, { @@ -264,7 +264,7 @@ describe("handleResponses Codex WS relay selection", () => { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test" }, - body: JSON.stringify({ model: "gpt-5.6-luna", input: "hello", stream: true }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true }), }); }