Files
fusion/packages/core/src/openai-models.ts
gsxdsm fed5d3d951 FN-7759: fix GPT-5.6 codex models missing from model dropdown
Ensure GPT-5.6 codex models (luna, sol, terra) actually surface in the codex model picker by validating the openai-codex supplemental merge against the real pi-coding-agent ModelRegistry instead of only a mocked one.

- Harden openai-models.ts supplemental merge logic against real ModelRegistry auth filtering, registerProvider full-replacement, and OAuth provider validation
- Add @earendil-works/pi-coding-agent devDependency to @fusion/core for real-registry testing
- Add regression test (openai-models.test.ts) exercising the real registry path
- Extend register-model-routes-openai-codex-supplemental.test.ts dashboard test coverage
- Update docs/settings-reference.md
- Add changeset fn-7759-codex-gpt-5-6-dropdown.md (patch, fix)

Files changed:
 .changeset/fn-7759-codex-gpt-5-6-dropdown.md       |   7 ++
 docs/settings-reference.md                         |   2 +-
 packages/core/package.json                         |   1 +
 packages/core/src/__tests__/openai-models.test.ts  |  68 +++++++++++++
 packages/core/src/openai-models.ts                 | 107 ++++++++++++++++-----
 ...-model-routes-openai-codex-supplemental.test.ts |  77 +++++++++++++--
 pnpm-lock.yaml                                     |  68 +++++++++++++
 7 files changed, 300 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-7759

Fusion-Task-Lineage: 4c03838f-b8d8-4f6f-ae55-c3356c1c3176

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-09 23:12:02 -07:00

230 lines
9.0 KiB
TypeScript

type OpenAiCodexModelInput = "text" | "image";
export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
export const GPT_5_6_LUNA_MODEL_ID = "gpt-5.6-luna";
export const GPT_5_6_SOL_MODEL_ID = "gpt-5.6-sol";
export const GPT_5_6_TERRA_MODEL_ID = "gpt-5.6-terra";
interface OpenAiCodexCostTier {
inputTokensAbove: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
interface OpenAiCodexOAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
interface OpenAiCodexOAuthProviderRegistration {
id?: unknown;
name: string;
login: (...args: never[]) => Promise<OpenAiCodexOAuthCredentials>;
refreshToken: (credentials: OpenAiCodexOAuthCredentials) => Promise<OpenAiCodexOAuthCredentials>;
getApiKey: (credentials: OpenAiCodexOAuthCredentials) => string;
usesCallbackServer?: boolean;
}
interface OpenAiCodexModelRegistration {
id: string;
name: string;
api?: "openai-codex-responses";
baseUrl?: string;
reasoning: boolean;
thinkingLevelMap?: Record<string, string | null>;
input: OpenAiCodexModelInput[];
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
tiers?: OpenAiCodexCostTier[];
};
contextWindow: number;
maxTokens: number;
compat?: Record<string, unknown>;
}
export interface OpenAiCodexProviderRegistration {
name: string;
baseUrl: string;
apiKey?: string;
oauth?: OpenAiCodexOAuthProviderRegistration;
api: "openai-codex-responses";
models: OpenAiCodexModelRegistration[];
}
/*
* FNXC:ModelCatalog 2026-07-09-12:30:
* FN-7745: FN-7742 already priced the three GPT-5.6 codenamed OpenAI Codex variants
* (gpt-5.6-luna/sol/terra) in model-pricing.ts, but pricing does not make a model
* selectable — the /api/models picker sources rows from the pinned pi-ai
* ModelRegistry.getAvailable() catalog. At spec time (pi-ai 0.80.3) that pinned catalog
* did not carry the three GPT-5.6 codenamed ids under "openai-codex", so no picker
* surfaced them. Mirror the SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION seam
* (anthropic-models.ts) to additively register them: if a later pi-ai bump already
* carries an id, the merge below is a dedupe-safe no-op — the existing catalog row
* always wins, never displaced or duplicated. Field shape (api/baseUrl) is copied
* from the pinned catalog's openai-codex.models.js entries.
*
* FNXC:ModelCatalog 2026-07-09-23:55:
* FN-7759: the real pi-coding-agent ModelRegistry rejects dynamic providers that
* define models without `apiKey` or `oauth`, and `registerProvider` full-replaces
* the provider's rows before getAvailable() runs. Prior mock-only tests missed that
* the supplemental OpenAI Codex registration could be logged-and-dropped on installs
* whose pinned catalog lacked 5.6. Keep model fields aligned with the real Codex
* catalog (per-model api/baseUrl/thinking levels/pricing) and carry the built-in
* OAuth provider object into the dynamic registration so validation, getAvailable()
* auth filtering, and execution-time OAuth auth treatment stay identical to 5.3/5.4/5.5.
*/
export const SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION: OpenAiCodexProviderRegistration = {
name: "OpenAI Codex",
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",
models: [
{
id: GPT_5_6_LUNA_MODEL_ID,
name: "GPT-5.6 Luna",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max", minimal: "low" },
input: ["text", "image"],
cost: {
input: 1,
output: 6,
cacheRead: 0.1,
cacheWrite: 1.25,
tiers: [{ inputTokensAbove: 272_000, input: 2, output: 9, cacheRead: 0.2, cacheWrite: 2.5 }],
},
contextWindow: 372_000,
maxTokens: 128_000,
},
{
id: GPT_5_6_SOL_MODEL_ID,
name: "GPT-5.6 Sol",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max", minimal: "low" },
input: ["text", "image"],
cost: {
input: 5,
output: 30,
cacheRead: 0.5,
cacheWrite: 6.25,
tiers: [{ inputTokensAbove: 272_000, input: 10, output: 45, cacheRead: 1, cacheWrite: 12.5 }],
},
contextWindow: 372_000,
maxTokens: 128_000,
},
{
id: GPT_5_6_TERRA_MODEL_ID,
name: "GPT-5.6 Terra",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max", minimal: "low" },
input: ["text", "image"],
cost: {
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 3.125,
tiers: [{ inputTokensAbove: 272_000, input: 5, output: 22.5, cacheRead: 0.5, cacheWrite: 6.25 }],
},
contextWindow: 372_000,
maxTokens: 128_000,
},
],
};
type OpenAiCodexModelLike = Partial<Omit<OpenAiCodexModelRegistration, "name" | "api" | "compat" | "thinkingLevelMap">> & {
id: string;
api?: string;
name?: unknown;
provider?: string;
compat?: unknown;
thinkingLevelMap?: unknown;
};
interface OpenAiCodexModelRegistryLike {
registerProvider(providerName: string, config: OpenAiCodexProviderRegistration): void;
getAll?: () => OpenAiCodexModelLike[];
}
type RegistryWithProviderState = OpenAiCodexModelRegistryLike & {
registeredProviders?: Map<string, Partial<OpenAiCodexProviderRegistration>>;
authStorage?: { getOAuthProviders?: () => OpenAiCodexOAuthProviderRegistration[] };
};
function getOpenAiCodexOAuthProvider(registryWithState: RegistryWithProviderState, registeredProvider: Partial<OpenAiCodexProviderRegistration> | undefined): OpenAiCodexOAuthProviderRegistration | undefined {
return registeredProvider?.oauth
?? registryWithState.authStorage?.getOAuthProviders?.().find((provider) => provider.id === OPENAI_CODEX_PROVIDER_ID);
}
function toOpenAiCodexModelRegistration(model: OpenAiCodexModelLike): OpenAiCodexModelRegistration {
const supplemental = SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION.models.find((entry) => entry.id === model.id);
return {
id: model.id,
name: String(model.name ?? supplemental?.name ?? model.id),
api: model.api === "openai-codex-responses" ? model.api : supplemental?.api,
baseUrl: model.baseUrl ?? supplemental?.baseUrl,
reasoning: model.reasoning ?? supplemental?.reasoning ?? false,
thinkingLevelMap: typeof model.thinkingLevelMap === "object" && model.thinkingLevelMap !== null
? { ...(model.thinkingLevelMap as Record<string, string | null>) }
: supplemental?.thinkingLevelMap ? { ...supplemental.thinkingLevelMap } : undefined,
input: Array.isArray(model.input) ? model.input as OpenAiCodexModelInput[] : supplemental?.input ?? ["text"],
cost: model.cost ?? supplemental?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: Number(model.contextWindow ?? supplemental?.contextWindow ?? 0),
maxTokens: Number(model.maxTokens ?? supplemental?.maxTokens ?? 0),
compat: typeof model.compat === "object" && model.compat !== null
? { ...(model.compat as Record<string, unknown>) }
: supplemental?.compat ? { ...supplemental.compat } : undefined,
};
}
function cloneOpenAiCodexProviderRegistration(config: OpenAiCodexProviderRegistration): OpenAiCodexProviderRegistration {
return {
...config,
models: config.models.map((model) => toOpenAiCodexModelRegistration(model)),
};
}
export function mergeSupplementalOpenAiCodexModels(
modelRegistry: OpenAiCodexModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
try {
const registryWithState = modelRegistry as RegistryWithProviderState;
const registeredProvider = registryWithState.registeredProviders?.get(OPENAI_CODEX_PROVIDER_ID);
const registeredModels = registeredProvider?.models?.map((model) => toOpenAiCodexModelRegistration(model)) ?? [];
const currentModels = registeredModels.length > 0
? registeredModels
: modelRegistry.getAll?.()
.filter((model) => model.provider === OPENAI_CODEX_PROVIDER_ID)
.map((model) => toOpenAiCodexModelRegistration(model)) ?? [];
const currentModelIds = new Set(currentModels.map((model) => model.id));
const missingModels = SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION.models
.filter((model) => !currentModelIds.has(model.id));
if (missingModels.length === 0) return;
const oauth = getOpenAiCodexOAuthProvider(registryWithState, registeredProvider);
modelRegistry.registerProvider(OPENAI_CODEX_PROVIDER_ID, {
...cloneOpenAiCodexProviderRegistration(SUPPLEMENTAL_OPENAI_CODEX_PROVIDER_REGISTRATION),
...registeredProvider,
oauth,
models: [...currentModels, ...missingModels.map((model) => toOpenAiCodexModelRegistration(model))],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to merge supplemental ${OPENAI_CODEX_PROVIDER_ID} models: ${message}`);
}
}