import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { buildModelAliasIndex, modelKey, parseModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; import { type OpenClawConfig, readConfigFileSnapshot, writeConfigFile, } from "../../config/config.js"; export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => { if (opts.json && opts.plain) { throw new Error("Choose either --json or --plain, not both."); } }; export const formatTokenK = (value?: number | null) => { if (!value || !Number.isFinite(value)) return "-"; if (value < 1024) return `${Math.round(value)}`; return `${Math.round(value / 1024)}k`; }; export const formatMs = (value?: number | null) => { if (value === null || value === undefined) return "-"; if (!Number.isFinite(value)) return "-"; if (value < 1000) return `${Math.round(value)}ms`; return `${Math.round(value / 100) / 10}s`; }; export async function updateConfig( mutator: (cfg: OpenClawConfig) => OpenClawConfig, ): Promise { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); } const next = mutator(snapshot.config); await writeConfigFile(next); return next; } export function resolveModelTarget(params: { raw: string; cfg: OpenClawConfig }): { provider: string; model: string; } { const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER, }); const resolved = resolveModelRefFromString({ raw: params.raw, defaultProvider: DEFAULT_PROVIDER, aliasIndex, }); if (!resolved) { throw new Error(`Invalid model reference: ${params.raw}`); } return resolved.ref; } export function buildAllowlistSet(cfg: OpenClawConfig): Set { const allowed = new Set(); const models = cfg.agents?.defaults?.models ?? {}; for (const raw of Object.keys(models)) { const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); if (!parsed) continue; allowed.add(modelKey(parsed.provider, parsed.model)); } return allowed; } export function normalizeAlias(alias: string): string { const trimmed = alias.trim(); if (!trimmed) throw new Error("Alias cannot be empty."); if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) { throw new Error("Alias must use letters, numbers, dots, underscores, colons, or dashes."); } return trimmed; } export { modelKey }; export { DEFAULT_MODEL, DEFAULT_PROVIDER }; /** * Model key format: "provider/model" * * The model key is displayed in `/model status` and used to reference models. * When using `/model `, use the exact format shown (e.g., "openrouter/moonshotai/kimi-k2"). * * For providers with hierarchical model IDs (e.g., OpenRouter), the model ID may include * sub-providers (e.g., "moonshotai/kimi-k2"), resulting in a key like "openrouter/moonshotai/kimi-k2". */ import { findModelInCatalog, loadModelCatalog, type ModelCatalogEntry, modelSupportsVision, } from "../../agents/model-catalog.js"; export type ModelValidationResult = { valid: boolean; entry?: ModelCatalogEntry; suggestions?: string[]; }; /** * Validate that a model exists in the catalog. * Returns suggestions for similar models if not found. * Gracefully degrades if catalog fails to load. */ export async function validateModelInCatalog( provider: string, modelId: string, ): Promise { try { const catalog = await loadModelCatalog(); if (catalog.length === 0) { // Catalog unavailable - gracefully degrade return { valid: true }; } const entry = findModelInCatalog(catalog, provider, modelId); if (entry) { return { valid: true, entry }; } // Find suggestions via fuzzy matching const suggestions = findSimilarModels(catalog, provider, modelId); return { valid: false, suggestions }; } catch { // Catalog load failed - gracefully degrade return { valid: true }; } } /** * Validate that a model exists and supports vision input. */ export async function validateImageModel( provider: string, modelId: string, ): Promise { const result = await validateModelInCatalog(provider, modelId); if (!result.valid || !result.entry) { return result; } return { ...result, supportsVision: modelSupportsVision(result.entry), }; } function findSimilarModels( catalog: ModelCatalogEntry[], provider: string, modelId: string, ): string[] { const normalizedProvider = provider.toLowerCase(); const normalizedModelId = modelId.toLowerCase(); // Score models by similarity const scored = catalog .map((entry) => { const entryProvider = entry.provider.toLowerCase(); const entryModelId = entry.id.toLowerCase(); let score = 0; // Boost same-provider matches if (entryProvider === normalizedProvider) { score += 50; } // Calculate string similarity for model ID score += stringSimilarity(normalizedModelId, entryModelId); return { key: `${entry.provider}/${entry.id}`, score }; }) .filter((item) => item.score > 30) // Minimum threshold .sort((a, b) => b.score - a.score) .slice(0, 3); return scored.map((item) => item.key); } function stringSimilarity(a: string, b: string): number { // Simple Levenshtein-based similarity score const maxLen = Math.max(a.length, b.length); if (maxLen === 0) return 100; const distance = levenshteinDistance(a, b); return Math.round((1 - distance / maxLen) * 100); } function levenshteinDistance(a: string, b: string): number { const matrix: number[][] = []; for (let i = 0; i <= b.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= a.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { if (b[i - 1] === a[j - 1]) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1, ); } } } return matrix[b.length][a.length]; }