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

View file

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

View file

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

View file

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

View file

@ -2,11 +2,15 @@ import { constants as fsConstants } from 'node:fs';
import * as fsp from 'node:fs/promises'; import * as fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { execCli } from '@main/utils/childProcess';
const CACHE_VERIFY_TTL_MS = 30_000; const CACHE_VERIFY_TTL_MS = 30_000;
const VERSION_CACHE_TTL_MS = 30_000;
let cachedBinaryPath: string | null | undefined; let cachedBinaryPath: string | null | undefined;
let cacheVerifiedAt = 0; let cacheVerifiedAt = 0;
let resolveInFlight: Promise<string | null> | null = null; let resolveInFlight: Promise<string | null> | null = null;
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
async function fileExists(filePath: string): Promise<boolean> { async function fileExists(filePath: string): Promise<boolean> {
try { try {
@ -69,6 +73,7 @@ export class CodexBinaryResolver {
cachedBinaryPath = undefined; cachedBinaryPath = undefined;
cacheVerifiedAt = 0; cacheVerifiedAt = 0;
resolveInFlight = null; resolveInFlight = null;
versionCache.clear();
} }
static async resolve(): Promise<string | null> { static async resolve(): Promise<string | null> {
@ -117,4 +122,34 @@ export class CodexBinaryResolver {
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
return null; 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 { interface JsonRpcErrorPayload {
code?: number; code?: number;
message?: string; message?: string;
data?: unknown;
} }
interface JsonRpcResponse<T> { 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_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_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 { export class JsonRpcStdioClient {
constructor(private readonly logger: JsonRpcLogger) {} constructor(private readonly logger: JsonRpcLogger) {}
@ -93,6 +110,7 @@ export class JsonRpcStdioClient {
const pending = new Map< const pending = new Map<
number, number,
{ {
method: string;
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>; timeoutId: ReturnType<typeof setTimeout>;
@ -149,7 +167,7 @@ export class JsonRpcStdioClient {
pending.delete(message.id); pending.delete(message.id);
if (message.error) { if (message.error) {
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); entry.reject(new JsonRpcRequestError(entry.method, message.error));
return; return;
} }
@ -222,17 +240,25 @@ export class JsonRpcStdioClient {
reject(new Error(`JSON-RPC request timed out: ${method}`)); reject(new Error(`JSON-RPC request timed out: ${method}`));
}, timeoutMs); }, timeoutMs);
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId }); pending.set(id, {
method,
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { resolve: resolve as (value: unknown) => void,
if (!error) { reject,
return; timeoutId,
}
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}); });
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> => { notify: async (method: string, params?: unknown): Promise<void> => {
@ -241,7 +267,7 @@ export class JsonRpcStdioClient {
} }
await new Promise<void>((resolve, reject) => { 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) { if (error) {
reject(error instanceof Error ? error : new Error(String(error))); reject(error instanceof Error ? error : new Error(String(error)));
return; 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'; } from './CodexAppServerSessionFactory';
export { CodexBinaryResolver } from './CodexBinaryResolver'; export { CodexBinaryResolver } from './CodexBinaryResolver';
export type { JsonRpcSession } from './JsonRpcStdioClient'; export type { JsonRpcSession } from './JsonRpcStdioClient';
export { JsonRpcStdioClient } from './JsonRpcStdioClient'; export { JsonRpcRequestError, JsonRpcStdioClient } from './JsonRpcStdioClient';
export type { export type {
CodexAppServerAccount, CodexAppServerAccount,
CodexAppServerAccountLoginCompletedNotification, CodexAppServerAccountLoginCompletedNotification,
@ -20,10 +20,17 @@ export type {
CodexAppServerGetAccountRateLimitsResponse, CodexAppServerGetAccountRateLimitsResponse,
CodexAppServerGetAccountResponse, CodexAppServerGetAccountResponse,
CodexAppServerInitializeResponse, CodexAppServerInitializeResponse,
CodexAppServerListModelsParams,
CodexAppServerListModelsResponse,
CodexAppServerLoginAccountParams, CodexAppServerLoginAccountParams,
CodexAppServerLoginAccountResponse, CodexAppServerLoginAccountResponse,
CodexAppServerLogoutAccountResponse, CodexAppServerLogoutAccountResponse,
CodexAppServerModel,
CodexAppServerPlanType, CodexAppServerPlanType,
CodexAppServerRateLimitSnapshot, CodexAppServerRateLimitSnapshot,
CodexAppServerRateLimitWindow, CodexAppServerRateLimitWindow,
CodexAppServerReadConfigParams,
CodexAppServerReadConfigResponse,
CodexAppServerReasoningEffort,
CodexAppServerReasoningEffortOption,
} from './protocol'; } from './protocol';

View file

@ -111,3 +111,53 @@ export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound';
export interface CodexAppServerCancelLoginAccountResponse { export interface CodexAppServerCancelLoginAccountResponse {
status: CodexAppServerCancelLoginAccountStatus; 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; apiKeys?: RuntimeExtensionCapabilityResponse;
} }
interface RuntimeProviderCapabilitiesResponse {
modelCatalog?: {
dynamic?: boolean;
source?: 'app-server' | 'static-fallback' | 'runtime';
};
reasoningEffort?: {
supported?: boolean;
values?: string[];
configPassthrough?: boolean;
};
}
interface ProviderStatusCommandResponse { interface ProviderStatusCommandResponse {
schemaVersion?: number; schemaVersion?: number;
providers?: Record< providers?: Record<
@ -53,6 +65,7 @@ interface ProviderStatusCommandResponse {
projectId?: string | null; projectId?: string | null;
authMethodDetail?: string | null; authMethodDetail?: string | null;
} | null; } | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
} }
>; >;
} }
@ -119,6 +132,7 @@ interface UnifiedRuntimeStatusResponse {
projectId?: string | null; projectId?: string | null;
authMethodDetail?: string | null; authMethodDetail?: string | null;
} | null; } | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
} }
>; >;
} }
@ -164,6 +178,8 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
externalRuntimeDiagnostics: [], externalRuntimeDiagnostics: [],
backend: null, backend: null,
connection: null, connection: null,
modelCatalog: null,
runtimeCapabilities: null,
}; };
} }
@ -301,6 +317,34 @@ export class ClaudeMultimodelBridgeService {
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null, authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
} }
: 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, CodexAccountSnapshotDto,
} from '@features/codex-account/contracts'; } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main';
import type { import type {
CliProviderAuthMode, CliProviderAuthMode,
CliProviderConnectionInfo, CliProviderConnectionInfo,
CliProviderId, CliProviderId,
CliProviderReasoningEffort,
CliProviderStatus, CliProviderStatus,
} from '@shared/types'; } from '@shared/types';
@ -77,6 +79,8 @@ function buildCodexForcedLoginLaunchArgs(
export class ProviderConnectionService { export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null; private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null; private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
null;
constructor( constructor(
private readonly apiKeyService = new ApiKeyService(), private readonly apiKeyService = new ApiKeyService(),
@ -92,6 +96,12 @@ export class ProviderConnectionService {
this.codexAccountFeature = feature; this.codexAccountFeature = feature;
} }
setCodexModelCatalogFeature(
feature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null
): void {
this.codexModelCatalogFeature = feature;
}
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null { getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
if (providerId === 'anthropic') { if (providerId === 'anthropic') {
return this.configManager.getConfig().providerConnections.anthropic.authMode; return this.configManager.getConfig().providerConnections.anthropic.authMode;
@ -353,10 +363,53 @@ export class ProviderConnectionService {
} }
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> { async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
return { const withConnection = {
...provider, ...provider,
connection: await this.getConnectionInfo(provider.providerId), 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[]> { async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import * as fs from 'fs'; 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, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.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 { atomicWriteAsync } from './atomicWrite';
import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types';
/** /**
* Persisted team-level metadata saved by the UI before CLI provisioning. * Persisted team-level metadata saved by the UI before CLI provisioning.
* CLI does not know about this file it only reads/writes config.json. * CLI does not know about this file it only reads/writes config.json.
@ -27,6 +29,7 @@ export interface TeamMetaFile {
worktree?: string; worktree?: string;
extraCliArgs?: string; extraCliArgs?: string;
limitContext?: boolean; limitContext?: boolean;
launchIdentity?: ProviderModelLaunchIdentity;
createdAt: number; createdAt: number;
} }
@ -40,6 +43,70 @@ function normalizeOptionalBackendId(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : 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 { export class TeamMetaStore {
private getMetaPath(teamName: string): string { private getMetaPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'team.meta.json'); return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
@ -110,6 +177,7 @@ export class TeamMetaStore {
extraCliArgs: extraCliArgs:
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined, typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined, limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
launchIdentity: normalizeLaunchIdentity(file.launchIdentity),
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(), createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
}; };
} }
@ -133,6 +201,7 @@ export class TeamMetaStore {
worktree: data.worktree?.trim() || undefined, worktree: data.worktree?.trim() || undefined,
extraCliArgs: data.extraCliArgs?.trim() || undefined, extraCliArgs: data.extraCliArgs?.trim() || undefined,
limitContext: data.limitContext, limitContext: data.limitContext,
launchIdentity: normalizeLaunchIdentity(data.launchIdentity),
createdAt: data.createdAt, createdAt: data.createdAt,
}; };
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2)); 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 { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { import {
isInboxNoiseMessage, isInboxNoiseMessage,
@ -169,6 +170,7 @@ interface RelayInboxMessageView {
import type { import type {
ActiveToolCall, ActiveToolCall,
CliProviderRuntimeCapabilities,
CrossTeamSendResult, CrossTeamSendResult,
EffortLevel, EffortLevel,
InboxMessage, InboxMessage,
@ -179,6 +181,7 @@ import type {
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
PersistedTeamLaunchPhase, PersistedTeamLaunchPhase,
PersistedTeamLaunchSummary, PersistedTeamLaunchSummary,
ProviderModelLaunchIdentity,
TeamAgentRuntimeBackendType, TeamAgentRuntimeBackendType,
TeamAgentRuntimeEntry, TeamAgentRuntimeEntry,
TeamAgentRuntimeSnapshot, 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 { function extractJsonObjectFromCli<T>(raw: string): T {
const trimmed = raw.trim(); const trimmed = raw.trim();
try { 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 { function isProbeTimeoutMessage(message: string): boolean {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
return ( return (
@ -476,6 +553,7 @@ function logRuntimeLaunchSnapshot(
geminiRuntimeAuth?: GeminiRuntimeAuthState | null; geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
promptSize?: PromptSizeSummary | null; promptSize?: PromptSizeSummary | null;
expectedMembersCount?: number; expectedMembersCount?: number;
launchIdentity?: ProviderModelLaunchIdentity | null;
} }
): void { ): void {
const providerId = resolveTeamProviderId(request.providerId); const providerId = resolveTeamProviderId(request.providerId);
@ -489,6 +567,7 @@ function logRuntimeLaunchSnapshot(
getConfiguredRuntimeBackend(providerId), getConfiguredRuntimeBackend(providerId),
promptSize: options?.promptSize ?? null, promptSize: options?.promptSize ?? null,
expectedMembersCount: options?.expectedMembersCount ?? null, expectedMembersCount: options?.expectedMembersCount ?? null,
launchIdentity: options?.launchIdentity ?? null,
geminiRuntimeAuth: geminiRuntimeAuth:
providerId === 'gemini' providerId === 'gemini'
? { ? {
@ -1257,17 +1336,22 @@ function buildEffectiveTeamMemberSpec(
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
const model = const model =
member.model?.trim() || getExplicitLaunchModelSelection(member.model) ||
(memberProviderId == null || memberProviderId === defaultProviderId (memberProviderId == null || memberProviderId === defaultProviderId
? defaults.model?.trim() ? getExplicitLaunchModelSelection(defaults.model)
: undefined) || : undefined) ||
undefined; undefined;
const effort =
member.effort ??
(memberProviderId == null || memberProviderId === defaultProviderId
? defaults.effort
: undefined);
return { return {
...member, ...member,
providerId: effectiveProviderId, providerId: effectiveProviderId,
model, model,
effort: member.effort ?? defaults.effort, effort,
}; };
} }
@ -2859,6 +2943,214 @@ export class TeamProvisioningService {
this.controlApiBaseUrlResolver = resolver; 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( async getClaudeLogs(
teamName: string, teamName: string,
query?: { offset?: number; limit?: number } query?: { offset?: number; limit?: number }
@ -6227,6 +6519,13 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv, primaryEnv: provisioningEnv,
limitContext: request.limitContext, limitContext: request.limitContext,
}); });
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
claudePath,
cwd: request.cwd,
env: shellEnv,
request,
effectiveMembers: effectiveMemberSpecs,
});
const runId = randomUUID(); const runId = randomUUID();
const startedAt = nowIso(); const startedAt = nowIso();
const run: ProvisioningRun = { const run: ProvisioningRun = {
@ -6363,6 +6662,11 @@ export class TeamProvisioningService {
run.bootstrapUserPromptPath = null; run.bootstrapUserPromptPath = null;
throw error; throw error;
} }
const launchModelArg = getLaunchModelArg(
resolveTeamProviderId(request.providerId),
request.model,
launchIdentity
);
const spawnArgs = [ const spawnArgs = [
'--input-format', '--input-format',
'stream-json', 'stream-json',
@ -6385,7 +6689,7 @@ export class TeamProvisioningService {
...(request.skipPermissions !== false ...(request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(request.model ? ['--model', request.model] : []), ...(launchModelArg ? ['--model', launchModelArg] : []),
...(request.effort ? ['--effort', request.effort] : []), ...(request.effort ? ['--effort', request.effort] : []),
...(request.worktree ? ['--worktree', request.worktree] : []), ...(request.worktree ? ['--worktree', request.worktree] : []),
...parseCliArgs(request.extraCliArgs), ...parseCliArgs(request.extraCliArgs),
@ -6400,6 +6704,7 @@ export class TeamProvisioningService {
geminiRuntimeAuth, geminiRuntimeAuth,
promptSize, promptSize,
expectedMembersCount: effectiveMemberSpecs.length, expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity,
}); });
try { try {
// Pre-save our meta files before spawn — CLI doesn't touch these. // Pre-save our meta files before spawn — CLI doesn't touch these.
@ -6422,6 +6727,7 @@ export class TeamProvisioningService {
worktree: request.worktree, worktree: request.worktree,
extraCliArgs: request.extraCliArgs, extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext, limitContext: request.limitContext,
launchIdentity,
createdAt: Date.now(), createdAt: Date.now(),
}); });
const membersToWrite = applyDistinctProvisioningMemberColors( const membersToWrite = applyDistinctProvisioningMemberColors(
@ -6431,10 +6737,7 @@ export class TeamProvisioningService {
workflow: m.workflow?.trim() || undefined, workflow: m.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(m.providerId), providerId: normalizeOptionalTeamProviderId(m.providerId),
model: m.model?.trim() || undefined, model: m.model?.trim() || undefined,
effort: effort: isTeamEffortLevel(m.effort) ? m.effort : undefined,
m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
? m.effort
: undefined,
agentType: 'general-purpose' as const, agentType: 'general-purpose' as const,
joinedAt: Date.now(), joinedAt: Date.now(),
})) }))
@ -6804,6 +7107,13 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv, primaryEnv: provisioningEnv,
limitContext: request.limitContext, 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 // Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = { const syntheticRequest: TeamCreateRequest = {
@ -7013,8 +7323,13 @@ export class TeamProvisioningService {
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity` `[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
); );
} }
if (request.model) { const launchModelArg = getLaunchModelArg(
launchArgs.push('--model', request.model); resolveTeamProviderId(request.providerId),
request.model,
launchIdentity
);
if (launchModelArg) {
launchArgs.push('--model', launchModelArg);
} }
if (request.effort) { if (request.effort) {
launchArgs.push('--effort', request.effort); launchArgs.push('--effort', request.effort);
@ -7033,6 +7348,7 @@ export class TeamProvisioningService {
geminiRuntimeAuth, geminiRuntimeAuth,
promptSize, promptSize,
expectedMembersCount: effectiveMemberSpecs.length, expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity,
}); });
// --resume is added above when a valid previous session JSONL exists. // --resume is added above when a valid previous session JSONL exists.
// Without it, CLI creates a fresh session ID automatically. // Without it, CLI creates a fresh session ID automatically.
@ -7050,6 +7366,7 @@ export class TeamProvisioningService {
worktree: request.worktree, worktree: request.worktree,
extraCliArgs: request.extraCliArgs, extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext, limitContext: request.limitContext,
launchIdentity,
createdAt: Date.now(), createdAt: Date.now(),
}); });
await this.membersMetaStore.writeMembers( await this.membersMetaStore.writeMembers(
@ -7060,10 +7377,7 @@ export class TeamProvisioningService {
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()), color: getMemberColorByName(member.name.trim()),
joinedAt: Date.now(), joinedAt: Date.now(),
@ -8514,16 +8828,11 @@ export class TeamProvisioningService {
normalizeTeamMemberProviderId(metaMember?.providerId) ?? normalizeTeamMemberProviderId(metaMember?.providerId) ??
normalizeTeamMemberProviderId(configuredMember?.providerId); normalizeTeamMemberProviderId(configuredMember?.providerId);
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined; const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
const effort = const effort = isTeamEffortLevel(metaMember?.effort)
metaMember?.effort === 'low' || ? metaMember.effort
metaMember?.effort === 'medium' || : isTeamEffortLevel(configuredMember?.effort)
metaMember?.effort === 'high' ? configuredMember.effort
? metaMember.effort : undefined;
: configuredMember?.effort === 'low' ||
configuredMember?.effort === 'medium' ||
configuredMember?.effort === 'high'
? configuredMember.effort
: undefined;
const agentType = const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
@ -13035,12 +13344,9 @@ export class TeamProvisioningService {
const effectiveLeadProviderId = const effectiveLeadProviderId =
normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic'; normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic';
const effectiveLeadModel = launchState.model?.trim() || undefined; const effectiveLeadModel = launchState.model?.trim() || undefined;
const effectiveLeadEffort = const effectiveLeadEffort = isTeamEffortLevel(launchState.effort)
launchState.effort === 'low' || ? launchState.effort
launchState.effort === 'medium' || : undefined;
launchState.effort === 'high'
? launchState.effort
: undefined;
const membersByName = new Map( const membersByName = new Map(
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const) (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
@ -13075,10 +13381,7 @@ export class TeamProvisioningService {
delete nextMember.model; delete nextMember.model;
} }
const effort = const effort = isTeamEffortLevel(state.effort) ? state.effort : undefined;
state.effort === 'low' || state.effort === 'medium' || state.effort === 'high'
? state.effort
: undefined;
if (effort) { if (effort) {
nextMember.effort = effort; nextMember.effort = effort;
} else { } else {
@ -13712,10 +14015,7 @@ export class TeamProvisioningService {
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose' as const, agentType: 'general-purpose' as const,
joinedAt, joinedAt,
})) }))
@ -13758,10 +14058,7 @@ export class TeamProvisioningService {
const providerId = normalizeOptionalTeamProviderId(member.providerId); const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = const model =
typeof member.model === 'string' ? member.model.trim() || undefined : undefined; typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort = const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
const prev = byName.get(name); const prev = byName.get(name);
if (!prev) { if (!prev) {
byName.set(name, { name, role, workflow, providerId, model, effort }); 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, typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
}); });
} }
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. // 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 { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Brain } from 'lucide-react'; import { Brain } from 'lucide-react';
const EFFORT_OPTIONS = [ import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
const BASE_EFFORT_OPTIONS = [
{ value: '', label: 'Default' }, { value: '', label: 'Default' },
{ value: 'low', label: 'Low' }, { value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }, { value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }, { value: 'high', label: 'High' },
] as const; ] 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 { export interface EffortLevelSelectorProps {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
id?: string; 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> = ({ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
value, value,
onValueChange, onValueChange,
id, id,
}) => ( providerId,
<div className="mb-3"> model,
<Label htmlFor={id} className="label-optional mb-1.5 block"> }) => {
Effort level (optional) const providerStatus = useStore(
</Label> (s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null
<div className="flex items-center gap-2"> );
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" /> const effortOptions = getEffortOptions({ providerId, model, providerStatus });
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{EFFORT_OPTIONS.map((opt) => ( return (
<button <div className="mb-3">
key={opt.value || '__default__'} <Label htmlFor={id} className="label-optional mb-1.5 block">
type="button" Effort level (optional)
id={opt.value === value ? id : undefined} </Label>
className={cn( <div className="flex items-center gap-2">
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', <Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
value === opt.value <div className="inline-flex flex-wrap rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' {effortOptions.map((opt) => (
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' <button
)} key={opt.value || '__default__'}
onClick={() => onValueChange(opt.value)} type="button"
> id={opt.value === value ? id : undefined}
{opt.label} className={cn(
</button> '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> </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> </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} value={selectedEffort}
onValueChange={setSelectedEffort} onValueChange={setSelectedEffort}
id="dialog-effort" id="dialog-effort"
providerId={selectedProviderId}
model={selectedModel}
/> />
<SkipPermissionsCheckbox <SkipPermissionsCheckbox
id="dialog-skip-permissions" id="dialog-skip-permissions"

View file

@ -68,6 +68,7 @@ export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini
export function getTeamEffortLabel(effort: string): string { export function getTeamEffortLabel(effort: string): string {
const trimmed = effort.trim(); const trimmed = effort.trim();
if (!trimmed) return 'Default'; if (!trimmed) return 'Default';
if (trimmed === 'xhigh') return 'XHigh';
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); 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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; 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: { function normalizeRestartSensitiveMemberContract(member: {
role?: string; role?: string;
@ -14,16 +15,13 @@ function normalizeRestartSensitiveMemberContract(member: {
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini'; providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
effort?: 'low' | 'medium' | 'high'; effort?: EffortLevel;
} { } {
const role = member.role?.trim() || undefined; const role = member.role?.trim() || undefined;
const workflow = member.workflow?.trim() || undefined; const workflow = member.workflow?.trim() || undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId); const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = member.model?.trim() || undefined; const model = member.model?.trim() || undefined;
const effort = const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
return { role, workflow, providerId, model, effort }; return { role, workflow, providerId, model, effort };
} }
@ -128,7 +126,7 @@ function normalizeEditableMemberSnapshot(member: {
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini'; providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
effort?: 'low' | 'medium' | 'high'; effort?: EffortLevel;
} | null { } | null {
if (member.removedAt) { if (member.removedAt) {
return null; return null;

View file

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

View file

@ -446,6 +446,8 @@ export const MemberDraftRow = ({
onEffortChange(member.id, value); onEffortChange(member.id, value);
}} }}
id={`member-${member.id}-effort`} id={`member-${member.id}-effort`}
providerId={effectiveProviderId}
model={effectiveModel}
/> />
{lockProviderModel && ( {lockProviderModel && (
<p className="text-[11px] text-amber-300"> <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 { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -50,10 +51,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : ''; const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
const providerId = normalizeOptionalTeamProviderId(item.providerId); const providerId = normalizeOptionalTeamProviderId(item.providerId);
const model = typeof item.model === 'string' ? item.model.trim() : ''; const model = typeof item.model === 'string' ? item.model.trim() : '';
const effort: EffortLevel | undefined = const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high' ? item.effort
? item.effort : undefined;
: undefined;
const presetRoles: readonly string[] = PRESET_ROLES; const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role); const isPreset = presetRoles.includes(role);
return createMemberDraft({ return createMemberDraft({
@ -227,8 +227,7 @@ export const MembersEditorSection = ({
c.id === memberId c.id === memberId
? { ? {
...c, ...c,
effort: effort: isTeamEffortLevel(effort) ? effort : undefined,
effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
} }
: c : 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 { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
@ -120,10 +121,7 @@ export function normalizeMemberDraftForProviderMode(
} }
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined { function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
if (value === 'low' || value === 'medium' || value === 'high') { return isTeamEffortLevel(value) ? value : undefined;
return value;
}
return undefined;
} }
interface ExistingMemberColorInput { interface ExistingMemberColorInput {

View file

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

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, getTeamProviderLabel,
getTeamProviderModelOptions, getTeamProviderModelOptions,
getVisibleTeamProviderModels, getVisibleTeamProviderModels,
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_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_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
@ -28,6 +29,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
export { export {
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_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_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
@ -44,8 +46,10 @@ export type TeamModelRuntimeProviderStatus = Pick<
CliProviderStatus, CliProviderStatus,
| 'providerId' | 'providerId'
| 'models' | 'models'
| 'modelCatalog'
| 'modelAvailability' | 'modelAvailability'
| 'modelVerificationState' | 'modelVerificationState'
| 'runtimeCapabilities'
| 'authMethod' | 'authMethod'
| 'backend' | 'backend'
| 'authenticated' | '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( function getRuntimeSelectorModels(
providerId: SupportedProviderId, providerId: SupportedProviderId,
providerStatus?: TeamModelRuntimeProviderStatus | null providerStatus?: TeamModelRuntimeProviderStatus | null
@ -108,6 +162,11 @@ function getRuntimeSelectorModels(
return []; return [];
} }
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
if (catalogModels) {
return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus);
}
return sortTeamProviderModels(providerId, providerStatus.models); return sortTeamProviderModels(providerId, providerStatus.models);
} }
@ -208,12 +267,18 @@ export function getAvailableTeamProviderModelOptions(
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
return [ return [
{ value: '', label: 'Default', badgeLabel: 'Default' }, { value: '', label: 'Default', badgeLabel: 'Default' },
...visibleModels.map((model) => ({ ...visibleModels.map((model) => {
value: model, const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus);
label: getProviderScopedTeamModelLabel(providerId, model) ?? model, if (catalogOption) {
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), return catalogOption;
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), }
})), 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'; } from '@shared/utils/providerModelVisibility';
type SupportedProviderId = CliProviderId | TeamProviderId; type SupportedProviderId = CliProviderId | TeamProviderId;
type RuntimeAwareProviderStatus = Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>; type RuntimeAwareProviderStatus = Pick<
CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
>;
export interface TeamProviderModelOption { export interface TeamProviderModelOption {
value: string; 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.'; '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 = 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.'; '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> = { const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
anthropic: 'Anthropic', anthropic: 'Anthropic',
@ -152,6 +157,13 @@ function getKnownTeamProviderModelOption(
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed); 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( export function getTeamProviderModelOptions(
providerId: SupportedProviderId providerId: SupportedProviderId
): readonly TeamProviderModelOption[] { ): readonly TeamProviderModelOption[] {
@ -389,6 +401,18 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
return null; 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) return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
: null; : null;

View file

@ -117,6 +117,57 @@ export interface CliProviderModelAvailability {
checkedAt?: string | null; 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 { export interface CliProviderStatus {
providerId: CliProviderId; providerId: CliProviderId;
displayName: string; displayName: string;
@ -127,7 +178,9 @@ export interface CliProviderStatus {
modelVerificationState?: 'idle' | 'verifying' | 'verified'; modelVerificationState?: 'idle' | 'verifying' | 'verified';
statusMessage?: string | null; statusMessage?: string | null;
models: string[]; models: string[];
modelCatalog?: CliProviderModelCatalog | null;
modelAvailability?: CliProviderModelAvailability[]; modelAvailability?: CliProviderModelAvailability[];
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
canLoginFromUi: boolean; canLoginFromUi: boolean;
capabilities: { capabilities: {
teamLaunch: boolean; teamLaunch: boolean;

View file

@ -782,10 +782,23 @@ export interface TeamViewSnapshot {
isAlive?: boolean; 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 TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; 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 { export interface TeamLaunchRequest {
teamName: string; teamName: string;
cwd: 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', () => ({ 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(), spawnCli: vi.fn(),
killProcessTree: vi.fn(), killProcessTree: vi.fn(),
})); }));

View file

@ -24,6 +24,46 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
})); }));
vi.mock('@main/utils/childProcess', () => ({ 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(), spawnCli: vi.fn(),
killProcessTree: vi.fn(), killProcessTree: vi.fn(),
})); }));
@ -45,7 +85,7 @@ import {
TeamProvisioningService, TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService'; } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; 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'; import { setAppDataBasePath } from '@main/utils/pathDecoder';
function createFakeChild() { function createFakeChild() {
@ -314,6 +354,72 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId); 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', () => { it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => {
const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice', name: 'alice',