/** * Model resolution and handling utilities */ import modelsData from "../models.json"; import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider"; export const models = modelsData; export type ModelReasoningEffort = | "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; const REASONING_EFFORT_ORDER: ModelReasoningEffort[] = [ "none", "minimal", "low", "medium", "high", "xhigh", ]; function isModelReasoningEffort(value: unknown): value is ModelReasoningEffort { return ( typeof value === "string" && REASONING_EFFORT_ORDER.includes(value as ModelReasoningEffort) ); } export function getReasoningTierOptionsForHandle(modelHandle: string): Array<{ effort: ModelReasoningEffort; modelId: string; }> { const byEffort = new Map(); for (const model of models) { if (model.handle !== modelHandle) continue; const effort = (model.updateArgs as { reasoning_effort?: unknown } | null) ?.reasoning_effort; if (!isModelReasoningEffort(effort)) continue; if (!byEffort.has(effort)) { byEffort.set(effort, model.id); } } return REASONING_EFFORT_ORDER.flatMap((effort) => { const modelId = byEffort.get(effort); return modelId ? [{ effort, modelId }] : []; }); } /** * Resolve a model by ID or handle * @param modelIdentifier - Can be either a model ID (e.g., "opus-4.5") or a full handle (e.g., "anthropic/claude-opus-4-5") * @returns The model handle if found, null otherwise */ export function resolveModel(modelIdentifier: string): string | null { const byId = models.find((m) => m.id === modelIdentifier); if (byId) return byId.handle; const byHandle = models.find((m) => m.handle === modelIdentifier); if (byHandle) return byHandle.handle; // For self-hosted servers: if it looks like a handle (contains /), pass it through // This allows using models not in models.json (e.g., from server's /v1/models) if (modelIdentifier.includes("/")) { return modelIdentifier; } return null; } /** * Get the default model handle */ export function getDefaultModel(): string { const defaultModel = models.find((m) => m.isDefault); if (defaultModel) return defaultModel.handle; const firstModel = models[0]; if (!firstModel) { throw new Error("No models available in models.json"); } return firstModel.handle; } /** * Get the default model handle based on billing tier. * Free tier users get glm-4.7, everyone else gets the standard default. * @param billingTier - The user's billing tier (e.g., "free", "pro", "enterprise") * @returns The model handle to use as default */ export function getDefaultModelForTier(billingTier?: string | null): string { // Free tier gets glm-4.7 (a free model) if (billingTier?.toLowerCase() === "free") { const freeDefault = models.find((m) => m.id === "glm-4.7"); if (freeDefault) return freeDefault.handle; } // Everyone else (pro, enterprise, unknown) gets the standard default return getDefaultModel(); } /** * Format available models for error messages */ export function formatAvailableModels(): string { return models.map((m) => ` ${m.id.padEnd(20)} ${m.handle}`).join("\n"); } /** * Get model info by ID or handle * @param modelIdentifier - Can be either a model ID (e.g., "opus-4.5") or a full handle (e.g., "anthropic/claude-opus-4-5") * @returns The model info if found, null otherwise */ export function getModelInfo(modelIdentifier: string) { const byId = models.find((m) => m.id === modelIdentifier); if (byId) return byId; const byHandle = models.find((m) => m.handle === modelIdentifier); if (byHandle) return byHandle; return null; } /** * Get model info by handle + llm_config. * * This exists because many model "tiers" (e.g. gpt-5.2-none/low/medium/high) * share the same handle and differ only by updateArgs like reasoning_effort. * * When resuming a session we want `/model` to highlight the tier that actually * matches the agent configuration. */ export function getModelInfoForLlmConfig( modelHandle: string, llmConfig?: { reasoning_effort?: string | null; enable_reasoner?: boolean | null; } | null, ) { // Try ID/handle direct resolution first. const direct = getModelInfo(modelHandle); // Collect all candidates that share this handle. const candidates = models.filter((m) => m.handle === modelHandle); if (candidates.length === 0) { return direct; } const effort = llmConfig?.reasoning_effort ?? null; if (effort) { const match = candidates.find( (m) => (m.updateArgs as { reasoning_effort?: unknown } | undefined) ?.reasoning_effort === effort, ); if (match) return match; } // Anthropic-style toggle (best-effort; llm_config may not always include it) if (llmConfig?.enable_reasoner === false) { const match = candidates.find( (m) => (m.updateArgs as { enable_reasoner?: unknown } | undefined) ?.enable_reasoner === false, ); if (match) return match; } // Fall back to whatever models.json considers the default for this handle. return direct ?? candidates[0] ?? null; } /** * Get updateArgs for a model by ID or handle * @param modelIdentifier - Can be either a model ID (e.g., "opus-4.5") or a full handle (e.g., "anthropic/claude-opus-4-5") * @returns The updateArgs if found, undefined otherwise */ export function getModelUpdateArgs( modelIdentifier?: string, ): Record | undefined { if (!modelIdentifier) return undefined; const modelInfo = getModelInfo(modelIdentifier); return modelInfo?.updateArgs; } type AgentModelSnapshot = { model?: string | null; llm_config?: { model?: string | null; model_endpoint_type?: string | null; reasoning_effort?: string | null; enable_reasoner?: boolean | null; } | null; }; /** * Resolve the current model preset + updateArgs for an existing agent. * * Used during startup/resume refresh to re-apply only preset-defined fields * (without requiring an explicit --model flag). */ export function getModelPresetUpdateForAgent( agent: AgentModelSnapshot, ): { modelHandle: string; updateArgs: Record } | null { const directHandle = typeof agent.model === "string" && agent.model.length > 0 ? agent.model : null; const endpointType = agent.llm_config?.model_endpoint_type; const llmModel = agent.llm_config?.model; const llmDerivedHandle = typeof endpointType === "string" && endpointType.length > 0 && typeof llmModel === "string" && llmModel.length > 0 ? `${ endpointType === "chatgpt_oauth" ? OPENAI_CODEX_PROVIDER_NAME : endpointType }/${llmModel}` : typeof llmModel === "string" && llmModel.includes("/") ? llmModel : null; const modelHandle = directHandle ?? llmDerivedHandle; if (!modelHandle) return null; const modelInfo = getModelInfoForLlmConfig(modelHandle, { reasoning_effort: agent.llm_config?.reasoning_effort ?? null, enable_reasoner: agent.llm_config?.enable_reasoner ?? null, }); const updateArgs = (modelInfo?.updateArgs as Record | undefined) ?? getModelUpdateArgs(modelHandle); if (!updateArgs || Object.keys(updateArgs).length === 0) { return null; } return { modelHandle: modelInfo?.handle ?? modelHandle, updateArgs, }; } /** * Find a model entry by handle with fuzzy matching support * @param handle - The full model handle * @returns The model entry if found, null otherwise */ function findModelByHandle(handle: string): (typeof models)[number] | null { const pickPreferred = (candidates: (typeof models)[number][]) => candidates.find((m) => m.isDefault) ?? candidates.find((m) => m.isFeatured) ?? candidates.find( (m) => (m.updateArgs as { reasoning_effort?: unknown } | undefined) ?.reasoning_effort === "medium", ) ?? candidates.find( (m) => (m.updateArgs as { reasoning_effort?: unknown } | undefined) ?.reasoning_effort === "high", ) ?? candidates[0] ?? null; // Try exact match first const exactMatch = models.find((m) => m.handle === handle); if (exactMatch) return exactMatch; // For handles like "bedrock/claude-opus-4-5-20251101" where the API returns without // vendor prefix or version suffix, but models.json has // "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0", try fuzzy matching const [provider, ...rest] = handle.split("/"); if (provider && rest.length > 0) { const modelPortion = rest.join("/"); // Find models with the same provider where the model portion is contained // in the models.json handle (handles vendor prefixes and version suffixes) const providerMatches = models.filter((m) => { if (!m.handle.startsWith(`${provider}/`)) return false; const mModelPortion = m.handle.slice(provider.length + 1); // Check if either contains the other (handles both directions) return ( mModelPortion.includes(modelPortion) || modelPortion.includes(mModelPortion) ); }); const providerMatch = pickPreferred(providerMatches); if (providerMatch) return providerMatch; // Cross-provider fallback by model suffix. This helps when llm_config reports // provider_type=openai for BYOK models that are represented in models.json // under a different provider prefix (e.g. chatgpt-plus-pro/*). const suffixMatches = models.filter((m) => m.handle.endsWith(`/${modelPortion}`), ); const suffixMatch = pickPreferred(suffixMatches); if (suffixMatch) return suffixMatch; } return null; } /** * Get a display-friendly name for a model by its handle * @param handle - The full model handle (e.g., "anthropic/claude-sonnet-4-5-20250929") * @returns The display name (e.g., "Sonnet 4.5") if found, null otherwise */ export function getModelDisplayName(handle: string): string | null { const model = findModelByHandle(handle); return model?.label ?? null; } /** * Get a short display name for a model (for status bar) * Falls back to full label if no shortLabel is defined * @param handle - The full model handle * @returns The short name (e.g., "Opus 4.5 BR") if found, null otherwise */ export function getModelShortName(handle: string): string | null { const model = findModelByHandle(handle); if (!model) return null; // Use shortLabel if available, otherwise fall back to label return (model as { shortLabel?: string }).shortLabel ?? model.label; } /** * Resolve a model ID from the llm_config.model value * The llm_config.model is the model portion without the provider prefix * (e.g., "z-ai/glm-4.6:exacto" for handle "openrouter/z-ai/glm-4.6:exacto") * * Note: This may not distinguish between variants like gpt-5.2-medium vs gpt-5.2-high * since they share the same handle. For provider fallback, this is acceptable. * * @param llmConfigModel - The model value from agent.llm_config.model * @returns The model ID if found, null otherwise */ export function resolveModelByLlmConfig(llmConfigModel: string): string | null { // Try to find a model whose handle ends with the llm_config model value const match = models.find((m) => m.handle.endsWith(`/${llmConfigModel}`)); if (match) return match.id; // Also try exact match on the model portion (for simple cases like "gpt-5.2") const exactMatch = models.find((m) => { const parts = m.handle.split("/"); return parts.slice(1).join("/") === llmConfigModel; }); if (exactMatch) return exactMatch.id; return null; }