feat: prefer orchestrator codex model catalog
This commit is contained in:
parent
42c9cbd227
commit
051bfe2319
7 changed files with 280 additions and 31 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/shared/utils/codexModelCatalog.ts
Normal file
19
src/shared/utils/codexModelCatalog.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue