Files
letta-code/src/agent/model.ts
2026-02-21 14:38:13 -08:00

359 lines
11 KiB
TypeScript

/**
* 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<ModelReasoningEffort, string>();
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<string, unknown> | 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<string, unknown> } | 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<string, unknown> | 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;
}