feat(codex): add app-server model catalog

This commit is contained in:
777genius 2026-04-21 12:45:34 +03:00
parent 99102565f3
commit 7a337b6268
46 changed files with 4873 additions and 168 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import type {
CliProviderModelCatalog,
CliProviderModelCatalogItem,
CliProviderModelCatalogSource,
CliProviderModelCatalogStatus,
CliProviderReasoningEffort,
} from '@shared/types';
export type CodexModelCatalogDto = CliProviderModelCatalog;
export type CodexModelCatalogItemDto = CliProviderModelCatalogItem;
export type CodexModelCatalogSourceDto = CliProviderModelCatalogSource;
export type CodexModelCatalogStatusDto = CliProviderModelCatalogStatus;
export type CodexModelReasoningEffortDto = CliProviderReasoningEffort;

View file

@ -0,0 +1,7 @@
export type {
CodexModelCatalogDto,
CodexModelCatalogItemDto,
CodexModelCatalogSourceDto,
CodexModelCatalogStatusDto,
CodexModelReasoningEffortDto,
} from './dto';

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { normalizeCodexAppServerModels } from '../normalizeCodexAppServerModel';
describe('normalizeCodexAppServerModels', () => {
it('keeps app-server model metadata required by the UI picker', () => {
const result = normalizeCodexAppServerModels([
{
id: 'gpt-5.5',
displayName: 'GPT-5.5',
supportedReasoningEfforts: [
{ reasoningEffort: 'low' },
{ reasoningEffort: 'medium' },
{ reasoningEffort: 'high' },
{ reasoningEffort: 'xhigh' },
],
defaultReasoningEffort: 'xhigh',
inputModalities: ['text', 'image'],
supportsPersonality: true,
isDefault: true,
},
]);
expect(result.defaultModelId).toBe('gpt-5.5');
expect(result.models).toEqual([
expect.objectContaining({
id: 'gpt-5.5',
launchModel: 'gpt-5.5',
displayName: 'GPT-5.5',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'xhigh',
inputModalities: ['text', 'image'],
supportsPersonality: true,
isDefault: true,
source: 'app-server',
}),
]);
});
it('filters hidden models unless the caller explicitly asks for them', () => {
const result = normalizeCodexAppServerModels([
{ id: 'gpt-visible', hidden: false },
{ id: 'gpt-hidden', hidden: true },
]);
expect(result.models.map((model) => model.id)).toEqual(['gpt-visible']);
const withHidden = normalizeCodexAppServerModels(
[
{ id: 'gpt-visible', hidden: false },
{ id: 'gpt-hidden', hidden: true },
],
{ includeHidden: true }
);
expect(withHidden.models.map((model) => model.id)).toEqual(['gpt-visible', 'gpt-hidden']);
});
it('drops unknown effort values instead of leaking them into launch options', () => {
const result = normalizeCodexAppServerModels([
{
id: 'gpt-5.4',
supportedReasoningEfforts: ['none', 'medium', { reasoningEffort: 'future-effort' }],
defaultReasoningEffort: 'future-effort',
},
]);
expect(result.models[0]?.supportedReasoningEfforts).toEqual(['medium']);
expect(result.models[0]?.defaultReasoningEffort).toBe('medium');
});
it('uses model as the launch value and de-duplicates duplicate launch models', () => {
const result = normalizeCodexAppServerModels([
{
id: 'catalog-alias',
model: 'gpt-5.5',
displayName: 'GPT-5.5 Alias',
},
{
id: 'catalog-duplicate',
model: 'gpt-5.5',
displayName: 'Duplicate GPT-5.5 Alias',
},
]);
expect(result.models).toEqual([
expect.objectContaining({
id: 'catalog-alias',
launchModel: 'gpt-5.5',
displayName: 'GPT-5.5 Alias',
}),
]);
expect(result.diagnostics).toContain('model/list returned duplicate launch model gpt-5.5.');
});
});

View file

@ -0,0 +1,61 @@
import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types';
const DEFAULT_CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'] as const;
const MINI_CODEX_EFFORTS = ['medium', 'high'] as const;
function createFallbackModel(options: {
id: string;
displayName: string;
badgeLabel: string;
isDefault?: boolean;
efforts?: readonly CliProviderReasoningEffort[];
defaultEffort?: CliProviderReasoningEffort;
}): CliProviderModelCatalogItem {
const efforts = [...(options.efforts ?? DEFAULT_CODEX_EFFORTS)];
return {
id: options.id,
launchModel: options.id,
displayName: options.displayName,
hidden: false,
supportedReasoningEfforts: efforts,
defaultReasoningEffort: options.defaultEffort ?? 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: options.isDefault === true,
upgrade: false,
source: 'static-fallback',
badgeLabel: options.badgeLabel,
};
}
export function createStaticCodexModelCatalogModels(): CliProviderModelCatalogItem[] {
return [
createFallbackModel({
id: 'gpt-5.4',
displayName: 'GPT-5.4',
badgeLabel: '5.4',
isDefault: true,
}),
createFallbackModel({
id: 'gpt-5.4-mini',
displayName: 'GPT-5.4 Mini',
badgeLabel: '5.4-mini',
}),
createFallbackModel({
id: 'gpt-5.3-codex',
displayName: 'GPT-5.3 Codex',
badgeLabel: '5.3-codex',
}),
createFallbackModel({
id: 'gpt-5.2',
displayName: 'GPT-5.2',
badgeLabel: '5.2',
}),
createFallbackModel({
id: 'gpt-5.1-codex-mini',
displayName: 'GPT-5.1 Codex Mini',
badgeLabel: '5.1-codex-mini',
efforts: MINI_CODEX_EFFORTS,
}),
];
}

View file

@ -0,0 +1,24 @@
import type { CliProviderReasoningEffort } from '@shared/types';
export const CODEX_REASONING_EFFORTS = [
'minimal',
'low',
'medium',
'high',
'xhigh',
] as const satisfies readonly CliProviderReasoningEffort[];
const CODEX_REASONING_EFFORT_SET = new Set<string>(CODEX_REASONING_EFFORTS);
export function isCodexReasoningEffort(value: unknown): value is CliProviderReasoningEffort {
return typeof value === 'string' && CODEX_REASONING_EFFORT_SET.has(value);
}
export function normalizeCodexReasoningEffort(value: unknown): CliProviderReasoningEffort | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
return isCodexReasoningEffort(normalized) ? normalized : null;
}

View file

@ -0,0 +1,151 @@
import { normalizeCodexReasoningEffort, CODEX_REASONING_EFFORTS } from './codexReasoningEffort';
import type { CodexAppServerModel } from '@main/services/infrastructure/codexAppServer';
import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types';
export interface NormalizedCodexModelCatalogResult {
models: CliProviderModelCatalogItem[];
defaultModelId: string | null;
diagnostics: string[];
}
function normalizeModelId(model: CodexAppServerModel): string | null {
const id = model.id?.trim() || model.model?.trim() || null;
return id && id.length > 0 ? id : null;
}
function normalizeEffortOption(option: unknown): CliProviderReasoningEffort | null {
if (typeof option === 'string') {
return normalizeCodexReasoningEffort(option);
}
if (option && typeof option === 'object' && 'reasoningEffort' in option) {
return normalizeCodexReasoningEffort((option as { reasoningEffort?: unknown }).reasoningEffort);
}
return null;
}
function normalizeEfforts(model: CodexAppServerModel): CliProviderReasoningEffort[] {
const efforts = model.supportedReasoningEfforts?.flatMap((option) => {
const normalized = normalizeEffortOption(option);
return normalized ? [normalized] : [];
});
if (!efforts || efforts.length === 0) {
return ['low', 'medium', 'high'];
}
return CODEX_REASONING_EFFORTS.filter((effort) => efforts.includes(effort));
}
function normalizeDefaultEffort(
defaultEffort: unknown,
supportedEfforts: readonly CliProviderReasoningEffort[]
): CliProviderReasoningEffort | null {
const normalized = normalizeCodexReasoningEffort(defaultEffort);
if (!normalized) {
return supportedEfforts.includes('medium') ? 'medium' : (supportedEfforts[0] ?? null);
}
return supportedEfforts.includes(normalized)
? normalized
: supportedEfforts.includes('medium')
? 'medium'
: (supportedEfforts[0] ?? null);
}
function normalizeModalities(value: unknown): string[] {
if (!Array.isArray(value)) {
return ['text', 'image'];
}
const seen = new Set<string>();
const modalities: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
continue;
}
const normalized = item.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
modalities.push(normalized);
}
return modalities.length > 0 ? modalities : ['text', 'image'];
}
function asBadgeLabel(modelId: string): string {
return modelId.replace(/^gpt-/, '');
}
export function normalizeCodexAppServerModels(
models: readonly CodexAppServerModel[] | undefined,
options: {
includeHidden?: boolean;
} = {}
): NormalizedCodexModelCatalogResult {
const diagnostics: string[] = [];
const seen = new Set<string>();
const seenLaunchModels = new Set<string>();
const normalizedModels: CliProviderModelCatalogItem[] = [];
for (const model of models ?? []) {
const id = normalizeModelId(model);
if (!id) {
diagnostics.push('model/list returned a model without id/model.');
continue;
}
if (seen.has(id)) {
diagnostics.push(`model/list returned duplicate model id ${id}.`);
continue;
}
seen.add(id);
const hidden = model.hidden === true;
if (hidden && options.includeHidden !== true) {
continue;
}
const launchModel = model.model?.trim() || id;
if (seenLaunchModels.has(launchModel)) {
diagnostics.push(`model/list returned duplicate launch model ${launchModel}.`);
continue;
}
seenLaunchModels.add(launchModel);
const supportedReasoningEfforts = normalizeEfforts(model);
normalizedModels.push({
id,
launchModel,
displayName: model.displayName?.trim() || id,
hidden,
supportedReasoningEfforts,
defaultReasoningEffort: normalizeDefaultEffort(
model.defaultReasoningEffort,
supportedReasoningEfforts
),
inputModalities: normalizeModalities(model.inputModalities),
supportsPersonality: model.supportsPersonality === true,
isDefault: model.isDefault === true,
upgrade: Boolean(model.upgrade),
source: 'app-server',
badgeLabel: asBadgeLabel(id),
});
}
const defaultModel =
normalizedModels.find((model) => model.isDefault) ??
normalizedModels.find((model) => !model.hidden) ??
normalizedModels[0] ??
null;
return {
models: normalizedModels,
defaultModelId: defaultModel?.id ?? null,
diagnostics,
};
}

View file

@ -0,0 +1,9 @@
export type {
CodexModelCatalogDto,
CodexModelCatalogItemDto,
CodexModelCatalogSourceDto,
CodexModelCatalogStatusDto,
CodexModelReasoningEffortDto,
} from './contracts';
export type { CodexModelCatalogFeatureFacade, CodexModelCatalogRequest } from './main';
export { createCodexModelCatalogFeature } from './main';

View file

@ -0,0 +1,357 @@
import { createHash, randomBytes } from 'node:crypto';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder';
import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback';
import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel';
import {
CodexAppServerSessionFactory,
CodexBinaryResolver,
JsonRpcRequestError,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient';
import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache';
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
import type { Logger } from '@shared/utils/logger';
type LoggerPort = Pick<Logger, 'warn'>;
const CATALOG_CACHE_TTL_MS = 10 * 60_000;
const CATALOG_STALE_TTL_MS = 24 * 60 * 60_000;
const HASH_SALT = randomBytes(16).toString('hex');
export interface CodexModelCatalogRequest {
cwd?: string | null;
profile?: string | null;
includeHidden?: boolean;
forceRefresh?: boolean;
}
export interface CodexModelCatalogFeatureFacade {
getCatalog(options?: CodexModelCatalogRequest): Promise<CodexModelCatalogDto>;
invalidate(): void;
dispose(): Promise<void>;
}
function nowIso(): string {
return new Date().toISOString();
}
function staleAtIso(): string {
return new Date(Date.now() + CATALOG_CACHE_TTL_MS).toISOString();
}
function hashValue(value: unknown): string {
return createHash('sha256')
.update(HASH_SALT)
.update(JSON.stringify(value ?? null))
.digest('hex')
.slice(0, 16);
}
function classifyAppServerFailure(error: unknown): {
appServerState: CodexModelCatalogDto['diagnostics']['appServerState'];
message: string;
code: string | null;
} {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
const rpcCode =
error instanceof JsonRpcRequestError && error.code !== null ? String(error.code) : null;
if (
lower.includes('unknown method') ||
lower.includes('method not found') ||
lower.includes('unknown command') ||
lower.includes('no such command') ||
rpcCode === '-32601'
) {
return {
appServerState: 'incompatible',
message: 'The installed Codex binary does not support app-server model/list yet.',
code: rpcCode ?? 'method-not-found',
};
}
return {
appServerState: 'degraded',
message,
code: rpcCode,
};
}
function createCacheKey(options: {
binaryPath: string | null;
binaryVersion: string | null;
accountSnapshot: CodexAccountSnapshotDto;
cwd?: string | null;
profile?: string | null;
configFingerprint?: string | null;
includeHidden?: boolean;
}): string {
return hashValue({
binaryPath: options.binaryPath,
binaryVersion: options.binaryVersion,
preferredAuthMode: options.accountSnapshot.preferredAuthMode,
effectiveAuthMode: options.accountSnapshot.effectiveAuthMode,
managedAccount: options.accountSnapshot.managedAccount
? {
type: options.accountSnapshot.managedAccount.type,
planType: options.accountSnapshot.managedAccount.planType,
emailHash: hashValue(options.accountSnapshot.managedAccount.email),
}
: null,
apiKeySource: options.accountSnapshot.apiKey.source,
cwd: options.cwd?.trim() || null,
profile: options.profile?.trim() || null,
configFingerprint: options.configFingerprint ?? null,
includeHidden: options.includeHidden === true,
codexHome: process.env.CODEX_HOME?.trim() || null,
});
}
function setCatalogCacheEntries(
cache: InMemoryCodexModelCatalogCache,
keys: readonly string[],
catalog: CodexModelCatalogDto
): void {
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
cache.set(key, catalog);
}
}
function createFallbackCatalog(options: {
sourceMessage: string;
appServerState: CodexModelCatalogDto['diagnostics']['appServerState'];
status?: CodexModelCatalogDto['status'];
code?: string | null;
}): CodexModelCatalogDto {
const models = createStaticCodexModelCatalogModels();
const defaultModel = models.find((model) => model.isDefault) ?? models[0] ?? null;
return {
schemaVersion: 1,
providerId: 'codex',
source: 'static-fallback',
status: options.status ?? 'degraded',
fetchedAt: nowIso(),
staleAt: staleAtIso(),
defaultModelId: defaultModel?.id ?? null,
defaultLaunchModel: defaultModel?.launchModel ?? null,
models,
diagnostics: {
configReadState: 'skipped',
appServerState: options.appServerState,
message: options.sourceMessage,
code: options.code ?? null,
},
};
}
function markCatalogStale(
catalog: CodexModelCatalogDto,
diagnostics: CodexModelCatalogDto['diagnostics']
): CodexModelCatalogDto {
return {
...catalog,
status: 'stale',
diagnostics,
};
}
export function createCodexModelCatalogFeature(options: {
logger: LoggerPort;
codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'>;
}): CodexModelCatalogFeatureFacade {
const envBuilder = new CodexAccountEnvBuilder();
const cache = new InMemoryCodexModelCatalogCache();
const inFlightRefreshes = new Map<string, Promise<CodexModelCatalogDto>>();
let cacheGeneration = 0;
const client = new CodexModelCatalogAppServerClient(
new CodexAppServerSessionFactory(new JsonRpcStdioClient(options.logger))
);
async function getCatalog(request: CodexModelCatalogRequest = {}): Promise<CodexModelCatalogDto> {
const accountSnapshot = await options.codexAccountFeature.getSnapshot();
const binaryPath = await CodexBinaryResolver.resolve();
const binaryVersion = await CodexBinaryResolver.resolveVersion(binaryPath);
if (!binaryPath) {
return createFallbackCatalog({
sourceMessage: 'Codex CLI was not found. Showing static fallback model list.',
appServerState: 'runtime-missing',
status: 'unavailable',
});
}
const env = envBuilder.buildControlPlaneEnv({ binaryPath });
const preflightCacheKey = createCacheKey({
binaryPath,
binaryVersion,
accountSnapshot,
cwd: request.cwd,
profile: request.profile,
configFingerprint: null,
includeHidden: request.includeHidden,
});
if (request.forceRefresh !== true) {
const cached = cache.get(preflightCacheKey, CATALOG_CACHE_TTL_MS);
if (cached) {
return cached;
}
}
const existingRefresh = inFlightRefreshes.get(preflightCacheKey);
if (existingRefresh) {
return existingRefresh;
}
const refreshGeneration = cacheGeneration;
const refreshPromise = (async (): Promise<CodexModelCatalogDto> => {
let configFingerprint: string | null = null;
let configReadState: CodexModelCatalogDto['diagnostics']['configReadState'] = 'skipped';
let configReadMessage: string | null = null;
let cacheKey = preflightCacheKey;
try {
const payload = await client.readModelCatalogWithConfig({
binaryPath,
env,
includeHidden: request.includeHidden,
cwd: request.cwd,
profile: request.profile,
});
if (payload.config.ok) {
configReadState = 'ready';
configFingerprint = hashValue(payload.config.value);
} else {
configReadState =
payload.config.error instanceof JsonRpcRequestError &&
payload.config.error.code === -32601
? 'unsupported'
: 'failed';
configReadMessage =
payload.config.error instanceof Error
? payload.config.error.message
: String(payload.config.error);
}
cacheKey = createCacheKey({
binaryPath,
binaryVersion,
accountSnapshot,
cwd: request.cwd,
profile: request.profile,
configFingerprint,
includeHidden: request.includeHidden,
});
const normalized = normalizeCodexAppServerModels(
payload.modelCatalog.models ?? payload.modelCatalog.data,
{
includeHidden: request.includeHidden,
}
);
const defaultModel =
normalized.models.find((model) => model.id === normalized.defaultModelId) ??
normalized.models.find((model) => model.isDefault) ??
normalized.models[0] ??
null;
const diagnostics = [
...normalized.diagnostics,
configReadMessage ? `config/read: ${configReadMessage}` : null,
payload.modelCatalog.truncated
? 'model/list pagination reached the safety page limit; some Codex models may be omitted.'
: null,
].filter(Boolean);
const catalog: CodexModelCatalogDto = {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: nowIso(),
staleAt: staleAtIso(),
defaultModelId: defaultModel?.id ?? null,
defaultLaunchModel: defaultModel?.launchModel ?? null,
models: normalized.models,
diagnostics: {
configReadState,
appServerState: 'healthy',
message: diagnostics.length > 0 ? diagnostics.join(' ') : null,
code: null,
},
};
if (normalized.models.length === 0) {
throw new Error('Codex app-server model/list returned no visible models.');
}
if (refreshGeneration === cacheGeneration) {
setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], catalog);
}
return catalog;
} catch (error) {
const failure = classifyAppServerFailure(error);
const stale =
cache.getLatest(cacheKey) ??
(cacheKey === preflightCacheKey ? null : cache.getLatest(preflightCacheKey));
if (stale && Date.parse(stale.fetchedAt) + CATALOG_STALE_TTL_MS > Date.now()) {
return markCatalogStale(stale, {
configReadState,
appServerState: failure.appServerState,
message: failure.message,
code: failure.code,
});
}
options.logger.warn('codex model catalog refresh failed', {
error: failure.message,
code: failure.code,
});
const fallback = createFallbackCatalog({
sourceMessage: failure.message,
appServerState: failure.appServerState,
code: failure.code,
});
if (refreshGeneration === cacheGeneration) {
setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], fallback);
}
return fallback;
}
})();
inFlightRefreshes.set(preflightCacheKey, refreshPromise);
try {
return await refreshPromise;
} finally {
if (inFlightRefreshes.get(preflightCacheKey) === refreshPromise) {
inFlightRefreshes.delete(preflightCacheKey);
}
}
}
return {
getCatalog,
invalidate: () => {
cacheGeneration += 1;
cache.clear();
inFlightRefreshes.clear();
},
dispose: async () => {
cacheGeneration += 1;
cache.clear();
inFlightRefreshes.clear();
},
};
}

View file

@ -0,0 +1,5 @@
export type {
CodexModelCatalogFeatureFacade,
CodexModelCatalogRequest,
} from './composition/createCodexModelCatalogFeature';
export { createCodexModelCatalogFeature } from './composition/createCodexModelCatalogFeature';

View file

@ -0,0 +1,159 @@
import type {
CodexAppServerListModelsParams,
CodexAppServerListModelsResponse,
CodexAppServerReadConfigParams,
CodexAppServerReadConfigResponse,
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
const MODEL_LIST_PAGE_LIMIT = 100;
const MODEL_LIST_MAX_PAGES = 5;
const MODEL_LIST_TIMEOUT_MS = 4_500;
const CONFIG_READ_TIMEOUT_MS = 3_500;
const INITIALIZE_TIMEOUT_MS = 6_000;
const TOTAL_TIMEOUT_MS = 9_000;
export class CodexModelCatalogAppServerClient {
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
async readModelCatalogWithConfig(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
includeHidden?: boolean;
cwd?: string | null;
profile?: string | null;
}): Promise<{
modelCatalog: CodexAppServerListModelsResponse;
config: { ok: true; value: CodexAppServerReadConfigResponse } | { ok: false; error: unknown };
}> {
const configParams = this.buildConfigReadParams(options);
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: MODEL_LIST_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server model/list with config/read',
experimentalApi: false,
},
async (session) => {
const configPromise = session
.request<CodexAppServerReadConfigResponse>(
'config/read',
configParams,
CONFIG_READ_TIMEOUT_MS
)
.then((value) => ({ ok: true as const, value }))
.catch((error: unknown) => ({ ok: false as const, error }));
const modelCatalogPromise = this.readModelCatalogPages(session, {
includeHidden: options.includeHidden,
});
const [config, modelCatalog] = await Promise.all([configPromise, modelCatalogPromise]);
return {
config,
modelCatalog,
};
}
);
}
async readModelCatalog(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
includeHidden?: boolean;
}): Promise<CodexAppServerListModelsResponse> {
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: MODEL_LIST_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server model/list',
experimentalApi: false,
},
async (session) =>
this.readModelCatalogPages(session, {
includeHidden: options.includeHidden,
})
);
}
async readConfig(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
cwd?: string | null;
profile?: string | null;
}): Promise<CodexAppServerReadConfigResponse> {
const params = this.buildConfigReadParams(options);
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: CONFIG_READ_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server config/read',
experimentalApi: false,
},
async (session) =>
session.request<CodexAppServerReadConfigResponse>(
'config/read',
params,
CONFIG_READ_TIMEOUT_MS
)
);
}
private buildConfigReadParams(options: {
cwd?: string | null;
profile?: string | null;
}): CodexAppServerReadConfigParams {
const params: CodexAppServerReadConfigParams = {};
if (options.cwd?.trim()) {
params.cwd = options.cwd.trim();
}
if (options.profile?.trim()) {
params.profile = options.profile.trim();
}
return params;
}
private async readModelCatalogPages(
session: CodexAppServerSession,
options: { includeHidden?: boolean }
): Promise<CodexAppServerListModelsResponse> {
const data: NonNullable<CodexAppServerListModelsResponse['data']> = [];
let cursor: string | null = null;
let nextCursor: string | null = null;
for (let page = 0; page < MODEL_LIST_MAX_PAGES; page += 1) {
const payload: CodexAppServerListModelsResponse =
await session.request<CodexAppServerListModelsResponse>(
'model/list',
{
cursor,
limit: MODEL_LIST_PAGE_LIMIT,
includeHidden: options.includeHidden === true,
} satisfies CodexAppServerListModelsParams,
MODEL_LIST_TIMEOUT_MS
);
data.push(...(payload.data ?? payload.models ?? []));
nextCursor = payload.nextCursor ?? null;
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return {
data,
nextCursor,
truncated: nextCursor !== null,
};
}
}

View file

@ -0,0 +1,37 @@
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
interface CacheEntry {
value: CodexModelCatalogDto;
observedAt: number;
}
export class InMemoryCodexModelCatalogCache {
private readonly entries = new Map<string, CacheEntry>();
get(key: string, maxAgeMs: number): CodexModelCatalogDto | null {
const entry = this.entries.get(key);
if (!entry) {
return null;
}
if (Date.now() - entry.observedAt > maxAgeMs) {
return null;
}
return structuredClone(entry.value);
}
getLatest(key: string): CodexModelCatalogDto | null {
const entry = this.entries.get(key);
return entry ? structuredClone(entry.value) : null;
}
set(key: string, value: CodexModelCatalogDto): void {
this.entries.set(key, {
value: structuredClone(value),
observedAt: Date.now(),
});
}
clear(): void {
this.entries.clear();
}
}

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { CodexModelCatalogAppServerClient } from '../CodexModelCatalogAppServerClient';
import type {
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
describe('CodexModelCatalogAppServerClient', () => {
it('reads config and paginated model/list in one app-server session', async () => {
const requests: Array<{ method: string; params: unknown }> = [];
let sessionCount = 0;
const session: CodexAppServerSession = {
initializeResponse: {
userAgent: 'codex-cli 0.117.0',
codexHome: '/Users/me/.codex',
platformFamily: 'macos',
platformOs: 'darwin',
},
request: async <TResult>(method: string, params?: unknown): Promise<TResult> => {
requests.push({ method, params });
if (method === 'config/read') {
return { config: { model: 'gpt-5.4' }, origins: {} } as TResult;
}
if (method === 'model/list') {
const cursor = (params as { cursor?: string | null }).cursor ?? null;
if (cursor === null) {
return {
data: [{ id: 'gpt-5.4', model: 'gpt-5.4' }],
nextCursor: 'page-2',
} as TResult;
}
return {
data: [{ id: 'gpt-5.5', model: 'gpt-5.5' }],
nextCursor: null,
} as TResult;
}
throw new Error(`Unexpected method ${method}`);
},
notify: async () => undefined,
onNotification: () => () => undefined,
close: async () => undefined,
};
const factory = {
withSession: async <TResult>(
_options: unknown,
handler: (session: CodexAppServerSession) => Promise<TResult>
): Promise<TResult> => {
sessionCount += 1;
return handler(session);
},
} as unknown as CodexAppServerSessionFactory;
const client = new CodexModelCatalogAppServerClient(factory);
const result = await client.readModelCatalogWithConfig({
binaryPath: '/usr/local/bin/codex',
env: {},
cwd: '/repo',
profile: 'work',
});
expect(sessionCount).toBe(1);
expect(result.config).toEqual({
ok: true,
value: { config: { model: 'gpt-5.4' }, origins: {} },
});
expect(result.modelCatalog).toEqual({
data: [
{ id: 'gpt-5.4', model: 'gpt-5.4' },
{ id: 'gpt-5.5', model: 'gpt-5.5' },
],
nextCursor: null,
truncated: false,
});
expect(requests).toEqual([
{ method: 'config/read', params: { cwd: '/repo', profile: 'work' } },
{
method: 'model/list',
params: { cursor: null, limit: 100, includeHidden: false },
},
{
method: 'model/list',
params: { cursor: 'page-2', limit: 100, includeHidden: false },
},
]);
});
});

View file

@ -1,5 +1,9 @@
import { validateTeamName } from '@main/ipc/guards';
import { getErrorMessage } from '@shared/utils/errorHandling';
import {
formatEffortLevelListForProvider,
isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isAbsolute } from 'path';
@ -12,8 +16,6 @@ const logger = createLogger('HTTP:teams');
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError extends Error {}
@ -76,16 +78,21 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und
return value;
}
function assertOptionalEffort(value: unknown): EffortLevel | undefined {
function assertOptionalEffort(
value: unknown,
providerId: TeamLaunchRequest['providerId']
): EffortLevel | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) {
throw new HttpBadRequestError('effort must be one of: low, medium, high');
if (!isTeamEffortLevelForProvider(value, providerId)) {
throw new HttpBadRequestError(
`effort must be one of: ${formatEffortLevelListForProvider(providerId)}`
);
}
return value as EffortLevel;
return value;
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
@ -109,7 +116,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
);
}
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
const effort = assertOptionalEffort(payload.effort, providerId);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');

View file

@ -25,6 +25,10 @@ import {
registerCodexAccountIpc,
removeCodexAccountIpc,
} from '@features/codex-account/main';
import {
createCodexModelCatalogFeature,
type CodexModelCatalogFeatureFacade,
} from '@features/codex-model-catalog/main';
import {
createRecentProjectsFeature,
type RecentProjectsFeatureFacade,
@ -422,6 +426,7 @@ let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
@ -988,6 +993,11 @@ async function initializeServices(): Promise<void> {
configManager,
});
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
codexModelCatalogFeature = createCodexModelCatalogFeature({
logger: createLogger('Feature:CodexModelCatalog'),
codexAccountFeature,
});
providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature);
// startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
@ -1185,7 +1195,10 @@ function shutdownServices(): void {
}
void skillsWatcherService?.stopAll();
providerConnectionService.setCodexModelCatalogFeature(null);
providerConnectionService.setCodexAccountFeature(null);
void codexModelCatalogFeature?.dispose();
codexModelCatalogFeature = null;
void codexAccountFeature?.dispose();
codexAccountFeature = null;

View file

@ -91,6 +91,10 @@ import {
PROTECTED_CLI_FLAGS,
} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import {
formatEffortLevelListForProvider,
isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
@ -1112,10 +1116,8 @@ function isProvisioningTeamName(teamName: string): boolean {
return parts.every((p) => /^[a-z0-9]+$/.test(p));
}
const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high'];
function isValidEffort(value: unknown): value is EffortLevel {
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel {
return isTeamEffortLevelForProvider(value, providerId);
}
function parseOptionalMemberProviderId(
@ -1165,15 +1167,35 @@ function parseOptionalProviderBackendId(
}
function parseOptionalMemberEffort(
value: unknown
value: unknown,
providerId?: TeamProviderId | null
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (isValidEffort(value)) {
if (isValidEffort(value, providerId)) {
return { valid: true, value };
}
return { valid: false, error: 'member effort must be low, medium, or high' };
return {
valid: false,
error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
};
}
function parseOptionalTeamEffort(
value: unknown,
providerId?: TeamProviderId | null
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (isValidEffort(value, providerId)) {
return { valid: true, value };
}
return {
valid: false,
error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
};
}
async function validateProvisioningRequest(
@ -1202,6 +1224,12 @@ async function validateProvisioningRequest(
if (!Array.isArray(payload.members)) {
return { valid: false, error: 'members must be an array' };
}
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const seenNames = new Set<string>();
const members: TeamCreateRequest['members'] = [];
@ -1237,12 +1265,20 @@ async function validateProvisioningRequest(
if (model !== undefined && typeof model !== 'string') {
return { valid: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort(
(member as { effort?: unknown }).effort,
providerValidation.value ?? providerId
);
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
providerId: providerValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
});
}
@ -1257,12 +1293,6 @@ async function validateProvisioningRequest(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' };
}
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerId
@ -1270,6 +1300,10 @@ async function validateProvisioningRequest(
if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error };
}
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
@ -1324,7 +1358,7 @@ async function validateProvisioningRequest(
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
effort: effortValidation.value,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
@ -1474,6 +1508,10 @@ async function handleLaunchTeam(
: meta?.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const createRequest: TeamCreateRequest = {
teamName: tn,
@ -1488,7 +1526,7 @@ async function handleLaunchTeam(
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
),
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
effort: effortValidation.value,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -1520,6 +1558,11 @@ async function handleLaunchTeam(
);
}
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
return wrapTeamHandler('launch', () => {
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
return getTeamProvisioningService().launchTeam(
@ -1530,7 +1573,7 @@ async function handleLaunchTeam(
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
effort: effortValidation.value,
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -2652,7 +2695,10 @@ async function handleCreateConfig(
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(member as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
@ -3090,7 +3136,10 @@ async function handleAddMember(
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(payload as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
@ -3162,7 +3211,7 @@ async function handleReplaceMembers(
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: EffortLevel;
}[] = [];
for (const item of payload.members) {
if (!item || typeof item !== 'object') {
@ -3196,7 +3245,10 @@ async function handleReplaceMembers(
if (m.model !== undefined && typeof m.model !== 'string') {
return { success: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(m as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}

View file

@ -152,7 +152,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
providers: status.providers.map((provider) => ({
...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle',
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
runtimeCapabilities: provider.runtimeCapabilities
? structuredClone(provider.runtimeCapabilities)
: null,
capabilities: {
...provider.capabilities,
extensions: {

View file

@ -2,11 +2,15 @@ import { constants as fsConstants } from 'node:fs';
import * as fsp from 'node:fs/promises';
import path from 'node:path';
import { execCli } from '@main/utils/childProcess';
const CACHE_VERIFY_TTL_MS = 30_000;
const VERSION_CACHE_TTL_MS = 30_000;
let cachedBinaryPath: string | null | undefined;
let cacheVerifiedAt = 0;
let resolveInFlight: Promise<string | null> | null = null;
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
async function fileExists(filePath: string): Promise<boolean> {
try {
@ -69,6 +73,7 @@ export class CodexBinaryResolver {
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
resolveInFlight = null;
versionCache.clear();
}
static async resolve(): Promise<string | null> {
@ -117,4 +122,34 @@ export class CodexBinaryResolver {
cacheVerifiedAt = Date.now();
return null;
}
static async resolveVersion(binaryPath: string | null | undefined): Promise<string | null> {
const normalizedPath = binaryPath?.trim();
if (!normalizedPath) {
return null;
}
const cached = versionCache.get(normalizedPath);
if (cached && Date.now() - cached.observedAt <= VERSION_CACHE_TTL_MS) {
return cached.version;
}
try {
const result = await execCli(normalizedPath, ['--version'], {
timeout: 3_000,
});
const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null;
versionCache.set(normalizedPath, {
version,
observedAt: Date.now(),
});
return version;
} catch {
versionCache.set(normalizedPath, {
version: null,
observedAt: Date.now(),
});
return null;
}
}
}

View file

@ -10,6 +10,7 @@ interface JsonRpcLogger {
interface JsonRpcErrorPayload {
code?: number;
message?: string;
data?: unknown;
}
interface JsonRpcResponse<T> {
@ -49,6 +50,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
export class JsonRpcRequestError extends Error {
readonly code: number | null;
readonly data: unknown;
readonly details: unknown;
readonly method: string;
constructor(method: string, payload: JsonRpcErrorPayload) {
super(payload.message ?? 'Unknown JSON-RPC error');
this.name = 'JsonRpcRequestError';
this.method = method;
this.code = typeof payload.code === 'number' ? payload.code : null;
this.data = payload.data;
this.details = payload.data;
}
}
export class JsonRpcStdioClient {
constructor(private readonly logger: JsonRpcLogger) {}
@ -93,6 +110,7 @@ export class JsonRpcStdioClient {
const pending = new Map<
number,
{
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
@ -149,7 +167,7 @@ export class JsonRpcStdioClient {
pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
entry.reject(new JsonRpcRequestError(entry.method, message.error));
return;
}
@ -222,17 +240,25 @@ export class JsonRpcStdioClient {
reject(new Error(`JSON-RPC request timed out: ${method}`));
}, timeoutMs);
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId });
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
if (!error) {
return;
}
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
pending.set(id, {
method,
resolve: resolve as (value: unknown) => void,
reject,
timeoutId,
});
child.stdin.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
(error) => {
if (!error) {
return;
}
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}
);
}),
notify: async (method: string, params?: unknown): Promise<void> => {
@ -241,7 +267,7 @@ export class JsonRpcStdioClient {
}
await new Promise<void>((resolve, reject) => {
child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => {
child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`, (error) => {
if (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;

View file

@ -0,0 +1,79 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
const tempDirs: string[] = [];
function createStrictJsonRpcServerScript(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
tempDirs.push(tempDir);
const scriptPath = path.join(tempDir, 'server.cjs');
fs.writeFileSync(
scriptPath,
`
const readline = require('node:readline');
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
const message = JSON.parse(line);
if (message.jsonrpc !== '2.0') {
return;
}
if (message.method === 'fail') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: 'No such method', data: { method: message.method } },
}) + '\\n');
return;
}
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: { ok: true, params: message.params },
}) + '\\n');
});
`,
'utf8'
);
return scriptPath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('JsonRpcStdioClient', () => {
it('sends JSON-RPC 2.0 framed requests and preserves structured errors', async () => {
const scriptPath = createStrictJsonRpcServerScript();
const client = new JsonRpcStdioClient({ warn: () => undefined });
await client.withSession(
{
binaryPath: process.execPath,
args: [scriptPath],
label: 'strict json-rpc smoke',
requestTimeoutMs: 1_000,
totalTimeoutMs: 2_000,
},
async (session) => {
await expect(session.request('ping', { value: 1 })).resolves.toEqual({
ok: true,
params: { value: 1 },
});
await expect(session.request('fail')).rejects.toMatchObject({
method: 'fail',
code: -32601,
data: { method: 'fail' },
details: { method: 'fail' },
});
}
);
});
});

View file

@ -5,7 +5,7 @@ export {
} from './CodexAppServerSessionFactory';
export { CodexBinaryResolver } from './CodexBinaryResolver';
export type { JsonRpcSession } from './JsonRpcStdioClient';
export { JsonRpcStdioClient } from './JsonRpcStdioClient';
export { JsonRpcRequestError, JsonRpcStdioClient } from './JsonRpcStdioClient';
export type {
CodexAppServerAccount,
CodexAppServerAccountLoginCompletedNotification,
@ -20,10 +20,17 @@ export type {
CodexAppServerGetAccountRateLimitsResponse,
CodexAppServerGetAccountResponse,
CodexAppServerInitializeResponse,
CodexAppServerListModelsParams,
CodexAppServerListModelsResponse,
CodexAppServerLoginAccountParams,
CodexAppServerLoginAccountResponse,
CodexAppServerLogoutAccountResponse,
CodexAppServerModel,
CodexAppServerPlanType,
CodexAppServerRateLimitSnapshot,
CodexAppServerRateLimitWindow,
CodexAppServerReadConfigParams,
CodexAppServerReadConfigResponse,
CodexAppServerReasoningEffort,
CodexAppServerReasoningEffortOption,
} from './protocol';

View file

@ -111,3 +111,53 @@ export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound';
export interface CodexAppServerCancelLoginAccountResponse {
status: CodexAppServerCancelLoginAccountStatus;
}
export type CodexAppServerReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
export interface CodexAppServerReasoningEffortOption {
reasoningEffort?: string;
description?: string | null;
}
export interface CodexAppServerModel {
id?: string;
model?: string;
displayName?: string;
hidden?: boolean;
supportedReasoningEfforts?: (string | CodexAppServerReasoningEffortOption)[];
defaultReasoningEffort?: string | null;
inputModalities?: string[] | null;
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: boolean | string | null;
upgradeInfo?: unknown;
}
export interface CodexAppServerListModelsParams {
cursor?: string | null;
limit?: number | null;
includeHidden?: boolean;
}
export interface CodexAppServerListModelsResponse {
data?: CodexAppServerModel[];
models?: CodexAppServerModel[];
nextCursor?: string | null;
truncated?: boolean;
}
export interface CodexAppServerReadConfigParams {
cwd?: string;
profile?: string;
}
export interface CodexAppServerReadConfigResponse {
config?: Record<string, unknown>;
origins?: Record<string, unknown>;
}

View file

@ -30,6 +30,18 @@ interface RuntimeExtensionCapabilitiesResponse {
apiKeys?: RuntimeExtensionCapabilityResponse;
}
interface RuntimeProviderCapabilitiesResponse {
modelCatalog?: {
dynamic?: boolean;
source?: 'app-server' | 'static-fallback' | 'runtime';
};
reasoningEffort?: {
supported?: boolean;
values?: string[];
configPassthrough?: boolean;
};
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -53,6 +65,7 @@ interface ProviderStatusCommandResponse {
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
}
>;
}
@ -119,6 +132,7 @@ interface UnifiedRuntimeStatusResponse {
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
}
>;
}
@ -164,6 +178,8 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
modelCatalog: null,
runtimeCapabilities: null,
};
}
@ -301,6 +317,34 @@ export class ClaudeMultimodelBridgeService {
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
}
: null,
runtimeCapabilities: runtimeStatus.runtimeCapabilities
? {
modelCatalog: runtimeStatus.runtimeCapabilities.modelCatalog
? {
dynamic: runtimeStatus.runtimeCapabilities.modelCatalog.dynamic === true,
source: runtimeStatus.runtimeCapabilities.modelCatalog.source,
}
: undefined,
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
? {
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
values:
runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) =>
value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh'
? [value]
: []
) ?? [],
configPassthrough:
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
}
: undefined,
}
: null,
};
}

View file

@ -11,10 +11,12 @@ import type {
CodexAccountSnapshotDto,
} from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main';
import type {
CliProviderAuthMode,
CliProviderConnectionInfo,
CliProviderId,
CliProviderReasoningEffort,
CliProviderStatus,
} from '@shared/types';
@ -77,6 +79,8 @@ function buildCodexForcedLoginLaunchArgs(
export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
null;
constructor(
private readonly apiKeyService = new ApiKeyService(),
@ -92,6 +96,12 @@ export class ProviderConnectionService {
this.codexAccountFeature = feature;
}
setCodexModelCatalogFeature(
feature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null
): void {
this.codexModelCatalogFeature = feature;
}
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
if (providerId === 'anthropic') {
return this.configManager.getConfig().providerConnections.anthropic.authMode;
@ -353,10 +363,53 @@ export class ProviderConnectionService {
}
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
return {
const withConnection = {
...provider,
connection: await this.getConnectionInfo(provider.providerId),
};
if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) {
return withConnection;
}
try {
const catalog = await this.codexModelCatalogFeature.getCatalog();
const models = catalog.models
.filter((model) => !model.hidden)
.map((model) => model.launchModel.trim())
.filter(Boolean);
const reasoningEfforts = Array.from(
new Set(
catalog.models.flatMap<CliProviderReasoningEffort>(
(model) => model.supportedReasoningEfforts
)
)
);
const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort;
const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog;
return {
...withConnection,
models: models.length > 0 ? models : withConnection.models,
modelCatalog: catalog,
runtimeCapabilities: {
...withConnection.runtimeCapabilities,
modelCatalog: {
dynamic: runtimeModelCatalogCapability?.dynamic === true,
source: catalog.source,
},
reasoningEffort: {
supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0,
values:
runtimeReasoningCapability?.values && runtimeReasoningCapability.values.length > 0
? runtimeReasoningCapability.values
: (['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[]),
configPassthrough: runtimeReasoningCapability?.configPassthrough === true,
},
},
};
} catch {
return withConnection;
}
}
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {

View file

@ -8,6 +8,7 @@ import {
wrapAgentBlock,
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
@ -1258,10 +1259,7 @@ export class TeamDataService {
? request.providerId
: undefined,
model: request.model?.trim() || undefined,
effort:
request.effort === 'low' || request.effort === 'medium' || request.effort === 'high'
? request.effort
: undefined,
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
agentType: 'general-purpose',
joinedAt: Date.now(),
};
@ -1297,7 +1295,7 @@ export class TeamDataService {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
}[];
}
): Promise<void> {
@ -1339,10 +1337,7 @@ export class TeamDataService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: prev?.agentType ?? 'general-purpose',
agentId: isSameActiveMember ? prev?.agentId : undefined,
color: prev?.color,
@ -2418,10 +2413,7 @@ export class TeamDataService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: 'general-purpose' as const,
joinedAt,
}))

View file

@ -123,7 +123,7 @@ export class TeamMemberResolver {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
color?: string;
cwd?: string;
}
@ -166,7 +166,7 @@ export class TeamMemberResolver {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
color?: string;
removedAt?: number;
}

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import * as fs from 'fs';
@ -36,10 +37,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,

View file

@ -6,6 +6,8 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types';
/**
* Persisted team-level metadata saved by the UI before CLI provisioning.
* CLI does not know about this file it only reads/writes config.json.
@ -27,6 +29,7 @@ export interface TeamMetaFile {
worktree?: string;
extraCliArgs?: string;
limitContext?: boolean;
launchIdentity?: ProviderModelLaunchIdentity;
createdAt: number;
}
@ -40,6 +43,70 @@ function normalizeOptionalBackendId(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeProviderId(value: unknown): TeamProviderId | undefined {
return value === 'anthropic' || value === 'codex' || value === 'gemini' ? value : undefined;
}
function normalizeOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const raw = value as Partial<ProviderModelLaunchIdentity>;
const providerId = normalizeProviderId(raw.providerId);
const selectedModelKind =
raw.selectedModelKind === 'default' || raw.selectedModelKind === 'explicit'
? raw.selectedModelKind
: null;
if (!providerId || !selectedModelKind) {
return undefined;
}
const catalogSource =
raw.catalogSource === 'app-server' ||
raw.catalogSource === 'static-fallback' ||
raw.catalogSource === 'runtime' ||
raw.catalogSource === 'unavailable'
? raw.catalogSource
: 'unavailable';
const selectedEffort =
raw.selectedEffort === 'none' ||
raw.selectedEffort === 'minimal' ||
raw.selectedEffort === 'low' ||
raw.selectedEffort === 'medium' ||
raw.selectedEffort === 'high' ||
raw.selectedEffort === 'xhigh'
? raw.selectedEffort
: null;
const resolvedEffort =
raw.resolvedEffort === 'none' ||
raw.resolvedEffort === 'minimal' ||
raw.resolvedEffort === 'low' ||
raw.resolvedEffort === 'medium' ||
raw.resolvedEffort === 'high' ||
raw.resolvedEffort === 'xhigh'
? raw.resolvedEffort
: null;
return {
providerId,
providerBackendId:
migrateProviderBackendId(providerId, normalizeOptionalString(raw.providerBackendId)) ?? null,
selectedModel: normalizeOptionalString(raw.selectedModel),
selectedModelKind,
resolvedLaunchModel: normalizeOptionalString(raw.resolvedLaunchModel),
catalogId: normalizeOptionalString(raw.catalogId),
catalogSource,
catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt),
selectedEffort,
resolvedEffort,
};
}
export class TeamMetaStore {
private getMetaPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
@ -110,6 +177,7 @@ export class TeamMetaStore {
extraCliArgs:
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
launchIdentity: normalizeLaunchIdentity(file.launchIdentity),
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
};
}
@ -133,6 +201,7 @@ export class TeamMetaStore {
worktree: data.worktree?.trim() || undefined,
extraCliArgs: data.extraCliArgs?.trim() || undefined,
limitContext: data.limitContext,
launchIdentity: normalizeLaunchIdentity(data.launchIdentity),
createdAt: data.createdAt,
};
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));

View file

@ -40,6 +40,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import {
isInboxNoiseMessage,
@ -169,6 +170,7 @@ interface RelayInboxMessageView {
import type {
ActiveToolCall,
CliProviderRuntimeCapabilities,
CrossTeamSendResult,
EffortLevel,
InboxMessage,
@ -179,6 +181,7 @@ import type {
MemberSpawnStatusEntry,
PersistedTeamLaunchPhase,
PersistedTeamLaunchSummary,
ProviderModelLaunchIdentity,
TeamAgentRuntimeBackendType,
TeamAgentRuntimeEntry,
TeamAgentRuntimeSnapshot,
@ -320,6 +323,21 @@ interface ProviderModelListCommandResponse {
>;
}
interface RuntimeStatusCommandResponse {
providers?: Record<
string,
{
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
}
>;
}
interface RuntimeProviderLaunchFacts {
defaultModel: string | null;
modelIds: Set<string>;
runtimeCapabilities: CliProviderRuntimeCapabilities | null;
}
function extractJsonObjectFromCli<T>(raw: string): T {
const trimmed = raw.trim();
try {
@ -334,6 +352,65 @@ function extractJsonObjectFromCli<T>(raw: string): T {
}
}
function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
return undefined;
}
return trimmed;
}
function getLaunchModelArg(
providerId: TeamProviderId,
model: string | undefined,
launchIdentity?: ProviderModelLaunchIdentity | null
): string | undefined {
const explicitModel = getExplicitLaunchModelSelection(model);
if (explicitModel) {
return explicitModel;
}
if (
providerId === 'codex' &&
launchIdentity?.selectedModelKind === 'default' &&
launchIdentity.resolvedLaunchModel
) {
return launchIdentity.resolvedLaunchModel;
}
return undefined;
}
function normalizeProviderModelListModels(
provider: NonNullable<ProviderModelListCommandResponse['providers']>[string] | undefined
): Set<string> {
const models = new Set<string>();
for (const entry of provider?.models ?? []) {
const modelId = typeof entry === 'string' ? entry : entry.id;
const trimmed = modelId?.trim();
if (trimmed) {
models.add(trimmed);
}
}
return models;
}
function isLegacySafeEffort(effort: EffortLevel): boolean {
return effort === 'low' || effort === 'medium' || effort === 'high';
}
function isCodexEffortRuntimeSupported(
effort: EffortLevel,
capabilities: CliProviderRuntimeCapabilities | null
): boolean {
if (isLegacySafeEffort(effort)) {
return true;
}
const reasoning = capabilities?.reasoningEffort;
return reasoning?.configPassthrough === true && reasoning.values.includes(effort);
}
function isProbeTimeoutMessage(message: string): boolean {
const lower = message.toLowerCase();
return (
@ -476,6 +553,7 @@ function logRuntimeLaunchSnapshot(
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
promptSize?: PromptSizeSummary | null;
expectedMembersCount?: number;
launchIdentity?: ProviderModelLaunchIdentity | null;
}
): void {
const providerId = resolveTeamProviderId(request.providerId);
@ -489,6 +567,7 @@ function logRuntimeLaunchSnapshot(
getConfiguredRuntimeBackend(providerId),
promptSize: options?.promptSize ?? null,
expectedMembersCount: options?.expectedMembersCount ?? null,
launchIdentity: options?.launchIdentity ?? null,
geminiRuntimeAuth:
providerId === 'gemini'
? {
@ -1257,17 +1336,22 @@ function buildEffectiveTeamMemberSpec(
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
const model =
member.model?.trim() ||
getExplicitLaunchModelSelection(member.model) ||
(memberProviderId == null || memberProviderId === defaultProviderId
? defaults.model?.trim()
? getExplicitLaunchModelSelection(defaults.model)
: undefined) ||
undefined;
const effort =
member.effort ??
(memberProviderId == null || memberProviderId === defaultProviderId
? defaults.effort
: undefined);
return {
...member,
providerId: effectiveProviderId,
model,
effort: member.effort ?? defaults.effort,
effort,
};
}
@ -2859,6 +2943,214 @@ export class TeamProvisioningService {
this.controlApiBaseUrlResolver = resolver;
}
private async readRuntimeProviderLaunchFacts(params: {
claudePath: string;
cwd: string;
providerId: TeamProviderId;
env: NodeJS.ProcessEnv;
limitContext?: boolean;
}): Promise<RuntimeProviderLaunchFacts> {
if (params.providerId === 'anthropic') {
return {
defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true),
modelIds: new Set<string>(),
runtimeCapabilities: null,
};
}
const modelListPromise = execCli(
params.claudePath,
['model', 'list', '--json', '--provider', params.providerId],
{
cwd: params.cwd,
env: params.env,
timeout: 10_000,
}
);
const runtimeStatusPromise =
params.providerId === 'codex'
? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], {
cwd: params.cwd,
env: params.env,
timeout: 8_000,
})
: null;
const [modelListResult, runtimeStatusResult] = await Promise.allSettled([
modelListPromise,
runtimeStatusPromise,
]);
let defaultModel: string | null = null;
let modelIds = new Set<string>();
if (modelListResult.status === 'fulfilled') {
try {
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(
modelListResult.value.stdout
);
const provider = parsed.providers?.[params.providerId];
defaultModel =
typeof provider?.defaultModel === 'string' && provider.defaultModel.trim().length > 0
? provider.defaultModel.trim()
: null;
modelIds = normalizeProviderModelListModels(provider);
} catch (error) {
logger.warn(
`[${params.providerId}] Failed to parse runtime model list for launch validation: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null;
if (
runtimeStatusResult.status === 'fulfilled' &&
runtimeStatusResult.value &&
typeof runtimeStatusResult.value.stdout === 'string'
) {
try {
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(
runtimeStatusResult.value.stdout
);
runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null;
} catch (error) {
logger.warn(
`[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return {
defaultModel,
modelIds,
runtimeCapabilities,
};
}
private buildProviderModelLaunchIdentity(params: {
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>;
facts: RuntimeProviderLaunchFacts;
}): ProviderModelLaunchIdentity {
const providerId = resolveTeamProviderId(params.request.providerId);
const explicitModel = getExplicitLaunchModelSelection(params.request.model);
const resolvedLaunchModel = explicitModel ?? params.facts.defaultModel;
const resolvedEffort = params.request.effort ?? null;
return {
providerId,
providerBackendId:
migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null,
selectedModel: explicitModel ?? null,
selectedModelKind: explicitModel ? 'explicit' : 'default',
resolvedLaunchModel,
catalogId: resolvedLaunchModel,
catalogSource: 'runtime',
catalogFetchedAt: null,
selectedEffort: params.request.effort ?? null,
resolvedEffort,
};
}
private validateRuntimeLaunchSelection(params: {
actorLabel: string;
providerId: TeamProviderId;
model?: string;
effort?: EffortLevel;
facts: RuntimeProviderLaunchFacts;
}): void {
const explicitModel = getExplicitLaunchModelSelection(params.model);
if (params.providerId !== 'codex') {
if (params.effort && !isLegacySafeEffort(params.effort)) {
throw new Error(
`${params.actorLabel} uses effort "${params.effort}", but ${getTeamProviderLabel(
params.providerId
)} currently supports only low, medium, or high effort in Agent Teams.`
);
}
return;
}
if (
params.effort &&
!isCodexEffortRuntimeSupported(params.effort, params.facts.runtimeCapabilities)
) {
throw new Error(
`${params.actorLabel} uses Codex effort "${params.effort}", but this Agent Teams runtime does not expose Codex reasoning config passthrough yet. Use low, medium, or high for now.`
);
}
if (!explicitModel || params.facts.modelIds.has(explicitModel)) {
return;
}
if (params.facts.runtimeCapabilities?.modelCatalog?.dynamic === true) {
return;
}
throw new Error(
`${params.actorLabel} uses Codex model "${explicitModel}", but this Agent Teams runtime does not declare dynamic Codex model launch support yet. Upgrade the runtime or pick a listed Codex model.`
);
}
private async resolveAndValidateLaunchIdentity(params: {
claudePath: string;
cwd: string;
env: NodeJS.ProcessEnv;
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
>;
effectiveMembers: TeamCreateRequest['members'];
}): Promise<ProviderModelLaunchIdentity> {
const leadProviderId = resolveTeamProviderId(params.request.providerId);
const factsByProvider = new Map<TeamProviderId, RuntimeProviderLaunchFacts>();
const getFacts = async (providerId: TeamProviderId): Promise<RuntimeProviderLaunchFacts> => {
const cached = factsByProvider.get(providerId);
if (cached) {
return cached;
}
const facts = await this.readRuntimeProviderLaunchFacts({
claudePath: params.claudePath,
cwd: params.cwd,
providerId,
env: params.env,
limitContext: params.request.limitContext,
});
factsByProvider.set(providerId, facts);
return facts;
};
const leadFacts = await getFacts(leadProviderId);
this.validateRuntimeLaunchSelection({
actorLabel: 'Team lead',
providerId: leadProviderId,
model: params.request.model,
effort: params.request.effort,
facts: leadFacts,
});
for (const member of params.effectiveMembers) {
const memberProviderId = resolveTeamProviderId(member.providerId);
const memberFacts = await getFacts(memberProviderId);
this.validateRuntimeLaunchSelection({
actorLabel: `Member ${member.name}`,
providerId: memberProviderId,
model: member.model,
effort: member.effort,
facts: memberFacts,
});
}
return this.buildProviderModelLaunchIdentity({
request: params.request,
facts: leadFacts,
});
}
async getClaudeLogs(
teamName: string,
query?: { offset?: number; limit?: number }
@ -6227,6 +6519,13 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv,
limitContext: request.limitContext,
});
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
claudePath,
cwd: request.cwd,
env: shellEnv,
request,
effectiveMembers: effectiveMemberSpecs,
});
const runId = randomUUID();
const startedAt = nowIso();
const run: ProvisioningRun = {
@ -6363,6 +6662,11 @@ export class TeamProvisioningService {
run.bootstrapUserPromptPath = null;
throw error;
}
const launchModelArg = getLaunchModelArg(
resolveTeamProviderId(request.providerId),
request.model,
launchIdentity
);
const spawnArgs = [
'--input-format',
'stream-json',
@ -6385,7 +6689,7 @@ export class TeamProvisioningService {
...(request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(request.model ? ['--model', request.model] : []),
...(launchModelArg ? ['--model', launchModelArg] : []),
...(request.effort ? ['--effort', request.effort] : []),
...(request.worktree ? ['--worktree', request.worktree] : []),
...parseCliArgs(request.extraCliArgs),
@ -6400,6 +6704,7 @@ export class TeamProvisioningService {
geminiRuntimeAuth,
promptSize,
expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity,
});
try {
// Pre-save our meta files before spawn — CLI doesn't touch these.
@ -6422,6 +6727,7 @@ export class TeamProvisioningService {
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext,
launchIdentity,
createdAt: Date.now(),
});
const membersToWrite = applyDistinctProvisioningMemberColors(
@ -6431,10 +6737,7 @@ export class TeamProvisioningService {
workflow: m.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(m.providerId),
model: m.model?.trim() || undefined,
effort:
m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
? m.effort
: undefined,
effort: isTeamEffortLevel(m.effort) ? m.effort : undefined,
agentType: 'general-purpose' as const,
joinedAt: Date.now(),
}))
@ -6804,6 +7107,13 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv,
limitContext: request.limitContext,
});
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
claudePath,
cwd: request.cwd,
env: shellEnv,
request,
effectiveMembers: effectiveMemberSpecs,
});
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = {
@ -7013,8 +7323,13 @@ export class TeamProvisioningService {
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
);
}
if (request.model) {
launchArgs.push('--model', request.model);
const launchModelArg = getLaunchModelArg(
resolveTeamProviderId(request.providerId),
request.model,
launchIdentity
);
if (launchModelArg) {
launchArgs.push('--model', launchModelArg);
}
if (request.effort) {
launchArgs.push('--effort', request.effort);
@ -7033,6 +7348,7 @@ export class TeamProvisioningService {
geminiRuntimeAuth,
promptSize,
expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity,
});
// --resume is added above when a valid previous session JSONL exists.
// Without it, CLI creates a fresh session ID automatically.
@ -7050,6 +7366,7 @@ export class TeamProvisioningService {
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext,
launchIdentity,
createdAt: Date.now(),
});
await this.membersMetaStore.writeMembers(
@ -7060,10 +7377,7 @@ export class TeamProvisioningService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt: Date.now(),
@ -8514,16 +8828,11 @@ export class TeamProvisioningService {
normalizeTeamMemberProviderId(metaMember?.providerId) ??
normalizeTeamMemberProviderId(configuredMember?.providerId);
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
const effort =
metaMember?.effort === 'low' ||
metaMember?.effort === 'medium' ||
metaMember?.effort === 'high'
? metaMember.effort
: configuredMember?.effort === 'low' ||
configuredMember?.effort === 'medium' ||
configuredMember?.effort === 'high'
? configuredMember.effort
: undefined;
const effort = isTeamEffortLevel(metaMember?.effort)
? metaMember.effort
: isTeamEffortLevel(configuredMember?.effort)
? configuredMember.effort
: undefined;
const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
@ -13035,12 +13344,9 @@ export class TeamProvisioningService {
const effectiveLeadProviderId =
normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic';
const effectiveLeadModel = launchState.model?.trim() || undefined;
const effectiveLeadEffort =
launchState.effort === 'low' ||
launchState.effort === 'medium' ||
launchState.effort === 'high'
? launchState.effort
: undefined;
const effectiveLeadEffort = isTeamEffortLevel(launchState.effort)
? launchState.effort
: undefined;
const membersByName = new Map(
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
@ -13075,10 +13381,7 @@ export class TeamProvisioningService {
delete nextMember.model;
}
const effort =
state.effort === 'low' || state.effort === 'medium' || state.effort === 'high'
? state.effort
: undefined;
const effort = isTeamEffortLevel(state.effort) ? state.effort : undefined;
if (effort) {
nextMember.effort = effort;
} else {
@ -13712,10 +14015,7 @@ export class TeamProvisioningService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: 'general-purpose' as const,
joinedAt,
}))
@ -13758,10 +14058,7 @@ export class TeamProvisioningService {
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model =
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort =
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
const prev = byName.get(name);
if (!prev) {
byName.set(name, { name, role, workflow, providerId, model, effort });
@ -13923,10 +14220,7 @@ export class TeamProvisioningService {
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
});
}
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.

View file

@ -2,54 +2,136 @@ import React from 'react';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Brain } from 'lucide-react';
const EFFORT_OPTIONS = [
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
const BASE_EFFORT_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
] as const;
const EFFORT_LABELS: Record<EffortLevel, string> = {
none: 'None',
minimal: 'Minimal',
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'XHigh',
};
const BASE_CODEX_SAFE_EFFORTS = new Set<EffortLevel>(['low', 'medium', 'high']);
export interface EffortLevelSelectorProps {
value: string;
onValueChange: (value: string) => void;
id?: string;
providerId?: TeamProviderId;
model?: string;
}
function getCatalogModel(
providerStatus: CliProviderStatus | null | undefined,
model: string | undefined
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number] | null {
const catalog = providerStatus?.modelCatalog;
if (!catalog || catalog.providerId !== 'codex') {
return null;
}
const explicitModel = model?.trim();
if (explicitModel) {
return (
catalog.models.find(
(item) => item.launchModel === explicitModel || item.id === explicitModel
) ?? null
);
}
return (
catalog.models.find((item) => item.id === catalog.defaultModelId) ??
catalog.models.find((item) => item.isDefault) ??
null
);
}
function getEffortOptions(params: {
providerId?: TeamProviderId;
model?: string;
providerStatus?: CliProviderStatus | null;
}): readonly { value: string; label: string }[] {
if (params.providerId !== 'codex') {
return BASE_EFFORT_OPTIONS;
}
const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort;
const catalogModel = getCatalogModel(params.providerStatus, params.model);
const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? [];
const candidateEfforts =
catalogEfforts.length > 0 ? catalogEfforts : (runtimeCapability?.values ?? []);
const safeEfforts =
runtimeCapability?.configPassthrough === true
? candidateEfforts
: candidateEfforts.filter((effort) => BASE_CODEX_SAFE_EFFORTS.has(effort));
const efforts = safeEfforts.length > 0 ? safeEfforts : (['low', 'medium', 'high'] as const);
const defaultLabel = catalogModel?.defaultReasoningEffort
? `Default (${EFFORT_LABELS[catalogModel.defaultReasoningEffort]})`
: 'Default';
return [
{ value: '', label: defaultLabel },
...efforts.map((effort) => ({
value: effort,
label: EFFORT_LABELS[effort],
})),
];
}
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
value,
onValueChange,
id,
}) => (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Effort level (optional)
</Label>
<div className="flex items-center gap-2">
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{EFFORT_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
providerId,
model,
}) => {
const providerStatus = useStore(
(s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null
);
const effortOptions = getEffortOptions({ providerId, model, providerStatus });
return (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Effort level (optional)
</Label>
<div className="flex items-center gap-2">
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="inline-flex flex-wrap rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{effortOptions.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning the selected provider invests before responding. Default uses
the provider&apos;s standard behavior for the selected model.
</p>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning the selected provider invests before responding. Default uses the
provider&apos;s standard behavior for the selected model.
</p>
</div>
);
);
};

View file

@ -2011,6 +2011,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
providerId={selectedProviderId}
model={selectedModel}
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"

View file

@ -68,6 +68,7 @@ export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini
export function getTeamEffortLabel(effort: string): string {
const trimmed = effort.trim();
if (!trimmed) return 'Default';
if (trimmed === 'xhigh') return 'XHigh';
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
}

View file

@ -1,7 +1,8 @@
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
import type { EffortLevel, ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
function normalizeRestartSensitiveMemberContract(member: {
role?: string;
@ -14,16 +15,13 @@ function normalizeRestartSensitiveMemberContract(member: {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: EffortLevel;
} {
const role = member.role?.trim() || undefined;
const workflow = member.workflow?.trim() || undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = member.model?.trim() || undefined;
const effort =
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
return { role, workflow, providerId, model, effort };
}
@ -128,7 +126,7 @@ function normalizeEditableMemberSnapshot(member: {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: EffortLevel;
} | null {
if (member.removedAt) {
return null;

View file

@ -155,6 +155,8 @@ export const LeadModelRow = ({
value={effort ?? ''}
onValueChange={onEffortChange}
id="lead-effort"
providerId={providerId}
model={model}
/>
{providerId === 'anthropic' ? (
<LimitContextCheckbox

View file

@ -446,6 +446,8 @@ export const MemberDraftRow = ({
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
providerId={effectiveProviderId}
model={effectiveModel}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">

View file

@ -5,6 +5,7 @@ import { Label } from '@renderer/components/ui/label';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -50,10 +51,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
const providerId = normalizeOptionalTeamProviderId(item.providerId);
const model = typeof item.model === 'string' ? item.model.trim() : '';
const effort: EffortLevel | undefined =
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
? item.effort
: undefined;
const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
? item.effort
: undefined;
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
@ -227,8 +227,7 @@ export const MembersEditorSection = ({
c.id === memberId
? {
...c,
effort:
effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
effort: isTeamEffortLevel(effort) ? effort : undefined,
}
: c
)

View file

@ -2,6 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
@ -120,10 +121,7 @@ export function normalizeMemberDraftForProviderMode(
}
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
if (value === 'low' || value === 'medium' || value === 'high') {
return value;
}
return undefined;
return isTeamEffortLevel(value) ? value : undefined;
}
interface ExistingMemberColorInput {

View file

@ -11,6 +11,10 @@
import { del, get, set } from 'idb-keyval';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import type { EffortLevel } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -27,7 +31,7 @@ export interface SerializedMemberDraft {
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: EffortLevel;
}
export interface CreateTeamDraftSnapshot {
@ -67,10 +71,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
obj.providerId === 'codex' ||
obj.providerId === 'gemini') &&
(obj.model === undefined || typeof obj.model === 'string') &&
(obj.effort === undefined ||
obj.effort === 'low' ||
obj.effort === 'medium' ||
obj.effort === 'high')
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
);
}

View file

@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import {
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
getAvailableTeamProviderModelOptions,
getAvailableTeamProviderModels,
getTeamModelSelectionError,
} from '../teamModelAvailability';
import type { CliProviderStatus } from '@shared/types';
function createCodexProviderStatus(
models: NonNullable<CliProviderStatus['modelCatalog']>['models'],
options: { dynamicLaunch?: boolean } = {}
): CliProviderStatus {
return {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
verificationState: 'verified',
models: models.map((model) => model.launchModel),
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:01:00.000Z',
defaultModelId: models[0]?.id ?? null,
defaultLaunchModel: models[0]?.launchModel ?? null,
models,
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
modelAvailability: [],
runtimeCapabilities: {
modelCatalog: {
dynamic: options.dynamicLaunch === true,
source: 'app-server',
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'unsupported', ownership: 'shared', reason: null },
mcp: { status: 'supported', ownership: 'shared', reason: null },
skills: { status: 'supported', ownership: 'shared', reason: null },
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
};
}
describe('team model availability Codex catalog integration', () => {
it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => {
const providerStatus = createCodexProviderStatus(
[
{
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: true,
upgrade: false,
source: 'app-server',
badgeLabel: '5.5',
},
],
{ dynamicLaunch: true }
);
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']);
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
{
value: 'gpt-5.5',
label: '5.5',
badgeLabel: '5.5',
availabilityStatus: 'available',
availabilityReason: null,
},
]);
});
it('shows app-server future models but blocks launch until runtime declares dynamic support', () => {
const providerStatus = createCodexProviderStatus([
{
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: true,
upgrade: false,
source: 'app-server',
},
]);
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]);
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
value: 'gpt-5.5',
label: '5.5',
badgeLabel: 'New',
availabilityStatus: null,
});
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
);
});
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
const providerStatus = createCodexProviderStatus([
{
id: 'gpt-5.3-codex-spark',
launchModel: 'gpt-5.3-codex-spark',
displayName: 'GPT-5.3 Codex Spark',
hidden: false,
supportedReasoningEfforts: ['high'],
defaultReasoningEffort: 'high',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
},
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
},
]);
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']);
});
});

View file

@ -4,6 +4,7 @@ import {
getTeamProviderLabel,
getTeamProviderModelOptions,
getVisibleTeamProviderModels,
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
@ -28,6 +29,7 @@ import type {
} from '@shared/types';
export {
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
@ -44,8 +46,10 @@ export type TeamModelRuntimeProviderStatus = Pick<
CliProviderStatus,
| 'providerId'
| 'models'
| 'modelCatalog'
| 'modelAvailability'
| 'modelVerificationState'
| 'runtimeCapabilities'
| 'authMethod'
| 'backend'
| 'authenticated'
@ -100,6 +104,56 @@ function getFallbackTeamProviderModelOptions(
}));
}
function getRuntimeCatalogModels(
providerId: SupportedProviderId,
providerStatus?: TeamModelRuntimeProviderStatus | null
): string[] | null {
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
return null;
}
const models = providerStatus.modelCatalog.models
.filter((model) => !model.hidden)
.map((model) => model.launchModel.trim())
.filter(Boolean);
return models.length > 0 ? models : null;
}
function getRuntimeCatalogModelOption(
providerId: SupportedProviderId,
model: string,
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamRuntimeModelOption | null {
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
return null;
}
const catalogModel = providerStatus.modelCatalog.models.find(
(item) => item.launchModel === model || item.id === model
);
if (!catalogModel) {
return null;
}
return {
value: catalogModel.launchModel,
label:
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
catalogModel.displayName,
badgeLabel:
catalogModel.badgeLabel ??
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
? undefined
: 'New'),
availabilityStatus: getRuntimeModelAvailability(
providerId,
catalogModel.launchModel,
providerStatus
),
availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus),
};
}
function getRuntimeSelectorModels(
providerId: SupportedProviderId,
providerStatus?: TeamModelRuntimeProviderStatus | null
@ -108,6 +162,11 @@ function getRuntimeSelectorModels(
return [];
}
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
if (catalogModels) {
return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus);
}
return sortTeamProviderModels(providerId, providerStatus.models);
}
@ -208,12 +267,18 @@ export function getAvailableTeamProviderModelOptions(
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
return [
{ value: '', label: 'Default', badgeLabel: 'Default' },
...visibleModels.map((model) => ({
value: model,
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
})),
...visibleModels.map((model) => {
const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus);
if (catalogOption) {
return catalogOption;
}
return {
value: model,
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
};
}),
];
}

View file

@ -15,7 +15,10 @@ export {
} from '@shared/utils/providerModelVisibility';
type SupportedProviderId = CliProviderId | TeamProviderId;
type RuntimeAwareProviderStatus = Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>;
type RuntimeAwareProviderStatus = Pick<
CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
>;
export interface TeamProviderModelOption {
value: string;
@ -33,6 +36,8 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON =
'Available in Codex, waiting for Agent Teams runtime support.';
const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
anthropic: 'Anthropic',
@ -152,6 +157,13 @@ function getKnownTeamProviderModelOption(
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
}
function isKnownTeamProviderModel(
providerId: SupportedProviderId | undefined,
model: string | undefined
): boolean {
return Boolean(getKnownTeamProviderModelOption(providerId, model));
}
export function getTeamProviderModelOptions(
providerId: SupportedProviderId
): readonly TeamProviderModelOption[] {
@ -389,6 +401,18 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
return null;
}
if (
providerId === 'codex' &&
providerStatus?.modelCatalog?.providerId === 'codex' &&
providerStatus.modelCatalog.models.some(
(item) => item.launchModel === trimmed || item.id === trimmed
) &&
!isKnownTeamProviderModel(providerId, trimmed) &&
providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true
) {
return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON;
}
return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
: null;

View file

@ -117,6 +117,57 @@ export interface CliProviderModelAvailability {
checkedAt?: string | null;
}
export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CliProviderModelCatalogSource = 'app-server' | 'static-fallback';
export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';
export interface CliProviderModelCatalogItem {
id: string;
launchModel: string;
displayName: string;
hidden: boolean;
supportedReasoningEfforts: CliProviderReasoningEffort[];
defaultReasoningEffort: CliProviderReasoningEffort | null;
inputModalities: string[];
supportsPersonality: boolean;
isDefault: boolean;
upgrade: boolean;
source: CliProviderModelCatalogSource;
badgeLabel?: string | null;
statusMessage?: string | null;
}
export interface CliProviderModelCatalog {
schemaVersion: 1;
providerId: CliProviderId;
source: CliProviderModelCatalogSource;
status: CliProviderModelCatalogStatus;
fetchedAt: string;
staleAt: string;
defaultModelId: string | null;
defaultLaunchModel: string | null;
models: CliProviderModelCatalogItem[];
diagnostics: {
configReadState: 'ready' | 'unsupported' | 'failed' | 'skipped';
appServerState: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible';
message?: string | null;
code?: string | null;
};
}
export interface CliProviderRuntimeCapabilities {
modelCatalog?: {
dynamic: boolean;
source?: CliProviderModelCatalogSource | 'runtime';
};
reasoningEffort?: {
supported: boolean;
values: CliProviderReasoningEffort[];
configPassthrough?: boolean;
};
}
export interface CliProviderStatus {
providerId: CliProviderId;
displayName: string;
@ -127,7 +178,9 @@ export interface CliProviderStatus {
modelVerificationState?: 'idle' | 'verifying' | 'verified';
statusMessage?: string | null;
models: string[];
modelCatalog?: CliProviderModelCatalog | null;
modelAvailability?: CliProviderModelAvailability[];
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
canLoginFromUi: boolean;
capabilities: {
teamLaunch: boolean;

View file

@ -782,10 +782,23 @@ export interface TeamViewSnapshot {
isAlive?: boolean;
}
export type EffortLevel = 'low' | 'medium' | 'high';
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native';
export interface ProviderModelLaunchIdentity {
providerId: TeamProviderId;
providerBackendId: TeamProviderBackendId | null;
selectedModel: string | null;
selectedModelKind: 'default' | 'explicit';
resolvedLaunchModel: string | null;
catalogId: string | null;
catalogSource: 'app-server' | 'static-fallback' | 'runtime' | 'unavailable';
catalogFetchedAt: string | null;
selectedEffort: EffortLevel | null;
resolvedEffort: EffortLevel | null;
}
export interface TeamLaunchRequest {
teamName: string;
cwd: string;

View file

@ -0,0 +1,57 @@
import type { EffortLevel, TeamProviderId } from '@shared/types/team';
export const TEAM_EFFORT_LEVELS = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
] as const satisfies readonly EffortLevel[];
export const LEGACY_TEAM_EFFORT_LEVELS = [
'low',
'medium',
'high',
] as const satisfies readonly EffortLevel[];
export const CODEX_TEAM_EFFORT_LEVELS = [
'minimal',
'low',
'medium',
'high',
'xhigh',
] as const satisfies readonly EffortLevel[];
const LEGACY_TEAM_EFFORT_LEVEL_SET = new Set<EffortLevel>(LEGACY_TEAM_EFFORT_LEVELS);
const CODEX_TEAM_EFFORT_LEVEL_SET = new Set<EffortLevel>(CODEX_TEAM_EFFORT_LEVELS);
export function isTeamEffortLevel(value: unknown): value is EffortLevel {
return typeof value === 'string' && TEAM_EFFORT_LEVELS.includes(value as EffortLevel);
}
export function formatEffortLevelList(): string {
return TEAM_EFFORT_LEVELS.join(', ');
}
export function isTeamEffortLevelForProvider(
value: unknown,
providerId?: TeamProviderId | null
): value is EffortLevel {
if (!isTeamEffortLevel(value)) {
return false;
}
if (providerId === 'codex') {
return CODEX_TEAM_EFFORT_LEVEL_SET.has(value);
}
return LEGACY_TEAM_EFFORT_LEVEL_SET.has(value);
}
export function formatEffortLevelListForProvider(providerId?: TeamProviderId | null): string {
if (providerId === 'codex') {
return CODEX_TEAM_EFFORT_LEVELS.join(', ');
}
return LEGACY_TEAM_EFFORT_LEVELS.join(', ');
}

View file

@ -46,6 +46,46 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
}));
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model') {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
codex: {
defaultModel: 'gpt-5.4',
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
},
gemini: {
defaultModel: 'gemini-2.5-pro',
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }],
},
},
}),
stderr: '',
};
}
if (args[0] === 'runtime') {
return {
stdout: JSON.stringify({
providers: {
codex: {
runtimeCapabilities: {
modelCatalog: { dynamic: false, source: 'runtime' },
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
},
}),
stderr: '',
};
}
return { stdout: '', stderr: '' };
}),
spawnCli: vi.fn(),
killProcessTree: vi.fn(),
}));

View file

@ -24,6 +24,46 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
}));
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model') {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
codex: {
defaultModel: 'gpt-5.4',
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
},
gemini: {
defaultModel: 'gemini-2.5-pro',
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }],
},
},
}),
stderr: '',
};
}
if (args[0] === 'runtime') {
return {
stdout: JSON.stringify({
providers: {
codex: {
runtimeCapabilities: {
modelCatalog: { dynamic: false, source: 'runtime' },
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
},
}),
stderr: '',
};
}
return { stdout: '', stderr: '' };
}),
spawnCli: vi.fn(),
killProcessTree: vi.fn(),
}));
@ -45,7 +85,7 @@ import {
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
import { execCli, spawnCli } from '@main/utils/childProcess';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
function createFakeChild() {
@ -314,6 +354,72 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
it('blocks Codex xhigh launch effort until runtime exposes reasoning config passthrough', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReset();
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: {},
authSource: 'codex_runtime',
providerArgs: [],
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
await expect(
svc.createTeam(
{
teamName: 'codex-xhigh-blocked',
cwd: process.cwd(),
members: [],
providerId: 'codex',
effort: 'xhigh',
},
() => {}
)
).rejects.toThrow('does not expose Codex reasoning config passthrough yet');
expect(spawnCli).not.toHaveBeenCalled();
});
it('blocks future Codex catalog models until runtime declares dynamic launch support', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReset();
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: {},
authSource: 'codex_runtime',
providerArgs: [],
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
await expect(
svc.createTeam(
{
teamName: 'codex-future-model-blocked',
cwd: process.cwd(),
members: [],
providerId: 'codex',
model: 'gpt-5.5',
effort: 'medium',
},
() => {}
)
).rejects.toThrow('does not declare dynamic Codex model launch support yet');
expect(execCli).toHaveBeenCalledWith(
'/fake/codex',
['runtime', 'status', '--json', '--provider', 'codex'],
expect.objectContaining({ cwd: process.cwd() })
);
expect(spawnCli).not.toHaveBeenCalled();
});
it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => {
const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice',