agent-ecosystem/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts

357 lines
11 KiB
TypeScript

import { createHash, randomBytes } from 'node:crypto';
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 { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
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();
},
};
}