diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 464afc7107..77f4cec6b7 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,11 +1,24 @@ import { ConfigMarkdown } from "@/config/markdown" import { Config } from "../config/config" import { MCP } from "../mcp" +import { Provider } from "../provider/provider" import { UI } from "./ui" export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` + if (Provider.ModelNotFoundError.isInstance(input)) { + const { providerID, modelID, suggestions } = input.data + return [ + `Model not found: ${providerID}/${modelID}`, + ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), + `Try: \`opencode models\` to list available models`, + `Or check your config (opencode.json) provider/model names`, + ].join("\n") + } + if (Provider.InitError.isInstance(input)) { + return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` + } if (Config.JsonError.isInstance(input)) { return ( `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7fbc1b88c3..093e963574 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mergeDeep, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" @@ -597,9 +598,78 @@ export namespace Provider { }) const provider = s.providers[providerID] - if (!provider) throw new ModelNotFoundError({ providerID, modelID }) + if (!provider) { + let suggestions: string[] = [] + const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, "") + const levenshtein = (a: string, b: string) => { + const m = a.length, + n = b.length + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) + for (let i = 0; i <= m; i++) dp[i][0] = i + for (let j = 0; j <= n; j++) dp[0][j] = j + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost) + } + } + return dp[m][n] + } + if (!modelID || modelID.trim() === "") { + // Treat single-token input as an unqualified model; search across all providers' models. + const q = normalize(providerID) + const entries: { combo: string; norm: string }[] = [] + for (const [pid, prov] of Object.entries(s.providers)) { + for (const mid of Object.keys(prov.info.models)) { + entries.push({ combo: pid + "/" + mid, norm: normalize(mid) }) + } + } + const byNorm = fuzzysort.go(q, entries as any, { limit: 5, key: "norm" }).map((r: any) => r.obj.combo) + const combos = entries.map((e) => e.combo) + const byRaw = fuzzysort.go(providerID, combos, { limit: 5 }).map((r) => r.target) + let merged = Array.from(new Set([...byNorm, ...byRaw])) + if (merged.length === 0) { + // fallback to edit distance on normalized mid + const scored = entries + .map((e) => ({ combo: e.combo, d: levenshtein(q, e.norm) })) + .sort((a, b) => a.d - b.d) + .slice(0, 3) + .map((x) => x.combo) + merged = scored + } + suggestions = merged.slice(0, 3) + } else { + const pcands = Object.keys(s.providers) + const corpus = pcands.map((raw) => ({ raw, norm: normalize(raw) })) + const q = normalize(providerID) + const hits = fuzzysort.go(q, corpus as any, { limit: 5, key: "norm" }) + let ranked = hits.map((r: any) => r.obj.raw) + if (ranked.length === 0) { + ranked = pcands + .map((p) => ({ p, d: levenshtein(q, normalize(p)) })) + .sort((a, b) => a.d - b.d) + .slice(0, 3) + .map((x) => x.p) + } + const providerSuggestions = ranked.map((r) => r + "/" + modelID) + suggestions = providerSuggestions + } + throw new ModelNotFoundError({ providerID, modelID, suggestions }) + } const info = provider.info.models[modelID] - if (!info) throw new ModelNotFoundError({ providerID, modelID }) + if (!info) { + const candidates = Object.keys(provider.info.models) + // Normalize punctuation differences like '-' vs '.' by stripping non-alphanumerics + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "") + const corpus = candidates.map((raw) => ({ raw, norm: normalize(raw) })) + const query = normalize(modelID) + const results = fuzzysort.go(query, corpus as any, { limit: 5, key: "norm" }) + const ranked = results.map((r) => ("obj" in r ? (r as any).obj.raw : (r as any).target)) as string[] + const fallback = fuzzysort.go(modelID, candidates, { limit: 5 }).map((r) => r.target) + const merged = Array.from(new Set([...ranked, ...fallback])) + const suggestions = merged.slice(0, 3).map((m) => providerID + "/" + m) + throw new ModelNotFoundError({ providerID, modelID, suggestions }) + } const sdk = await getSDK(provider.info, info) try { @@ -700,6 +770,7 @@ export namespace Provider { z.object({ providerID: z.string(), modelID: z.string(), + suggestions: z.array(z.string()).optional(), }), )