From 051bfe2319ea442b6c03b07164879470d696b501 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 20:13:03 +0300 Subject: [PATCH] feat: prefer orchestrator codex model catalog --- .../codexModelCatalogFallback.test.ts | 12 ++ .../core/domain/codexModelCatalogFallback.ts | 5 + .../runtime/ProviderConnectionService.ts | 29 ++++- .../services/team/TeamProvisioningService.ts | 51 ++++----- src/shared/utils/codexModelCatalog.ts | 19 ++++ .../runtime/ProviderConnectionService.test.ts | 104 ++++++++++++++++++ .../TeamProvisioningServicePrepare.test.ts | 91 +++++++++++++++ 7 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts create mode 100644 src/shared/utils/codexModelCatalog.ts diff --git a/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts b/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts new file mode 100644 index 00000000..7867e98d --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { createStaticCodexModelCatalogModels } from '../codexModelCatalogFallback'; + +describe('createStaticCodexModelCatalogModels', () => { + it('includes GPT-5.5 without changing the default from GPT-5.4', () => { + const models = createStaticCodexModelCatalogModels(); + + expect(models.map((model) => model.launchModel)).toContain('gpt-5.5'); + expect(models.find((model) => model.isDefault)?.launchModel).toBe('gpt-5.4'); + }); +}); diff --git a/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts index 5f9c724c..aac4d20c 100644 --- a/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts +++ b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts @@ -36,6 +36,11 @@ export function createStaticCodexModelCatalogModels(): CliProviderModelCatalogIt badgeLabel: '5.4', isDefault: true, }), + createFallbackModel({ + id: 'gpt-5.5', + displayName: 'GPT-5.5', + badgeLabel: '5.5', + }), createFallbackModel({ id: 'gpt-5.4-mini', displayName: 'GPT-5.4 Mini', diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 50af59de..de23f11c 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -2,6 +2,10 @@ import path from 'node:path'; import { evaluateCodexLaunchReadiness } from '@features/codex-account'; import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { + isDynamicCodexModelCatalog, + isUsableCodexModelCatalog, +} from '@shared/utils/codexModelCatalog'; import { ApiKeyService } from '../extensions/apikeys/ApiKeyService'; import { ConfigManager } from '../infrastructure/ConfigManager'; @@ -419,12 +423,21 @@ export class ProviderConnectionService { connection: await this.getConnectionInfo(provider.providerId), }; - if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) { + if (provider.providerId !== 'codex') { return withConnection; } try { - const catalog = await this.codexModelCatalogFeature.getCatalog(); + const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog) + ? withConnection.modelCatalog + : null; + const catalog = + orchestratorCatalog ?? + (this.codexModelCatalogFeature ? await this.codexModelCatalogFeature.getCatalog() : null); + if (!isUsableCodexModelCatalog(catalog)) { + return withConnection; + } + const models = catalog.models .filter((model) => !model.hidden) .map((model) => model.launchModel.trim()) @@ -438,16 +451,20 @@ export class ProviderConnectionService { ); const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort; const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog; + const modelCatalogCapability = + orchestratorCatalog && runtimeModelCatalogCapability + ? runtimeModelCatalogCapability + : { + dynamic: isDynamicCodexModelCatalog(catalog), + source: catalog.source, + }; return { ...withConnection, models: models.length > 0 ? models : withConnection.models, modelCatalog: catalog, runtimeCapabilities: { ...withConnection.runtimeCapabilities, - modelCatalog: { - dynamic: runtimeModelCatalogCapability?.dynamic === true, - source: catalog.source, - }, + modelCatalog: modelCatalogCapability, reasoningEffort: { supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0, values: diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 601d297e..73d47bb8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -67,6 +67,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; +import { isUsableCodexModelCatalog } from '@shared/utils/codexModelCatalog'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; @@ -922,6 +923,22 @@ function normalizeProviderModelListModels( return models; } +function addModelCatalogLaunchModels( + modelIds: Set, + catalog: CliProviderModelCatalog +): void { + for (const model of catalog.models ?? []) { + const launchModel = model.launchModel?.trim(); + if (launchModel) { + modelIds.add(launchModel); + } + const catalogId = model.id?.trim(); + if (catalogId) { + modelIds.add(catalogId); + } + } +} + function isLegacySafeEffort(effort: EffortLevel): boolean { return effort === 'low' || effort === 'medium' || effort === 'high'; } @@ -4466,38 +4483,22 @@ export class TeamProvisioningService { } if (modelCatalog) { - for (const model of modelCatalog.models ?? []) { - const launchModel = model.launchModel?.trim(); - if (launchModel) { - modelIds.add(launchModel); - } - const catalogId = model.id?.trim(); - if (catalogId) { - modelIds.add(catalogId); - } - } + addModelCatalogLaunchModels(modelIds, modelCatalog); defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel; } - if (params.providerId === 'codex' && runtimeCapabilities?.modelCatalog?.dynamic === true) { + if ( + params.providerId === 'codex' && + !isUsableCodexModelCatalog(modelCatalog) && + runtimeCapabilities?.modelCatalog?.dynamic === true + ) { const codexCatalog = await this.providerConnectionService.getCodexModelCatalog({ cwd: params.cwd, }); - if (codexCatalog?.providerId === 'codex' && codexCatalog.status === 'ready') { - for (const model of codexCatalog.models ?? []) { - const launchModel = model.launchModel?.trim(); - if (launchModel) { - modelIds.add(launchModel); - } - const catalogId = model.id?.trim(); - if (catalogId) { - modelIds.add(catalogId); - } - } + if (isUsableCodexModelCatalog(codexCatalog)) { + addModelCatalogLaunchModels(modelIds, codexCatalog); - if (!modelCatalog) { - modelCatalog = codexCatalog; - } + modelCatalog = codexCatalog; defaultModel = codexCatalog.defaultLaunchModel?.trim() || defaultModel; } } diff --git a/src/shared/utils/codexModelCatalog.ts b/src/shared/utils/codexModelCatalog.ts new file mode 100644 index 00000000..d06201d0 --- /dev/null +++ b/src/shared/utils/codexModelCatalog.ts @@ -0,0 +1,19 @@ +import type { CliProviderModelCatalog } from '@shared/types'; + +export function isUsableCodexModelCatalog( + catalog: CliProviderModelCatalog | null | undefined +): catalog is CliProviderModelCatalog { + return ( + catalog?.schemaVersion === 1 && + catalog.providerId === 'codex' && + (catalog.source === 'app-server' || catalog.source === 'static-fallback') && + Array.isArray(catalog.models) && + catalog.models.some((model) => model.launchModel?.trim()) + ); +} + +export function isDynamicCodexModelCatalog(catalog: CliProviderModelCatalog): boolean { + return ( + catalog.source === 'app-server' && (catalog.status === 'ready' || catalog.status === 'stale') + ); +} diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 037dedb2..2b801564 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -790,4 +790,108 @@ describe('ProviderConnectionService', () => { expect(args).toEqual(['-c', 'forced_login_method="api"']); }); + + it('prefers the orchestrator Codex model catalog over the legacy direct app-server fallback', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const directCatalog = vi.fn().mockResolvedValue({ + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4-mini', + defaultLaunchModel: 'gpt-5.4-mini', + models: [], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never); + + const enriched = await service.enrichProviderStatus({ + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: ['gpt-5.4'], + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + }, + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported', ownership: 'shared' }, + mcp: { status: 'supported', ownership: 'shared' }, + skills: { status: 'supported', ownership: 'shared' }, + apiKeys: { status: 'supported', ownership: 'shared' }, + }, + }, + }); + + expect(directCatalog).not.toHaveBeenCalled(); + expect(enriched.models).toEqual(['gpt-5.4', 'gpt-5.5']); + expect(enriched.modelCatalog?.defaultLaunchModel).toBe('gpt-5.4'); + expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({ + dynamic: true, + source: 'app-server', + }); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 0990bf71..e19e0b9b 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1736,6 +1736,97 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(getCodexModelCatalog).toHaveBeenCalledWith({ cwd: tempRoot }); }); + it('uses the orchestrator Codex catalog before falling back to the direct app-server catalog', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + }); + const getCodexModelCatalog = vi + .spyOn(ProviderConnectionService.getInstance(), 'getCodexModelCatalog') + .mockResolvedValue(null); + + execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { + if (args.includes('model') && args.includes('list')) { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + if (args.includes('runtime') && args.includes('status')) { + return { + stdout: JSON.stringify({ + providers: { + codex: { + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + }, + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + }, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const result = await (svc as any).verifySelectedProviderModels({ + claudePath: '/fake/claude', + cwd: tempRoot, + providerId: 'codex', + modelIds: ['gpt-5.5'], + limitContext: false, + }); + + expect(result.details).toEqual(['Selected model gpt-5.5 is available for launch.']); + expect(result.blockingMessages).toEqual([]); + expect(getCodexModelCatalog).not.toHaveBeenCalled(); + }); + it('passes provider launch args before model-list catalog subcommands', async () => { execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { if (args.includes('model')) {