/** * Model resolution: exact match ("provider/modelId") with fuzzy fallback. */ export interface ModelEntry { id: string; name: string; provider: string; } export interface ModelRegistry { find(provider: string, modelId: string): any; getAll(): any[]; getAvailable?(): any[]; } /** * Resolve a model string to a Model instance. * Tries exact match first ("provider/modelId"), then fuzzy match against all available models. * Returns the Model on success, or an error message string on failure. */ export function resolveModel( input: string, registry: ModelRegistry, ): any | string { // Available models (those with auth configured) const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[]; const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase())); // 1. Exact match: "provider/modelId" — only if available (has auth) const slashIdx = input.indexOf("/"); if (slashIdx !== -1) { const provider = input.slice(0, slashIdx); const modelId = input.slice(slashIdx + 1); if (availableSet.has(input.toLowerCase())) { const found = registry.find(provider, modelId); if (found) return found; } } // 2. Fuzzy match against available models. Normalize separators so cosmetic // punctuation differences still match — e.g. "claude-haiku-4.5" and // "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model. const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-"); const query = normalize(input); // Score each model: prefer exact id match > id contains > name contains > provider+id contains let bestMatch: ModelEntry | undefined; let bestScore = 0; for (const m of all) { const id = normalize(m.id); const name = normalize(m.name); const full = normalize(`${m.provider}/${m.id}`); let score = 0; if (id === query || full === query) { score = 100; // exact } else if (id.includes(query) || full.includes(query)) { score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches } else if (name.includes(query)) { score = 40 + (query.length / name.length) * 20; } else if ( // A trailing date-stamp token (e.g. "20251001") is optional, so a // date-pinned config like "claude-haiku-4-5-20251001" still matches an // undated registry id like "claude-haiku-4-5". query .split(/[\s\-/]+/) .every(part => /^\d{8}$/.test(part) || id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part)) ) { score = 20; // all parts present somewhere } if (score > bestScore) { bestScore = score; bestMatch = m; } } if (bestMatch && bestScore >= 20) { const found = registry.find(bestMatch.provider, bestMatch.id); if (found) return found; } // 3. Provider fallback: a "provider/modelId" query that didn't match under the // named provider (exact or fuzzy above) retries against all providers. The // named provider is preferred when present; this only kicks in when it isn't, // so the same model from another provider beats falling back to "inherit". if (slashIdx !== -1) { const bare = resolveModel(input.slice(slashIdx + 1), registry); if (typeof bare !== "string") return bare; } // 4. No match — list available models const modelList = all .map(m => ` ${m.provider}/${m.id}`) .sort() .join("\n"); return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`; }