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