feat(codex): add app-server model catalog
This commit is contained in:
parent
99102565f3
commit
7a337b6268
46 changed files with 4873 additions and 168 deletions
2372
docs/research/codex-app-server-model-catalog-plan.md
Normal file
2372
docs/research/codex-app-server-model-catalog-plan.md
Normal file
File diff suppressed because it is too large
Load diff
13
src/features/codex-model-catalog/contracts/dto.ts
Normal file
13
src/features/codex-model-catalog/contracts/dto.ts
Normal 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;
|
||||
7
src/features/codex-model-catalog/contracts/index.ts
Normal file
7
src/features/codex-model-catalog/contracts/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export type {
|
||||
CodexModelCatalogDto,
|
||||
CodexModelCatalogItemDto,
|
||||
CodexModelCatalogSourceDto,
|
||||
CodexModelCatalogStatusDto,
|
||||
CodexModelReasoningEffortDto,
|
||||
} from './dto';
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
9
src/features/codex-model-catalog/index.ts
Normal file
9
src/features/codex-model-catalog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type {
|
||||
CodexModelCatalogDto,
|
||||
CodexModelCatalogItemDto,
|
||||
CodexModelCatalogSourceDto,
|
||||
CodexModelCatalogStatusDto,
|
||||
CodexModelReasoningEffortDto,
|
||||
} from './contracts';
|
||||
export type { CodexModelCatalogFeatureFacade, CodexModelCatalogRequest } from './main';
|
||||
export { createCodexModelCatalogFeature } from './main';
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
5
src/features/codex-model-catalog/main/index.ts
Normal file
5
src/features/codex-model-catalog/main/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type {
|
||||
CodexModelCatalogFeatureFacade,
|
||||
CodexModelCatalogRequest,
|
||||
} from './composition/createCodexModelCatalogFeature';
|
||||
export { createCodexModelCatalogFeature } from './composition/createCodexModelCatalogFeature';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { validateTeamName } from '@main/ipc/guards';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import {
|
||||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
} from '@shared/utils/effortLevels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isAbsolute } from 'path';
|
||||
|
|
@ -12,8 +16,6 @@ const logger = createLogger('HTTP:teams');
|
|||
|
||||
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
|
||||
|
||||
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
|
||||
|
||||
class HttpBadRequestError extends Error {}
|
||||
class HttpFeatureUnavailableError extends Error {}
|
||||
|
||||
|
|
@ -76,16 +78,21 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und
|
|||
return value;
|
||||
}
|
||||
|
||||
function assertOptionalEffort(value: unknown): EffortLevel | undefined {
|
||||
function assertOptionalEffort(
|
||||
value: unknown,
|
||||
providerId: TeamLaunchRequest['providerId']
|
||||
): EffortLevel | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) {
|
||||
throw new HttpBadRequestError('effort must be one of: low, medium, high');
|
||||
if (!isTeamEffortLevelForProvider(value, providerId)) {
|
||||
throw new HttpBadRequestError(
|
||||
`effort must be one of: ${formatEffortLevelListForProvider(providerId)}`
|
||||
);
|
||||
}
|
||||
|
||||
return value as EffortLevel;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||
|
|
@ -109,7 +116,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
);
|
||||
}
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort);
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
const worktree = assertOptionalString(payload.worktree, 'worktree');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
registerCodexAccountIpc,
|
||||
removeCodexAccountIpc,
|
||||
} from '@features/codex-account/main';
|
||||
import {
|
||||
createCodexModelCatalogFeature,
|
||||
type CodexModelCatalogFeatureFacade,
|
||||
} from '@features/codex-model-catalog/main';
|
||||
import {
|
||||
createRecentProjectsFeature,
|
||||
type RecentProjectsFeatureFacade,
|
||||
|
|
@ -422,6 +426,7 @@ let notificationManager: NotificationManager;
|
|||
let updaterService: UpdaterService;
|
||||
let sshConnectionManager: SshConnectionManager;
|
||||
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
|
||||
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
|
||||
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
|
|
@ -988,6 +993,11 @@ async function initializeServices(): Promise<void> {
|
|||
configManager,
|
||||
});
|
||||
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
|
||||
codexModelCatalogFeature = createCodexModelCatalogFeature({
|
||||
logger: createLogger('Feature:CodexModelCatalog'),
|
||||
codexAccountFeature,
|
||||
});
|
||||
providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature);
|
||||
|
||||
// startProcessHealthPolling() is deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
|
@ -1185,7 +1195,10 @@ function shutdownServices(): void {
|
|||
}
|
||||
|
||||
void skillsWatcherService?.stopAll();
|
||||
providerConnectionService.setCodexModelCatalogFeature(null);
|
||||
providerConnectionService.setCodexAccountFeature(null);
|
||||
void codexModelCatalogFeature?.dispose();
|
||||
codexModelCatalogFeature = null;
|
||||
void codexAccountFeature?.dispose();
|
||||
codexAccountFeature = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ import {
|
|||
PROTECTED_CLI_FLAGS,
|
||||
} from '@shared/utils/cliArgsParser';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
} from '@shared/utils/effortLevels';
|
||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import {
|
||||
|
|
@ -1112,10 +1116,8 @@ function isProvisioningTeamName(teamName: string): boolean {
|
|||
return parts.every((p) => /^[a-z0-9]+$/.test(p));
|
||||
}
|
||||
|
||||
const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high'];
|
||||
|
||||
function isValidEffort(value: unknown): value is EffortLevel {
|
||||
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
|
||||
function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel {
|
||||
return isTeamEffortLevelForProvider(value, providerId);
|
||||
}
|
||||
|
||||
function parseOptionalMemberProviderId(
|
||||
|
|
@ -1165,15 +1167,35 @@ function parseOptionalProviderBackendId(
|
|||
}
|
||||
|
||||
function parseOptionalMemberEffort(
|
||||
value: unknown
|
||||
value: unknown,
|
||||
providerId?: TeamProviderId | null
|
||||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (isValidEffort(value)) {
|
||||
if (isValidEffort(value, providerId)) {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member effort must be low, medium, or high' };
|
||||
return {
|
||||
valid: false,
|
||||
error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalTeamEffort(
|
||||
value: unknown,
|
||||
providerId?: TeamProviderId | null
|
||||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (isValidEffort(value, providerId)) {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
|
|
@ -1202,6 +1224,12 @@ async function validateProvisioningRequest(
|
|||
if (!Array.isArray(payload.members)) {
|
||||
return { valid: false, error: 'members must be an array' };
|
||||
}
|
||||
const providerId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic';
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateRequest['members'] = [];
|
||||
|
|
@ -1237,12 +1265,20 @@ async function validateProvisioningRequest(
|
|||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { valid: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(member as { effort?: unknown }).effort,
|
||||
providerValidation.value ?? providerId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { valid: false, error: effortValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1257,12 +1293,6 @@ async function validateProvisioningRequest(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { valid: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic';
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerId
|
||||
|
|
@ -1270,6 +1300,10 @@ async function validateProvisioningRequest(
|
|||
if (!providerBackendValidation.valid) {
|
||||
return { valid: false, error: providerBackendValidation.error };
|
||||
}
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
|
||||
if (!effortValidation.valid) {
|
||||
return { valid: false, error: effortValidation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(cwd, { recursive: true });
|
||||
|
|
@ -1324,7 +1358,7 @@ async function validateProvisioningRequest(
|
|||
providerId,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
effort: effortValidation.value,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
|
|
@ -1474,6 +1508,10 @@ async function handleLaunchTeam(
|
|||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic';
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
||||
const createRequest: TeamCreateRequest = {
|
||||
teamName: tn,
|
||||
|
|
@ -1488,7 +1526,7 @@ async function handleLaunchTeam(
|
|||
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
|
||||
),
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
effort: effortValidation.value,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
|
|
@ -1520,6 +1558,11 @@ async function handleLaunchTeam(
|
|||
);
|
||||
}
|
||||
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('launch', () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
return getTeamProvisioningService().launchTeam(
|
||||
|
|
@ -1530,7 +1573,7 @@ async function handleLaunchTeam(
|
|||
providerId,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
effort: effortValidation.value,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
|
|
@ -2652,7 +2695,10 @@ async function handleCreateConfig(
|
|||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(member as { effort?: unknown }).effort,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
|
@ -3090,7 +3136,10 @@ async function handleAddMember(
|
|||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(payload as { effort?: unknown }).effort,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
|
@ -3162,7 +3211,7 @@ async function handleReplaceMembers(
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: EffortLevel;
|
||||
}[] = [];
|
||||
for (const item of payload.members) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
|
|
@ -3196,7 +3245,10 @@ async function handleReplaceMembers(
|
|||
if (m.model !== undefined && typeof m.model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(m as { effort?: unknown }).effort,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
|
||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||
runtimeCapabilities: provider.runtimeCapabilities
|
||||
? structuredClone(provider.runtimeCapabilities)
|
||||
: null,
|
||||
capabilities: {
|
||||
...provider.capabilities,
|
||||
extensions: {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import { constants as fsConstants } from 'node:fs';
|
|||
import * as fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
|
||||
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||
const VERSION_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedBinaryPath: string | null | undefined;
|
||||
let cacheVerifiedAt = 0;
|
||||
let resolveInFlight: Promise<string | null> | null = null;
|
||||
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -69,6 +73,7 @@ export class CodexBinaryResolver {
|
|||
cachedBinaryPath = undefined;
|
||||
cacheVerifiedAt = 0;
|
||||
resolveInFlight = null;
|
||||
versionCache.clear();
|
||||
}
|
||||
|
||||
static async resolve(): Promise<string | null> {
|
||||
|
|
@ -117,4 +122,34 @@ export class CodexBinaryResolver {
|
|||
cacheVerifiedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
|
||||
static async resolveVersion(binaryPath: string | null | undefined): Promise<string | null> {
|
||||
const normalizedPath = binaryPath?.trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = versionCache.get(normalizedPath);
|
||||
if (cached && Date.now() - cached.observedAt <= VERSION_CACHE_TTL_MS) {
|
||||
return cached.version;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execCli(normalizedPath, ['--version'], {
|
||||
timeout: 3_000,
|
||||
});
|
||||
const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null;
|
||||
versionCache.set(normalizedPath, {
|
||||
version,
|
||||
observedAt: Date.now(),
|
||||
});
|
||||
return version;
|
||||
} catch {
|
||||
versionCache.set(normalizedPath, {
|
||||
version: null,
|
||||
observedAt: Date.now(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface JsonRpcLogger {
|
|||
interface JsonRpcErrorPayload {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse<T> {
|
||||
|
|
@ -49,6 +50,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
|||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
|
||||
export class JsonRpcRequestError extends Error {
|
||||
readonly code: number | null;
|
||||
readonly data: unknown;
|
||||
readonly details: unknown;
|
||||
readonly method: string;
|
||||
|
||||
constructor(method: string, payload: JsonRpcErrorPayload) {
|
||||
super(payload.message ?? 'Unknown JSON-RPC error');
|
||||
this.name = 'JsonRpcRequestError';
|
||||
this.method = method;
|
||||
this.code = typeof payload.code === 'number' ? payload.code : null;
|
||||
this.data = payload.data;
|
||||
this.details = payload.data;
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonRpcStdioClient {
|
||||
constructor(private readonly logger: JsonRpcLogger) {}
|
||||
|
||||
|
|
@ -93,6 +110,7 @@ export class JsonRpcStdioClient {
|
|||
const pending = new Map<
|
||||
number,
|
||||
{
|
||||
method: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -149,7 +167,7 @@ export class JsonRpcStdioClient {
|
|||
pending.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
|
||||
entry.reject(new JsonRpcRequestError(entry.method, message.error));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -222,17 +240,25 @@ export class JsonRpcStdioClient {
|
|||
reject(new Error(`JSON-RPC request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId });
|
||||
|
||||
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
pending.delete(id);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
pending.set(id, {
|
||||
method,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
|
||||
(error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
pending.delete(id);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
);
|
||||
}),
|
||||
|
||||
notify: async (method: string, params?: unknown): Promise<void> => {
|
||||
|
|
@ -241,7 +267,7 @@ export class JsonRpcStdioClient {
|
|||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => {
|
||||
child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`, (error) => {
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ export {
|
|||
} from './CodexAppServerSessionFactory';
|
||||
export { CodexBinaryResolver } from './CodexBinaryResolver';
|
||||
export type { JsonRpcSession } from './JsonRpcStdioClient';
|
||||
export { JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||
export { JsonRpcRequestError, JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||
export type {
|
||||
CodexAppServerAccount,
|
||||
CodexAppServerAccountLoginCompletedNotification,
|
||||
|
|
@ -20,10 +20,17 @@ export type {
|
|||
CodexAppServerGetAccountRateLimitsResponse,
|
||||
CodexAppServerGetAccountResponse,
|
||||
CodexAppServerInitializeResponse,
|
||||
CodexAppServerListModelsParams,
|
||||
CodexAppServerListModelsResponse,
|
||||
CodexAppServerLoginAccountParams,
|
||||
CodexAppServerLoginAccountResponse,
|
||||
CodexAppServerLogoutAccountResponse,
|
||||
CodexAppServerModel,
|
||||
CodexAppServerPlanType,
|
||||
CodexAppServerRateLimitSnapshot,
|
||||
CodexAppServerRateLimitWindow,
|
||||
CodexAppServerReadConfigParams,
|
||||
CodexAppServerReadConfigResponse,
|
||||
CodexAppServerReasoningEffort,
|
||||
CodexAppServerReasoningEffortOption,
|
||||
} from './protocol';
|
||||
|
|
|
|||
|
|
@ -111,3 +111,53 @@ export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound';
|
|||
export interface CodexAppServerCancelLoginAccountResponse {
|
||||
status: CodexAppServerCancelLoginAccountStatus;
|
||||
}
|
||||
|
||||
export type CodexAppServerReasoningEffort =
|
||||
| 'none'
|
||||
| 'minimal'
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'xhigh';
|
||||
|
||||
export interface CodexAppServerReasoningEffortOption {
|
||||
reasoningEffort?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexAppServerModel {
|
||||
id?: string;
|
||||
model?: string;
|
||||
displayName?: string;
|
||||
hidden?: boolean;
|
||||
supportedReasoningEfforts?: (string | CodexAppServerReasoningEffortOption)[];
|
||||
defaultReasoningEffort?: string | null;
|
||||
inputModalities?: string[] | null;
|
||||
supportsPersonality?: boolean;
|
||||
isDefault?: boolean;
|
||||
upgrade?: boolean | string | null;
|
||||
upgradeInfo?: unknown;
|
||||
}
|
||||
|
||||
export interface CodexAppServerListModelsParams {
|
||||
cursor?: string | null;
|
||||
limit?: number | null;
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface CodexAppServerListModelsResponse {
|
||||
data?: CodexAppServerModel[];
|
||||
models?: CodexAppServerModel[];
|
||||
nextCursor?: string | null;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface CodexAppServerReadConfigParams {
|
||||
cwd?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
export interface CodexAppServerReadConfigResponse {
|
||||
config?: Record<string, unknown>;
|
||||
origins?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ interface RuntimeExtensionCapabilitiesResponse {
|
|||
apiKeys?: RuntimeExtensionCapabilityResponse;
|
||||
}
|
||||
|
||||
interface RuntimeProviderCapabilitiesResponse {
|
||||
modelCatalog?: {
|
||||
dynamic?: boolean;
|
||||
source?: 'app-server' | 'static-fallback' | 'runtime';
|
||||
};
|
||||
reasoningEffort?: {
|
||||
supported?: boolean;
|
||||
values?: string[];
|
||||
configPassthrough?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderStatusCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
|
|
@ -53,6 +65,7 @@ interface ProviderStatusCommandResponse {
|
|||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
|
@ -119,6 +132,7 @@ interface UnifiedRuntimeStatusResponse {
|
|||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
|
@ -164,6 +178,8 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +317,34 @@ export class ClaudeMultimodelBridgeService {
|
|||
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||
}
|
||||
: null,
|
||||
runtimeCapabilities: runtimeStatus.runtimeCapabilities
|
||||
? {
|
||||
modelCatalog: runtimeStatus.runtimeCapabilities.modelCatalog
|
||||
? {
|
||||
dynamic: runtimeStatus.runtimeCapabilities.modelCatalog.dynamic === true,
|
||||
source: runtimeStatus.runtimeCapabilities.modelCatalog.source,
|
||||
}
|
||||
: undefined,
|
||||
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
|
||||
? {
|
||||
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
|
||||
values:
|
||||
runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) =>
|
||||
value === 'none' ||
|
||||
value === 'minimal' ||
|
||||
value === 'low' ||
|
||||
value === 'medium' ||
|
||||
value === 'high' ||
|
||||
value === 'xhigh'
|
||||
? [value]
|
||||
: []
|
||||
) ?? [],
|
||||
configPassthrough:
|
||||
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ import type {
|
|||
CodexAccountSnapshotDto,
|
||||
} from '@features/codex-account/contracts';
|
||||
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
|
||||
import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main';
|
||||
import type {
|
||||
CliProviderAuthMode,
|
||||
CliProviderConnectionInfo,
|
||||
CliProviderId,
|
||||
CliProviderReasoningEffort,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -77,6 +79,8 @@ function buildCodexForcedLoginLaunchArgs(
|
|||
export class ProviderConnectionService {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
||||
null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService = new ApiKeyService(),
|
||||
|
|
@ -92,6 +96,12 @@ export class ProviderConnectionService {
|
|||
this.codexAccountFeature = feature;
|
||||
}
|
||||
|
||||
setCodexModelCatalogFeature(
|
||||
feature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null
|
||||
): void {
|
||||
this.codexModelCatalogFeature = feature;
|
||||
}
|
||||
|
||||
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
|
||||
if (providerId === 'anthropic') {
|
||||
return this.configManager.getConfig().providerConnections.anthropic.authMode;
|
||||
|
|
@ -353,10 +363,53 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
|
||||
return {
|
||||
const withConnection = {
|
||||
...provider,
|
||||
connection: await this.getConnectionInfo(provider.providerId),
|
||||
};
|
||||
|
||||
if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) {
|
||||
return withConnection;
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await this.codexModelCatalogFeature.getCatalog();
|
||||
const models = catalog.models
|
||||
.filter((model) => !model.hidden)
|
||||
.map((model) => model.launchModel.trim())
|
||||
.filter(Boolean);
|
||||
const reasoningEfforts = Array.from(
|
||||
new Set(
|
||||
catalog.models.flatMap<CliProviderReasoningEffort>(
|
||||
(model) => model.supportedReasoningEfforts
|
||||
)
|
||||
)
|
||||
);
|
||||
const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort;
|
||||
const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog;
|
||||
return {
|
||||
...withConnection,
|
||||
models: models.length > 0 ? models : withConnection.models,
|
||||
modelCatalog: catalog,
|
||||
runtimeCapabilities: {
|
||||
...withConnection.runtimeCapabilities,
|
||||
modelCatalog: {
|
||||
dynamic: runtimeModelCatalogCapability?.dynamic === true,
|
||||
source: catalog.source,
|
||||
},
|
||||
reasoningEffort: {
|
||||
supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0,
|
||||
values:
|
||||
runtimeReasoningCapability?.values && runtimeReasoningCapability.values.length > 0
|
||||
? runtimeReasoningCapability.values
|
||||
: (['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[]),
|
||||
configPassthrough: runtimeReasoningCapability?.configPassthrough === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return withConnection;
|
||||
}
|
||||
}
|
||||
|
||||
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
wrapAgentBlock,
|
||||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -1258,10 +1259,7 @@ export class TeamDataService {
|
|||
? request.providerId
|
||||
: undefined,
|
||||
model: request.model?.trim() || undefined,
|
||||
effort:
|
||||
request.effort === 'low' || request.effort === 'medium' || request.effort === 'high'
|
||||
? request.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
|
||||
agentType: 'general-purpose',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
|
@ -1297,7 +1295,7 @@ export class TeamDataService {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: TeamMember['effort'];
|
||||
}[];
|
||||
}
|
||||
): Promise<void> {
|
||||
|
|
@ -1339,10 +1337,7 @@ export class TeamDataService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
agentId: isSameActiveMember ? prev?.agentId : undefined,
|
||||
color: prev?.color,
|
||||
|
|
@ -2418,10 +2413,7 @@ export class TeamDataService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export class TeamMemberResolver {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: TeamMember['effort'];
|
||||
color?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
|
@ -166,7 +166,7 @@ export class TeamMemberResolver {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: TeamMember['effort'];
|
||||
color?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -36,10 +37,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
agentType:
|
||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Persisted team-level metadata saved by the UI before CLI provisioning.
|
||||
* CLI does not know about this file — it only reads/writes config.json.
|
||||
|
|
@ -27,6 +29,7 @@ export interface TeamMetaFile {
|
|||
worktree?: string;
|
||||
extraCliArgs?: string;
|
||||
limitContext?: boolean;
|
||||
launchIdentity?: ProviderModelLaunchIdentity;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +43,70 @@ function normalizeOptionalBackendId(value: unknown): string | undefined {
|
|||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderId(value: unknown): TeamProviderId | undefined {
|
||||
return value === 'anthropic' || value === 'codex' || value === 'gemini' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = value as Partial<ProviderModelLaunchIdentity>;
|
||||
const providerId = normalizeProviderId(raw.providerId);
|
||||
const selectedModelKind =
|
||||
raw.selectedModelKind === 'default' || raw.selectedModelKind === 'explicit'
|
||||
? raw.selectedModelKind
|
||||
: null;
|
||||
if (!providerId || !selectedModelKind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const catalogSource =
|
||||
raw.catalogSource === 'app-server' ||
|
||||
raw.catalogSource === 'static-fallback' ||
|
||||
raw.catalogSource === 'runtime' ||
|
||||
raw.catalogSource === 'unavailable'
|
||||
? raw.catalogSource
|
||||
: 'unavailable';
|
||||
const selectedEffort =
|
||||
raw.selectedEffort === 'none' ||
|
||||
raw.selectedEffort === 'minimal' ||
|
||||
raw.selectedEffort === 'low' ||
|
||||
raw.selectedEffort === 'medium' ||
|
||||
raw.selectedEffort === 'high' ||
|
||||
raw.selectedEffort === 'xhigh'
|
||||
? raw.selectedEffort
|
||||
: null;
|
||||
const resolvedEffort =
|
||||
raw.resolvedEffort === 'none' ||
|
||||
raw.resolvedEffort === 'minimal' ||
|
||||
raw.resolvedEffort === 'low' ||
|
||||
raw.resolvedEffort === 'medium' ||
|
||||
raw.resolvedEffort === 'high' ||
|
||||
raw.resolvedEffort === 'xhigh'
|
||||
? raw.resolvedEffort
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId:
|
||||
migrateProviderBackendId(providerId, normalizeOptionalString(raw.providerBackendId)) ?? null,
|
||||
selectedModel: normalizeOptionalString(raw.selectedModel),
|
||||
selectedModelKind,
|
||||
resolvedLaunchModel: normalizeOptionalString(raw.resolvedLaunchModel),
|
||||
catalogId: normalizeOptionalString(raw.catalogId),
|
||||
catalogSource,
|
||||
catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt),
|
||||
selectedEffort,
|
||||
resolvedEffort,
|
||||
};
|
||||
}
|
||||
|
||||
export class TeamMetaStore {
|
||||
private getMetaPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
|
||||
|
|
@ -110,6 +177,7 @@ export class TeamMetaStore {
|
|||
extraCliArgs:
|
||||
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
|
||||
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
|
||||
launchIdentity: normalizeLaunchIdentity(file.launchIdentity),
|
||||
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
|
||||
};
|
||||
}
|
||||
|
|
@ -133,6 +201,7 @@ export class TeamMetaStore {
|
|||
worktree: data.worktree?.trim() || undefined,
|
||||
extraCliArgs: data.extraCliArgs?.trim() || undefined,
|
||||
limitContext: data.limitContext,
|
||||
launchIdentity: normalizeLaunchIdentity(data.launchIdentity),
|
||||
createdAt: data.createdAt,
|
||||
};
|
||||
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
|||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import {
|
||||
isInboxNoiseMessage,
|
||||
|
|
@ -169,6 +170,7 @@ interface RelayInboxMessageView {
|
|||
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
CliProviderRuntimeCapabilities,
|
||||
CrossTeamSendResult,
|
||||
EffortLevel,
|
||||
InboxMessage,
|
||||
|
|
@ -179,6 +181,7 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSummary,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamAgentRuntimeBackendType,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
|
|
@ -320,6 +323,21 @@ interface ProviderModelListCommandResponse {
|
|||
>;
|
||||
}
|
||||
|
||||
interface RuntimeStatusCommandResponse {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface RuntimeProviderLaunchFacts {
|
||||
defaultModel: string | null;
|
||||
modelIds: Set<string>;
|
||||
runtimeCapabilities: CliProviderRuntimeCapabilities | null;
|
||||
}
|
||||
|
||||
function extractJsonObjectFromCli<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
|
|
@ -334,6 +352,65 @@ function extractJsonObjectFromCli<T>(raw: string): T {
|
|||
}
|
||||
}
|
||||
|
||||
function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getLaunchModelArg(
|
||||
providerId: TeamProviderId,
|
||||
model: string | undefined,
|
||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||
): string | undefined {
|
||||
const explicitModel = getExplicitLaunchModelSelection(model);
|
||||
if (explicitModel) {
|
||||
return explicitModel;
|
||||
}
|
||||
|
||||
if (
|
||||
providerId === 'codex' &&
|
||||
launchIdentity?.selectedModelKind === 'default' &&
|
||||
launchIdentity.resolvedLaunchModel
|
||||
) {
|
||||
return launchIdentity.resolvedLaunchModel;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderModelListModels(
|
||||
provider: NonNullable<ProviderModelListCommandResponse['providers']>[string] | undefined
|
||||
): Set<string> {
|
||||
const models = new Set<string>();
|
||||
for (const entry of provider?.models ?? []) {
|
||||
const modelId = typeof entry === 'string' ? entry : entry.id;
|
||||
const trimmed = modelId?.trim();
|
||||
if (trimmed) {
|
||||
models.add(trimmed);
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function isLegacySafeEffort(effort: EffortLevel): boolean {
|
||||
return effort === 'low' || effort === 'medium' || effort === 'high';
|
||||
}
|
||||
|
||||
function isCodexEffortRuntimeSupported(
|
||||
effort: EffortLevel,
|
||||
capabilities: CliProviderRuntimeCapabilities | null
|
||||
): boolean {
|
||||
if (isLegacySafeEffort(effort)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reasoning = capabilities?.reasoningEffort;
|
||||
return reasoning?.configPassthrough === true && reasoning.values.includes(effort);
|
||||
}
|
||||
|
||||
function isProbeTimeoutMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
|
|
@ -476,6 +553,7 @@ function logRuntimeLaunchSnapshot(
|
|||
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
|
||||
promptSize?: PromptSizeSummary | null;
|
||||
expectedMembersCount?: number;
|
||||
launchIdentity?: ProviderModelLaunchIdentity | null;
|
||||
}
|
||||
): void {
|
||||
const providerId = resolveTeamProviderId(request.providerId);
|
||||
|
|
@ -489,6 +567,7 @@ function logRuntimeLaunchSnapshot(
|
|||
getConfiguredRuntimeBackend(providerId),
|
||||
promptSize: options?.promptSize ?? null,
|
||||
expectedMembersCount: options?.expectedMembersCount ?? null,
|
||||
launchIdentity: options?.launchIdentity ?? null,
|
||||
geminiRuntimeAuth:
|
||||
providerId === 'gemini'
|
||||
? {
|
||||
|
|
@ -1257,17 +1336,22 @@ function buildEffectiveTeamMemberSpec(
|
|||
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
|
||||
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
|
||||
const model =
|
||||
member.model?.trim() ||
|
||||
getExplicitLaunchModelSelection(member.model) ||
|
||||
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||
? defaults.model?.trim()
|
||||
? getExplicitLaunchModelSelection(defaults.model)
|
||||
: undefined) ||
|
||||
undefined;
|
||||
const effort =
|
||||
member.effort ??
|
||||
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||
? defaults.effort
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
...member,
|
||||
providerId: effectiveProviderId,
|
||||
model,
|
||||
effort: member.effort ?? defaults.effort,
|
||||
effort,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2859,6 +2943,214 @@ export class TeamProvisioningService {
|
|||
this.controlApiBaseUrlResolver = resolver;
|
||||
}
|
||||
|
||||
private async readRuntimeProviderLaunchFacts(params: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
providerId: TeamProviderId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
limitContext?: boolean;
|
||||
}): Promise<RuntimeProviderLaunchFacts> {
|
||||
if (params.providerId === 'anthropic') {
|
||||
return {
|
||||
defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true),
|
||||
modelIds: new Set<string>(),
|
||||
runtimeCapabilities: null,
|
||||
};
|
||||
}
|
||||
|
||||
const modelListPromise = execCli(
|
||||
params.claudePath,
|
||||
['model', 'list', '--json', '--provider', params.providerId],
|
||||
{
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
timeout: 10_000,
|
||||
}
|
||||
);
|
||||
const runtimeStatusPromise =
|
||||
params.providerId === 'codex'
|
||||
? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
timeout: 8_000,
|
||||
})
|
||||
: null;
|
||||
|
||||
const [modelListResult, runtimeStatusResult] = await Promise.allSettled([
|
||||
modelListPromise,
|
||||
runtimeStatusPromise,
|
||||
]);
|
||||
|
||||
let defaultModel: string | null = null;
|
||||
let modelIds = new Set<string>();
|
||||
if (modelListResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(
|
||||
modelListResult.value.stdout
|
||||
);
|
||||
const provider = parsed.providers?.[params.providerId];
|
||||
defaultModel =
|
||||
typeof provider?.defaultModel === 'string' && provider.defaultModel.trim().length > 0
|
||||
? provider.defaultModel.trim()
|
||||
: null;
|
||||
modelIds = normalizeProviderModelListModels(provider);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${params.providerId}] Failed to parse runtime model list for launch validation: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null;
|
||||
if (
|
||||
runtimeStatusResult.status === 'fulfilled' &&
|
||||
runtimeStatusResult.value &&
|
||||
typeof runtimeStatusResult.value.stdout === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(
|
||||
runtimeStatusResult.value.stdout
|
||||
);
|
||||
runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultModel,
|
||||
modelIds,
|
||||
runtimeCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
private buildProviderModelLaunchIdentity(params: {
|
||||
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>;
|
||||
facts: RuntimeProviderLaunchFacts;
|
||||
}): ProviderModelLaunchIdentity {
|
||||
const providerId = resolveTeamProviderId(params.request.providerId);
|
||||
const explicitModel = getExplicitLaunchModelSelection(params.request.model);
|
||||
const resolvedLaunchModel = explicitModel ?? params.facts.defaultModel;
|
||||
const resolvedEffort = params.request.effort ?? null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId:
|
||||
migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null,
|
||||
selectedModel: explicitModel ?? null,
|
||||
selectedModelKind: explicitModel ? 'explicit' : 'default',
|
||||
resolvedLaunchModel,
|
||||
catalogId: resolvedLaunchModel,
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: params.request.effort ?? null,
|
||||
resolvedEffort,
|
||||
};
|
||||
}
|
||||
|
||||
private validateRuntimeLaunchSelection(params: {
|
||||
actorLabel: string;
|
||||
providerId: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
facts: RuntimeProviderLaunchFacts;
|
||||
}): void {
|
||||
const explicitModel = getExplicitLaunchModelSelection(params.model);
|
||||
|
||||
if (params.providerId !== 'codex') {
|
||||
if (params.effort && !isLegacySafeEffort(params.effort)) {
|
||||
throw new Error(
|
||||
`${params.actorLabel} uses effort "${params.effort}", but ${getTeamProviderLabel(
|
||||
params.providerId
|
||||
)} currently supports only low, medium, or high effort in Agent Teams.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.effort &&
|
||||
!isCodexEffortRuntimeSupported(params.effort, params.facts.runtimeCapabilities)
|
||||
) {
|
||||
throw new Error(
|
||||
`${params.actorLabel} uses Codex effort "${params.effort}", but this Agent Teams runtime does not expose Codex reasoning config passthrough yet. Use low, medium, or high for now.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!explicitModel || params.facts.modelIds.has(explicitModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.facts.runtimeCapabilities?.modelCatalog?.dynamic === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${params.actorLabel} uses Codex model "${explicitModel}", but this Agent Teams runtime does not declare dynamic Codex model launch support yet. Upgrade the runtime or pick a listed Codex model.`
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAndValidateLaunchIdentity(params: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
request: Pick<
|
||||
TeamCreateRequest,
|
||||
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
|
||||
>;
|
||||
effectiveMembers: TeamCreateRequest['members'];
|
||||
}): Promise<ProviderModelLaunchIdentity> {
|
||||
const leadProviderId = resolveTeamProviderId(params.request.providerId);
|
||||
const factsByProvider = new Map<TeamProviderId, RuntimeProviderLaunchFacts>();
|
||||
const getFacts = async (providerId: TeamProviderId): Promise<RuntimeProviderLaunchFacts> => {
|
||||
const cached = factsByProvider.get(providerId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const facts = await this.readRuntimeProviderLaunchFacts({
|
||||
claudePath: params.claudePath,
|
||||
cwd: params.cwd,
|
||||
providerId,
|
||||
env: params.env,
|
||||
limitContext: params.request.limitContext,
|
||||
});
|
||||
factsByProvider.set(providerId, facts);
|
||||
return facts;
|
||||
};
|
||||
|
||||
const leadFacts = await getFacts(leadProviderId);
|
||||
this.validateRuntimeLaunchSelection({
|
||||
actorLabel: 'Team lead',
|
||||
providerId: leadProviderId,
|
||||
model: params.request.model,
|
||||
effort: params.request.effort,
|
||||
facts: leadFacts,
|
||||
});
|
||||
|
||||
for (const member of params.effectiveMembers) {
|
||||
const memberProviderId = resolveTeamProviderId(member.providerId);
|
||||
const memberFacts = await getFacts(memberProviderId);
|
||||
this.validateRuntimeLaunchSelection({
|
||||
actorLabel: `Member ${member.name}`,
|
||||
providerId: memberProviderId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
facts: memberFacts,
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildProviderModelLaunchIdentity({
|
||||
request: params.request,
|
||||
facts: leadFacts,
|
||||
});
|
||||
}
|
||||
|
||||
async getClaudeLogs(
|
||||
teamName: string,
|
||||
query?: { offset?: number; limit?: number }
|
||||
|
|
@ -6227,6 +6519,13 @@ export class TeamProvisioningService {
|
|||
primaryEnv: provisioningEnv,
|
||||
limitContext: request.limitContext,
|
||||
});
|
||||
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
|
||||
claudePath,
|
||||
cwd: request.cwd,
|
||||
env: shellEnv,
|
||||
request,
|
||||
effectiveMembers: effectiveMemberSpecs,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
const startedAt = nowIso();
|
||||
const run: ProvisioningRun = {
|
||||
|
|
@ -6363,6 +6662,11 @@ export class TeamProvisioningService {
|
|||
run.bootstrapUserPromptPath = null;
|
||||
throw error;
|
||||
}
|
||||
const launchModelArg = getLaunchModelArg(
|
||||
resolveTeamProviderId(request.providerId),
|
||||
request.model,
|
||||
launchIdentity
|
||||
);
|
||||
const spawnArgs = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
|
|
@ -6385,7 +6689,7 @@ export class TeamProvisioningService {
|
|||
...(request.skipPermissions !== false
|
||||
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
|
||||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||
...(request.model ? ['--model', request.model] : []),
|
||||
...(launchModelArg ? ['--model', launchModelArg] : []),
|
||||
...(request.effort ? ['--effort', request.effort] : []),
|
||||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||
...parseCliArgs(request.extraCliArgs),
|
||||
|
|
@ -6400,6 +6704,7 @@ export class TeamProvisioningService {
|
|||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
expectedMembersCount: effectiveMemberSpecs.length,
|
||||
launchIdentity,
|
||||
});
|
||||
try {
|
||||
// Pre-save our meta files before spawn — CLI doesn't touch these.
|
||||
|
|
@ -6422,6 +6727,7 @@ export class TeamProvisioningService {
|
|||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
limitContext: request.limitContext,
|
||||
launchIdentity,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||
|
|
@ -6431,10 +6737,7 @@ export class TeamProvisioningService {
|
|||
workflow: m.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||
model: m.model?.trim() || undefined,
|
||||
effort:
|
||||
m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
|
||||
? m.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(m.effort) ? m.effort : undefined,
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt: Date.now(),
|
||||
}))
|
||||
|
|
@ -6804,6 +7107,13 @@ export class TeamProvisioningService {
|
|||
primaryEnv: provisioningEnv,
|
||||
limitContext: request.limitContext,
|
||||
});
|
||||
const launchIdentity = await this.resolveAndValidateLaunchIdentity({
|
||||
claudePath,
|
||||
cwd: request.cwd,
|
||||
env: shellEnv,
|
||||
request,
|
||||
effectiveMembers: effectiveMemberSpecs,
|
||||
});
|
||||
|
||||
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
||||
const syntheticRequest: TeamCreateRequest = {
|
||||
|
|
@ -7013,8 +7323,13 @@ export class TeamProvisioningService {
|
|||
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
||||
);
|
||||
}
|
||||
if (request.model) {
|
||||
launchArgs.push('--model', request.model);
|
||||
const launchModelArg = getLaunchModelArg(
|
||||
resolveTeamProviderId(request.providerId),
|
||||
request.model,
|
||||
launchIdentity
|
||||
);
|
||||
if (launchModelArg) {
|
||||
launchArgs.push('--model', launchModelArg);
|
||||
}
|
||||
if (request.effort) {
|
||||
launchArgs.push('--effort', request.effort);
|
||||
|
|
@ -7033,6 +7348,7 @@ export class TeamProvisioningService {
|
|||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
expectedMembersCount: effectiveMemberSpecs.length,
|
||||
launchIdentity,
|
||||
});
|
||||
// --resume is added above when a valid previous session JSONL exists.
|
||||
// Without it, CLI creates a fresh session ID automatically.
|
||||
|
|
@ -7050,6 +7366,7 @@ export class TeamProvisioningService {
|
|||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
limitContext: request.limitContext,
|
||||
launchIdentity,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await this.membersMetaStore.writeMembers(
|
||||
|
|
@ -7060,10 +7377,7 @@ export class TeamProvisioningService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
joinedAt: Date.now(),
|
||||
|
|
@ -8514,16 +8828,11 @@ export class TeamProvisioningService {
|
|||
normalizeTeamMemberProviderId(metaMember?.providerId) ??
|
||||
normalizeTeamMemberProviderId(configuredMember?.providerId);
|
||||
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
|
||||
const effort =
|
||||
metaMember?.effort === 'low' ||
|
||||
metaMember?.effort === 'medium' ||
|
||||
metaMember?.effort === 'high'
|
||||
? metaMember.effort
|
||||
: configuredMember?.effort === 'low' ||
|
||||
configuredMember?.effort === 'medium' ||
|
||||
configuredMember?.effort === 'high'
|
||||
? configuredMember.effort
|
||||
: undefined;
|
||||
const effort = isTeamEffortLevel(metaMember?.effort)
|
||||
? metaMember.effort
|
||||
: isTeamEffortLevel(configuredMember?.effort)
|
||||
? configuredMember.effort
|
||||
: undefined;
|
||||
const agentType =
|
||||
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
|
||||
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
|
||||
|
|
@ -13035,12 +13344,9 @@ export class TeamProvisioningService {
|
|||
const effectiveLeadProviderId =
|
||||
normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic';
|
||||
const effectiveLeadModel = launchState.model?.trim() || undefined;
|
||||
const effectiveLeadEffort =
|
||||
launchState.effort === 'low' ||
|
||||
launchState.effort === 'medium' ||
|
||||
launchState.effort === 'high'
|
||||
? launchState.effort
|
||||
: undefined;
|
||||
const effectiveLeadEffort = isTeamEffortLevel(launchState.effort)
|
||||
? launchState.effort
|
||||
: undefined;
|
||||
|
||||
const membersByName = new Map(
|
||||
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
|
||||
|
|
@ -13075,10 +13381,7 @@ export class TeamProvisioningService {
|
|||
delete nextMember.model;
|
||||
}
|
||||
|
||||
const effort =
|
||||
state.effort === 'low' || state.effort === 'medium' || state.effort === 'high'
|
||||
? state.effort
|
||||
: undefined;
|
||||
const effort = isTeamEffortLevel(state.effort) ? state.effort : undefined;
|
||||
if (effort) {
|
||||
nextMember.effort = effort;
|
||||
} else {
|
||||
|
|
@ -13712,10 +14015,7 @@ export class TeamProvisioningService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt,
|
||||
}))
|
||||
|
|
@ -13758,10 +14058,7 @@ export class TeamProvisioningService {
|
|||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model =
|
||||
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
||||
const effort =
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined;
|
||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||
const prev = byName.get(name);
|
||||
if (!prev) {
|
||||
byName.set(name, { name, role, workflow, providerId, model, effort });
|
||||
|
|
@ -13923,10 +14220,7 @@ export class TeamProvisioningService {
|
|||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
});
|
||||
}
|
||||
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
|
|
|
|||
|
|
@ -2,54 +2,136 @@ import React from 'react';
|
|||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
const EFFORT_OPTIONS = [
|
||||
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
const BASE_EFFORT_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
] as const;
|
||||
|
||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||
none: 'None',
|
||||
minimal: 'Minimal',
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh',
|
||||
};
|
||||
|
||||
const BASE_CODEX_SAFE_EFFORTS = new Set<EffortLevel>(['low', 'medium', 'high']);
|
||||
|
||||
export interface EffortLevelSelectorProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
id?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function getCatalogModel(
|
||||
providerStatus: CliProviderStatus | null | undefined,
|
||||
model: string | undefined
|
||||
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number] | null {
|
||||
const catalog = providerStatus?.modelCatalog;
|
||||
if (!catalog || catalog.providerId !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitModel = model?.trim();
|
||||
if (explicitModel) {
|
||||
return (
|
||||
catalog.models.find(
|
||||
(item) => item.launchModel === explicitModel || item.id === explicitModel
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
catalog.models.find((item) => item.id === catalog.defaultModelId) ??
|
||||
catalog.models.find((item) => item.isDefault) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getEffortOptions(params: {
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
providerStatus?: CliProviderStatus | null;
|
||||
}): readonly { value: string; label: string }[] {
|
||||
if (params.providerId !== 'codex') {
|
||||
return BASE_EFFORT_OPTIONS;
|
||||
}
|
||||
|
||||
const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort;
|
||||
const catalogModel = getCatalogModel(params.providerStatus, params.model);
|
||||
const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? [];
|
||||
const candidateEfforts =
|
||||
catalogEfforts.length > 0 ? catalogEfforts : (runtimeCapability?.values ?? []);
|
||||
const safeEfforts =
|
||||
runtimeCapability?.configPassthrough === true
|
||||
? candidateEfforts
|
||||
: candidateEfforts.filter((effort) => BASE_CODEX_SAFE_EFFORTS.has(effort));
|
||||
const efforts = safeEfforts.length > 0 ? safeEfforts : (['low', 'medium', 'high'] as const);
|
||||
const defaultLabel = catalogModel?.defaultReasoningEffort
|
||||
? `Default (${EFFORT_LABELS[catalogModel.defaultReasoningEffort]})`
|
||||
: 'Default';
|
||||
|
||||
return [
|
||||
{ value: '', label: defaultLabel },
|
||||
...efforts.map((effort) => ({
|
||||
value: effort,
|
||||
label: EFFORT_LABELS[effort],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
id,
|
||||
}) => (
|
||||
<div className="mb-3">
|
||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Effort level (optional)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
providerId,
|
||||
model,
|
||||
}) => {
|
||||
const providerStatus = useStore(
|
||||
(s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null
|
||||
);
|
||||
const effortOptions = getEffortOptions({ providerId, model, providerStatus });
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Effort level (optional)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="inline-flex flex-wrap rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{effortOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Controls how much reasoning the selected provider invests before responding. Default uses
|
||||
the provider's standard behavior for the selected model.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Controls how much reasoning the selected provider invests before responding. Default uses the
|
||||
provider's standard behavior for the selected model.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2011,6 +2011,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
value={selectedEffort}
|
||||
onValueChange={setSelectedEffort}
|
||||
id="dialog-effort"
|
||||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
/>
|
||||
<SkipPermissionsCheckbox
|
||||
id="dialog-skip-permissions"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini
|
|||
export function getTeamEffortLabel(effort: string): string {
|
||||
const trimmed = effort.trim();
|
||||
if (!trimmed) return 'Default';
|
||||
if (trimmed === 'xhigh') return 'XHigh';
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
|
||||
import type { EffortLevel, ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
|
||||
|
||||
function normalizeRestartSensitiveMemberContract(member: {
|
||||
role?: string;
|
||||
|
|
@ -14,16 +15,13 @@ function normalizeRestartSensitiveMemberContract(member: {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: EffortLevel;
|
||||
} {
|
||||
const role = member.role?.trim() || undefined;
|
||||
const workflow = member.workflow?.trim() || undefined;
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model = member.model?.trim() || undefined;
|
||||
const effort =
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined;
|
||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||
return { role, workflow, providerId, model, effort };
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +126,7 @@ function normalizeEditableMemberSnapshot(member: {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: EffortLevel;
|
||||
} | null {
|
||||
if (member.removedAt) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ export const LeadModelRow = ({
|
|||
value={effort ?? ''}
|
||||
onValueChange={onEffortChange}
|
||||
id="lead-effort"
|
||||
providerId={providerId}
|
||||
model={model}
|
||||
/>
|
||||
{providerId === 'anthropic' ? (
|
||||
<LimitContextCheckbox
|
||||
|
|
|
|||
|
|
@ -446,6 +446,8 @@ export const MemberDraftRow = ({
|
|||
onEffortChange(member.id, value);
|
||||
}}
|
||||
id={`member-${member.id}-effort`}
|
||||
providerId={effectiveProviderId}
|
||||
model={effectiveModel}
|
||||
/>
|
||||
{lockProviderModel && (
|
||||
<p className="text-[11px] text-amber-300">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Label } from '@renderer/components/ui/label';
|
|||
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
|
@ -50,10 +51,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||
const providerId = normalizeOptionalTeamProviderId(item.providerId);
|
||||
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
||||
const effort: EffortLevel | undefined =
|
||||
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
||||
? item.effort
|
||||
: undefined;
|
||||
const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
|
||||
? item.effort
|
||||
: undefined;
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
|
|
@ -227,8 +227,7 @@ export const MembersEditorSection = ({
|
|||
c.id === memberId
|
||||
? {
|
||||
...c,
|
||||
effort:
|
||||
effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
|
||||
effort: isTeamEffortLevel(effort) ? effort : undefined,
|
||||
}
|
||||
: c
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
|
|||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||
|
|
@ -120,10 +121,7 @@ export function normalizeMemberDraftForProviderMode(
|
|||
}
|
||||
|
||||
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
|
||||
if (value === 'low' || value === 'medium' || value === 'high') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
return isTeamEffortLevel(value) ? value : undefined;
|
||||
}
|
||||
|
||||
interface ExistingMemberColorInput {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
import { del, get, set } from 'idb-keyval';
|
||||
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
|
||||
import type { EffortLevel } from '@shared/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -27,7 +31,7 @@ export interface SerializedMemberDraft {
|
|||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
export interface CreateTeamDraftSnapshot {
|
||||
|
|
@ -67,10 +71,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
|
|||
obj.providerId === 'codex' ||
|
||||
obj.providerId === 'gemini') &&
|
||||
(obj.model === undefined || typeof obj.model === 'string') &&
|
||||
(obj.effort === undefined ||
|
||||
obj.effort === 'low' ||
|
||||
obj.effort === 'medium' ||
|
||||
obj.effort === 'high')
|
||||
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getTeamProviderLabel,
|
||||
getTeamProviderModelOptions,
|
||||
getVisibleTeamProviderModels,
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
|
|
@ -28,6 +29,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
export {
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
|
|
@ -44,8 +46,10 @@ export type TeamModelRuntimeProviderStatus = Pick<
|
|||
CliProviderStatus,
|
||||
| 'providerId'
|
||||
| 'models'
|
||||
| 'modelCatalog'
|
||||
| 'modelAvailability'
|
||||
| 'modelVerificationState'
|
||||
| 'runtimeCapabilities'
|
||||
| 'authMethod'
|
||||
| 'backend'
|
||||
| 'authenticated'
|
||||
|
|
@ -100,6 +104,56 @@ function getFallbackTeamProviderModelOptions(
|
|||
}));
|
||||
}
|
||||
|
||||
function getRuntimeCatalogModels(
|
||||
providerId: SupportedProviderId,
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): string[] | null {
|
||||
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = providerStatus.modelCatalog.models
|
||||
.filter((model) => !model.hidden)
|
||||
.map((model) => model.launchModel.trim())
|
||||
.filter(Boolean);
|
||||
return models.length > 0 ? models : null;
|
||||
}
|
||||
|
||||
function getRuntimeCatalogModelOption(
|
||||
providerId: SupportedProviderId,
|
||||
model: string,
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
): TeamRuntimeModelOption | null {
|
||||
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const catalogModel = providerStatus.modelCatalog.models.find(
|
||||
(item) => item.launchModel === model || item.id === model
|
||||
);
|
||||
if (!catalogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: catalogModel.launchModel,
|
||||
label:
|
||||
getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ??
|
||||
catalogModel.displayName,
|
||||
badgeLabel:
|
||||
catalogModel.badgeLabel ??
|
||||
(getTeamProviderModelOptions(providerId).some((option) => option.value === model)
|
||||
? undefined
|
||||
: 'New'),
|
||||
availabilityStatus: getRuntimeModelAvailability(
|
||||
providerId,
|
||||
catalogModel.launchModel,
|
||||
providerStatus
|
||||
),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus),
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeSelectorModels(
|
||||
providerId: SupportedProviderId,
|
||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||
|
|
@ -108,6 +162,11 @@ function getRuntimeSelectorModels(
|
|||
return [];
|
||||
}
|
||||
|
||||
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
|
||||
if (catalogModels) {
|
||||
return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus);
|
||||
}
|
||||
|
||||
return sortTeamProviderModels(providerId, providerStatus.models);
|
||||
}
|
||||
|
||||
|
|
@ -208,12 +267,18 @@ export function getAvailableTeamProviderModelOptions(
|
|||
const visibleModels = getRuntimeSelectorModels(providerId, providerStatus);
|
||||
return [
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
...visibleModels.map((model) => ({
|
||||
value: model,
|
||||
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
})),
|
||||
...visibleModels.map((model) => {
|
||||
const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus);
|
||||
if (catalogOption) {
|
||||
return catalogOption;
|
||||
}
|
||||
return {
|
||||
value: model,
|
||||
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ export {
|
|||
} from '@shared/utils/providerModelVisibility';
|
||||
|
||||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||
type RuntimeAwareProviderStatus = Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>;
|
||||
type RuntimeAwareProviderStatus = Pick<
|
||||
CliProviderStatus,
|
||||
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
|
||||
>;
|
||||
|
||||
export interface TeamProviderModelOption {
|
||||
value: string;
|
||||
|
|
@ -33,6 +36,8 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON =
|
|||
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
|
||||
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
|
||||
export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON =
|
||||
'Available in Codex, waiting for Agent Teams runtime support.';
|
||||
|
||||
const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
|
|
@ -152,6 +157,13 @@ function getKnownTeamProviderModelOption(
|
|||
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
|
||||
}
|
||||
|
||||
function isKnownTeamProviderModel(
|
||||
providerId: SupportedProviderId | undefined,
|
||||
model: string | undefined
|
||||
): boolean {
|
||||
return Boolean(getKnownTeamProviderModelOption(providerId, model));
|
||||
}
|
||||
|
||||
export function getTeamProviderModelOptions(
|
||||
providerId: SupportedProviderId
|
||||
): readonly TeamProviderModelOption[] {
|
||||
|
|
@ -389,6 +401,18 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
providerId === 'codex' &&
|
||||
providerStatus?.modelCatalog?.providerId === 'codex' &&
|
||||
providerStatus.modelCatalog.models.some(
|
||||
(item) => item.launchModel === trimmed || item.id === trimmed
|
||||
) &&
|
||||
!isKnownTeamProviderModel(providerId, trimmed) &&
|
||||
providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true
|
||||
) {
|
||||
return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON;
|
||||
}
|
||||
|
||||
return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
|
||||
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,57 @@ export interface CliProviderModelAvailability {
|
|||
checkedAt?: string | null;
|
||||
}
|
||||
|
||||
export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
export type CliProviderModelCatalogSource = 'app-server' | 'static-fallback';
|
||||
export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';
|
||||
|
||||
export interface CliProviderModelCatalogItem {
|
||||
id: string;
|
||||
launchModel: string;
|
||||
displayName: string;
|
||||
hidden: boolean;
|
||||
supportedReasoningEfforts: CliProviderReasoningEffort[];
|
||||
defaultReasoningEffort: CliProviderReasoningEffort | null;
|
||||
inputModalities: string[];
|
||||
supportsPersonality: boolean;
|
||||
isDefault: boolean;
|
||||
upgrade: boolean;
|
||||
source: CliProviderModelCatalogSource;
|
||||
badgeLabel?: string | null;
|
||||
statusMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface CliProviderModelCatalog {
|
||||
schemaVersion: 1;
|
||||
providerId: CliProviderId;
|
||||
source: CliProviderModelCatalogSource;
|
||||
status: CliProviderModelCatalogStatus;
|
||||
fetchedAt: string;
|
||||
staleAt: string;
|
||||
defaultModelId: string | null;
|
||||
defaultLaunchModel: string | null;
|
||||
models: CliProviderModelCatalogItem[];
|
||||
diagnostics: {
|
||||
configReadState: 'ready' | 'unsupported' | 'failed' | 'skipped';
|
||||
appServerState: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible';
|
||||
message?: string | null;
|
||||
code?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CliProviderRuntimeCapabilities {
|
||||
modelCatalog?: {
|
||||
dynamic: boolean;
|
||||
source?: CliProviderModelCatalogSource | 'runtime';
|
||||
};
|
||||
reasoningEffort?: {
|
||||
supported: boolean;
|
||||
values: CliProviderReasoningEffort[];
|
||||
configPassthrough?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CliProviderStatus {
|
||||
providerId: CliProviderId;
|
||||
displayName: string;
|
||||
|
|
@ -127,7 +178,9 @@ export interface CliProviderStatus {
|
|||
modelVerificationState?: 'idle' | 'verifying' | 'verified';
|
||||
statusMessage?: string | null;
|
||||
models: string[];
|
||||
modelCatalog?: CliProviderModelCatalog | null;
|
||||
modelAvailability?: CliProviderModelAvailability[];
|
||||
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
||||
canLoginFromUi: boolean;
|
||||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
|
|
|
|||
|
|
@ -782,10 +782,23 @@ export interface TeamViewSnapshot {
|
|||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
export type EffortLevel = 'low' | 'medium' | 'high';
|
||||
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native';
|
||||
|
||||
export interface ProviderModelLaunchIdentity {
|
||||
providerId: TeamProviderId;
|
||||
providerBackendId: TeamProviderBackendId | null;
|
||||
selectedModel: string | null;
|
||||
selectedModelKind: 'default' | 'explicit';
|
||||
resolvedLaunchModel: string | null;
|
||||
catalogId: string | null;
|
||||
catalogSource: 'app-server' | 'static-fallback' | 'runtime' | 'unavailable';
|
||||
catalogFetchedAt: string | null;
|
||||
selectedEffort: EffortLevel | null;
|
||||
resolvedEffort: EffortLevel | null;
|
||||
}
|
||||
|
||||
export interface TeamLaunchRequest {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
|
|
|
|||
57
src/shared/utils/effortLevels.ts
Normal file
57
src/shared/utils/effortLevels.ts
Normal 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(', ');
|
||||
}
|
||||
|
|
@ -46,6 +46,46 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providers: {
|
||||
codex: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
|
||||
},
|
||||
gemini: {
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args[0] === 'runtime') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
codex: {
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: false, source: 'runtime' },
|
||||
reasoningEffort: {
|
||||
supported: true,
|
||||
values: ['low', 'medium', 'high'],
|
||||
configPassthrough: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
}),
|
||||
spawnCli: vi.fn(),
|
||||
killProcessTree: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -24,6 +24,46 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providers: {
|
||||
codex: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
|
||||
},
|
||||
gemini: {
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args[0] === 'runtime') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
codex: {
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: false, source: 'runtime' },
|
||||
reasoningEffort: {
|
||||
supported: true,
|
||||
values: ['low', 'medium', 'high'],
|
||||
configPassthrough: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
}),
|
||||
spawnCli: vi.fn(),
|
||||
killProcessTree: vi.fn(),
|
||||
}));
|
||||
|
|
@ -45,7 +85,7 @@ import {
|
|||
TeamProvisioningService,
|
||||
} from '@main/services/team/TeamProvisioningService';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { spawnCli } from '@main/utils/childProcess';
|
||||
import { execCli, spawnCli } from '@main/utils/childProcess';
|
||||
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||
|
||||
function createFakeChild() {
|
||||
|
|
@ -314,6 +354,72 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('blocks Codex xhigh launch effort until runtime exposes reasoning config passthrough', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
|
||||
vi.mocked(spawnCli).mockReset();
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: {},
|
||||
authSource: 'codex_runtime',
|
||||
providerArgs: [],
|
||||
}));
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
|
||||
await expect(
|
||||
svc.createTeam(
|
||||
{
|
||||
teamName: 'codex-xhigh-blocked',
|
||||
cwd: process.cwd(),
|
||||
members: [],
|
||||
providerId: 'codex',
|
||||
effort: 'xhigh',
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
).rejects.toThrow('does not expose Codex reasoning config passthrough yet');
|
||||
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks future Codex catalog models until runtime declares dynamic launch support', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
|
||||
vi.mocked(spawnCli).mockReset();
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: {},
|
||||
authSource: 'codex_runtime',
|
||||
providerArgs: [],
|
||||
}));
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
|
||||
await expect(
|
||||
svc.createTeam(
|
||||
{
|
||||
teamName: 'codex-future-model-blocked',
|
||||
cwd: process.cwd(),
|
||||
members: [],
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
).rejects.toThrow('does not declare dynamic Codex model launch support yet');
|
||||
|
||||
expect(execCli).toHaveBeenCalledWith(
|
||||
'/fake/codex',
|
||||
['runtime', 'status', '--json', '--provider', 'codex'],
|
||||
expect.objectContaining({ cwd: process.cwd() })
|
||||
);
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => {
|
||||
const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
|
||||
name: 'alice',
|
||||
|
|
|
|||
Loading…
Reference in a new issue