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',
|
badgeLabel: '5.4',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
}),
|
}),
|
||||||
|
createFallbackModel({
|
||||||
|
id: 'gpt-5.5',
|
||||||
|
displayName: 'GPT-5.5',
|
||||||
|
badgeLabel: '5.5',
|
||||||
|
}),
|
||||||
createFallbackModel({
|
createFallbackModel({
|
||||||
id: 'gpt-5.4-mini',
|
id: 'gpt-5.4-mini',
|
||||||
displayName: '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 { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
||||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
import {
|
||||||
|
isDynamicCodexModelCatalog,
|
||||||
|
isUsableCodexModelCatalog,
|
||||||
|
} from '@shared/utils/codexModelCatalog';
|
||||||
|
|
||||||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||||
|
|
@ -419,12 +423,21 @@ export class ProviderConnectionService {
|
||||||
connection: await this.getConnectionInfo(provider.providerId),
|
connection: await this.getConnectionInfo(provider.providerId),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) {
|
if (provider.providerId !== 'codex') {
|
||||||
return withConnection;
|
return withConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
const models = catalog.models
|
||||||
.filter((model) => !model.hidden)
|
.filter((model) => !model.hidden)
|
||||||
.map((model) => model.launchModel.trim())
|
.map((model) => model.launchModel.trim())
|
||||||
|
|
@ -438,16 +451,20 @@ export class ProviderConnectionService {
|
||||||
);
|
);
|
||||||
const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort;
|
const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort;
|
||||||
const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog;
|
const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog;
|
||||||
|
const modelCatalogCapability =
|
||||||
|
orchestratorCatalog && runtimeModelCatalogCapability
|
||||||
|
? runtimeModelCatalogCapability
|
||||||
|
: {
|
||||||
|
dynamic: isDynamicCodexModelCatalog(catalog),
|
||||||
|
source: catalog.source,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...withConnection,
|
...withConnection,
|
||||||
models: models.length > 0 ? models : withConnection.models,
|
models: models.length > 0 ? models : withConnection.models,
|
||||||
modelCatalog: catalog,
|
modelCatalog: catalog,
|
||||||
runtimeCapabilities: {
|
runtimeCapabilities: {
|
||||||
...withConnection.runtimeCapabilities,
|
...withConnection.runtimeCapabilities,
|
||||||
modelCatalog: {
|
modelCatalog: modelCatalogCapability,
|
||||||
dynamic: runtimeModelCatalogCapability?.dynamic === true,
|
|
||||||
source: catalog.source,
|
|
||||||
},
|
|
||||||
reasoningEffort: {
|
reasoningEffort: {
|
||||||
supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0,
|
supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0,
|
||||||
values:
|
values:
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||||
|
import { isUsableCodexModelCatalog } from '@shared/utils/codexModelCatalog';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||||
|
|
@ -922,6 +923,22 @@ function normalizeProviderModelListModels(
|
||||||
return models;
|
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 {
|
function isLegacySafeEffort(effort: EffortLevel): boolean {
|
||||||
return effort === 'low' || effort === 'medium' || effort === 'high';
|
return effort === 'low' || effort === 'medium' || effort === 'high';
|
||||||
}
|
}
|
||||||
|
|
@ -4466,38 +4483,22 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelCatalog) {
|
if (modelCatalog) {
|
||||||
for (const model of modelCatalog.models ?? []) {
|
addModelCatalogLaunchModels(modelIds, modelCatalog);
|
||||||
const launchModel = model.launchModel?.trim();
|
|
||||||
if (launchModel) {
|
|
||||||
modelIds.add(launchModel);
|
|
||||||
}
|
|
||||||
const catalogId = model.id?.trim();
|
|
||||||
if (catalogId) {
|
|
||||||
modelIds.add(catalogId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel;
|
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({
|
const codexCatalog = await this.providerConnectionService.getCodexModelCatalog({
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
});
|
});
|
||||||
if (codexCatalog?.providerId === 'codex' && codexCatalog.status === 'ready') {
|
if (isUsableCodexModelCatalog(codexCatalog)) {
|
||||||
for (const model of codexCatalog.models ?? []) {
|
addModelCatalogLaunchModels(modelIds, codexCatalog);
|
||||||
const launchModel = model.launchModel?.trim();
|
|
||||||
if (launchModel) {
|
|
||||||
modelIds.add(launchModel);
|
|
||||||
}
|
|
||||||
const catalogId = model.id?.trim();
|
|
||||||
if (catalogId) {
|
|
||||||
modelIds.add(catalogId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!modelCatalog) {
|
modelCatalog = codexCatalog;
|
||||||
modelCatalog = codexCatalog;
|
|
||||||
}
|
|
||||||
defaultModel = codexCatalog.defaultLaunchModel?.trim() || defaultModel;
|
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"']);
|
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 });
|
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 () => {
|
it('passes provider launch args before model-list catalog subcommands', async () => {
|
||||||
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||||
if (args.includes('model')) {
|
if (args.includes('model')) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue