feat: prefer orchestrator codex model catalog

This commit is contained in:
777genius 2026-04-28 20:13:03 +03:00
parent 42c9cbd227
commit 051bfe2319
7 changed files with 280 additions and 31 deletions

View file

@ -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');
});
});

View file

@ -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',

View file

@ -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:

View file

@ -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<string>,
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;
}
}

View file

@ -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')
);
}

View file

@ -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',
});
});
});

View file

@ -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')) {