From 2be8b2269fea19bf0f3286063b4337a72e795fcb Mon Sep 17 00:00:00 2001 From: Ian Maurer Date: Wed, 12 Nov 2025 10:41:38 -0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20suggest=20closest=20provider/model?= =?UTF-8?q?=20on=20not=20found=20("Did=20you=20mean=E2=80=A6")\n\nSummary\?= =?UTF-8?q?n-=20Add=20fuzzy=20suggestions=20to=20ProviderModelNotFoundErro?= =?UTF-8?q?r=20with=20up=20to=203=20candidates\n-=20Normalize=20punctuatio?= =?UTF-8?q?n=20(e.g.,=204.5=20vs=204-5)=20and=20case=20to=20better=20match?= =?UTF-8?q?=20common=20typos\n-=20Support=20model-only=20input=20(no=20pro?= =?UTF-8?q?vider)=20by=20searching=20across=20all=20providers\n-=20Enhance?= =?UTF-8?q?=20CLI=20error=20formatter=20to=20display=20suggestions=20when?= =?UTF-8?q?=20present\n\nImplementation\n-=20provider.ts:=20use=20fuzzysor?= =?UTF-8?q?t;=20add=20normalization=20by=20stripping=20non-alphanumerics;?= =?UTF-8?q?=20search=20by=20key=20for=20robust=20matches\n-=20provider.ts:?= =?UTF-8?q?=20when=20provider=20is=20unknown=20and=20model=20is=20empty,?= =?UTF-8?q?=20treat=20token=20as=20unqualified=20model=20and=20search=20ac?= =?UTF-8?q?ross=20all=20providers'=20models;=20otherwise=20suggest=20provi?= =?UTF-8?q?der=20matches\n-=20error.ts:=20print=20"Did=20you=20mean:=20,=20=E2=80=A6"=20when=20suggestions=20exist\n\nExa?= =?UTF-8?q?mples\n1)=20Typo=20in=20model=20ID\n=20=20=20$=20bun=20run=20./?= =?UTF-8?q?src/index.ts=20run=20--model=20anthropic/claude-haiu-4-5=20"hi"?= =?UTF-8?q?\n=20=20=20Error:=20Model=20not=20found:=20anthropic/claude-hai?= =?UTF-8?q?u-4-5\n=20=20=20Did=20you=20mean:=20anthropic/claude-haiku-4-5,?= =?UTF-8?q?=20anthropic/claude-haiku-4-5-20251001\n=20=20=20Try:=20zai-cod?= =?UTF-8?q?ing-plan/glm-4.5-flash=20zai-coding-plan/glm-4.5=20zai-coding-p?= =?UTF-8?q?lan/glm-4.5-air=20zai-coding-plan/glm-4.5v=20zai-coding-plan/gl?= =?UTF-8?q?m-4.6=20opencode/big-pickle=20opencode/grok-code=20anthropic/cl?= =?UTF-8?q?aude-opus-4-0=20anthropic/claude-3-5-sonnet-20241022=20anthropi?= =?UTF-8?q?c/claude-opus-4-1=20anthropic/claude-haiku-4-5=20anthropic/clau?= =?UTF-8?q?de-3-5-sonnet-20240620=20anthropic/claude-3-5-haiku-latest=20an?= =?UTF-8?q?thropic/claude-3-opus-20240229=20anthropic/claude-sonnet-4-5=20?= =?UTF-8?q?anthropic/claude-sonnet-4-5-20250929=20anthropic/claude-sonnet-?= =?UTF-8?q?4-20250514=20anthropic/claude-opus-4-20250514=20anthropic/claud?= =?UTF-8?q?e-3-5-haiku-20241022=20anthropic/claude-3-haiku-20240307=20anth?= =?UTF-8?q?ropic/claude-3-7-sonnet-20250219=20anthropic/claude-3-7-sonnet-?= =?UTF-8?q?latest=20anthropic/claude-sonnet-4-0=20anthropic/claude-opus-4-?= =?UTF-8?q?1-20250805=20anthropic/claude-3-sonnet-20240229=20anthropic/cla?= =?UTF-8?q?ude-haiku-4-5-20251001=20openai/gpt-4.1-nano=20openai/text-embe?= =?UTF-8?q?dding-3-small=20openai/gpt-4=20openai/o1-pro=20openai/gpt-4o-20?= =?UTF-8?q?24-05-13=20openai/gpt-4o-2024-08-06=20openai/gpt-4.1-mini=20ope?= =?UTF-8?q?nai/o3-deep-research=20openai/gpt-3.5-turbo=20openai/text-embed?= =?UTF-8?q?ding-3-large=20openai/gpt-4-turbo=20openai/o1-preview=20openai/?= =?UTF-8?q?o3-mini=20openai/codex-mini-latest=20openai/gpt-5-nano=20openai?= =?UTF-8?q?/gpt-5-codex=20openai/gpt-4o=20openai/gpt-4.1=20openai/o4-mini?= =?UTF-8?q?=20openai/o1=20openai/gpt-5-mini=20openai/o1-mini=20openai/text?= =?UTF-8?q?-embedding-ada-002=20openai/o3-pro=20openai/gpt-4o-2024-11-20?= =?UTF-8?q?=20openai/o3=20openai/o4-mini-deep-research=20openai/gpt-4o-min?= =?UTF-8?q?i=20openai/gpt-5=20openai/gpt-5-pro=20to=20list=20available=20m?= =?UTF-8?q?odels\n=20=20=20Or=20check=20your=20config=20(opencode.json)=20?= =?UTF-8?q?provider/model=20names\n\n2)=20Dot=20vs=20dash=20(punctuation?= =?UTF-8?q?=20normalization)\n=20=20=20$=20bun=20run=20./src/index.ts=20ru?= =?UTF-8?q?n=20--model=20anthropic/claude-haiku-4.5=20"hi"\n=20=20=20Error?= =?UTF-8?q?:=20Model=20not=20found:=20anthropic/claude-haiku-4.5\n=20=20?= =?UTF-8?q?=20Did=20you=20mean:=20anthropic/claude-haiku-4-5,=20anthropic/?= =?UTF-8?q?claude-haiku-4-5-20251001\n=20=20=20Try:=20zai-coding-plan/glm-?= =?UTF-8?q?4.5-flash=20zai-coding-plan/glm-4.5=20zai-coding-plan/glm-4.5-a?= =?UTF-8?q?ir=20zai-coding-plan/glm-4.5v=20zai-coding-plan/glm-4.6=20openc?= =?UTF-8?q?ode/big-pickle=20opencode/grok-code=20anthropic/claude-opus-4-0?= =?UTF-8?q?=20anthropic/claude-3-5-sonnet-20241022=20anthropic/claude-opus?= =?UTF-8?q?-4-1=20anthropic/claude-haiku-4-5=20anthropic/claude-3-5-sonnet?= =?UTF-8?q?-20240620=20anthropic/claude-3-5-haiku-latest=20anthropic/claud?= =?UTF-8?q?e-3-opus-20240229=20anthropic/claude-sonnet-4-5=20anthropic/cla?= =?UTF-8?q?ude-sonnet-4-5-20250929=20anthropic/claude-sonnet-4-20250514=20?= =?UTF-8?q?anthropic/claude-opus-4-20250514=20anthropic/claude-3-5-haiku-2?= =?UTF-8?q?0241022=20anthropic/claude-3-haiku-20240307=20anthropic/claude-?= =?UTF-8?q?3-7-sonnet-20250219=20anthropic/claude-3-7-sonnet-latest=20anth?= =?UTF-8?q?ropic/claude-sonnet-4-0=20anthropic/claude-opus-4-1-20250805=20?= =?UTF-8?q?anthropic/claude-3-sonnet-20240229=20anthropic/claude-haiku-4-5?= =?UTF-8?q?-20251001=20openai/gpt-4.1-nano=20openai/text-embedding-3-small?= =?UTF-8?q?=20openai/gpt-4=20openai/o1-pro=20openai/gpt-4o-2024-05-13=20op?= =?UTF-8?q?enai/gpt-4o-2024-08-06=20openai/gpt-4.1-mini=20openai/o3-deep-r?= =?UTF-8?q?esearch=20openai/gpt-3.5-turbo=20openai/text-embedding-3-large?= =?UTF-8?q?=20openai/gpt-4-turbo=20openai/o1-preview=20openai/o3-mini=20op?= =?UTF-8?q?enai/codex-mini-latest=20openai/gpt-5-nano=20openai/gpt-5-codex?= =?UTF-8?q?=20openai/gpt-4o=20openai/gpt-4.1=20openai/o4-mini=20openai/o1?= =?UTF-8?q?=20openai/gpt-5-mini=20openai/o1-mini=20openai/text-embedding-a?= =?UTF-8?q?da-002=20openai/o3-pro=20openai/gpt-4o-2024-11-20=20openai/o3?= =?UTF-8?q?=20openai/o4-mini-deep-research=20openai/gpt-4o-mini=20openai/g?= =?UTF-8?q?pt-5=20openai/gpt-5-pro=20to=20list=20available=20models\n=20?= =?UTF-8?q?=20=20Or=20check=20your=20config=20(opencode.json)=20provider/m?= =?UTF-8?q?odel=20names\n\n3)=20Missing=20provider=20(model-only=20input)\?= =?UTF-8?q?n=20=20=20$=20bun=20run=20./src/index.ts=20run=20--model=20big-?= =?UTF-8?q?pickle=20"hi"\n=20=20=20Error:=20Model=20not=20found:=20big-pic?= =?UTF-8?q?kle/\n=20=20=20Did=20you=20mean:=20opencode/big-pickle\n\n4)=20?= =?UTF-8?q?Correct=20model=20after=20suggestion\n=20=20=20$=20bun=20run=20?= =?UTF-8?q?./src/index.ts=20run=20--model=20opencode/big-pickle=20"hi"\n?= =?UTF-8?q?=20=20=20Hi!=20How=20can=20I=20help=20you=20with=20your=20openc?= =?UTF-8?q?ode=20project=20today=3F\n\nNotes\n-=20Suggestions=20are=20hint?= =?UTF-8?q?s=20only;=20behavior=20is=20unchanged=20(no=20auto-selection).\?= =?UTF-8?q?n-=20This=20runs=20locally=20as=20part=20of=20the=20CLI=20error?= =?UTF-8?q?=20path;=20performance=20impact=20is=20negligible=20(small=20in?= =?UTF-8?q?-memory=20scans).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/error.ts | 5 ++- packages/opencode/src/provider/provider.ts | 41 ++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 5892344e7d..4f2e0ab827 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -8,9 +8,12 @@ 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 } = input.data + 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") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e30576bf7c..5e3da288c1 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 path from "path" import { Config } from "../config/config" import { mergeDeep, sortBy } from "remeda" @@ -522,9 +523,44 @@ 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, "") + 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) + suggestions = Array.from(new Set([...byNorm, ...byRaw])).slice(0, 3) + } else { + const providerSuggestions = fuzzysort + .go(providerID, Object.keys(s.providers), { limit: 3 }) + .map((r) => r.target + "/" + 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 { @@ -658,6 +694,7 @@ export namespace Provider { z.object({ providerID: z.string(), modelID: z.string(), + suggestions: z.array(z.string()).optional(), }), )