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 3fd673f84b8..4c63d89ec16 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 44f5fa39132..78647e1075a 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 be4449c144b..51f1fd99ee6 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 acc181b384a..7c36a0ffe04 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 19f58ac7955..0fd20b156fb 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 4bf7876312b..c8e552771fa 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 5a3372c1373..da4d6cb5d0e 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 f689d80eb22..253c42f4495 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 29d84ff6613..c00e96923d4 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 bf5656779b0..2cfdfa1ce57 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 a0e367091b9..9206aaa9e44 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 55b1b2a63ab..307c5f667c6 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 a234b7a4019..7b5833383c8 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 3e0a3536620..e4e0d33d957 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 d1593a949a4..0ed57bcfb98 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 26d261ec0b7..b70db3dde04 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 53ad74ff6e8..7eac48b321b 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 239d305746d..5a618c4be7d 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 baee2a34d2c..0df9ba44b84 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 b8d153db6ce..fcf42e07dc2 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 66bc308936c..d54a85495f6 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 247f2df7fa8..921796a6fd8 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 e6806754189..104119a9ba7 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 ebb0f766365..f17b58b18c9 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 6ec6473503d..10d45dda23f 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 de2de9fc421..401fcde4af1 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 96348e74018..bfcced0f354 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 937b0aadeba..288786f2d6c 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 813e462b1d9..0627422d920 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 2d7eba9a383..c16060c9f5f 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 }), }); }