Summary
autoRouteProvider (in internal/models/providers.go) silently fails for any model whose resolved npm package maps to a non-anthropic/openai/openaicompat LLM provider. This breaks every Gemini model proxied through opencode today, and the same structural gap exists for five other entries in the npmToLLMProvider table.
The TUI hits the same bug — the /model picker silently swallows the failure and keeps you on the previous model, which is why this took a while to surface as a real bug report (it looked like Gemini-on-opencode was working when it wasn't).
The proposed fix is a small refactor that aligns Kit's routing with fantasy's actual architecture: three wire protocols, not ten LLM providers.
Versions
Reproduction
1. SDK (the consumer who originally hit the bug)
m, err := kit.New(ctx, &kit.Options{
Model: "opencode/gemini-3.5-flash",
SkipConfig: true,
NoExtensions: true,
Quiet: true,
ProviderAPIKey: os.Getenv("OPENCODE_API_KEY"),
})
ERROR: failed to create agent: failed to create agent: failed to create model
provider: unsupported provider: opencode (npm: @ai-sdk/google has no LLM provider mapping)
2. CLI — same failure
$ kit -m opencode/gemini-3.5-flash --quiet --no-extensions "say hi"
ERROR
Failed to create agent: failed to create agent: failed to create model provider:
unsupported provider: opencode (npm: @ai-sdk/google has no LLM provider mapping).
$ kit -m opencode/gemini-3-pro --quiet --no-extensions "say hi"
# same error
$ kit -m opencode/kimi-k2.6 --quiet --no-extensions "say hi"
Hi # ✅ routes via @ai-sdk/openai-compatible
$ kit -m google/gemini-3.5-flash --quiet --no-extensions "say hi"
Hi there! # ✅ hits native `case "google":` (different code path)
3. Why the TUI looks fine (it isn't)
The TUI's /model picker shows five rows for gemini-3.5-flash (one per provider that registers it), distinguished only by a tiny grey [provider] description tag — easy to confuse:
[github-copilot] gemini-3.5-flash ← works (@ai-sdk/openai-compatible)
[google] gemini-3.5-flash ← works (native case "google")
[google-vertex] gemini-3.5-flash ← broken (@ai-sdk/google-vertex unmapped)
[llmgateway] gemini-3.5-flash ← works (@ai-sdk/openai-compatible)
[opencode] gemini-3.5-flash ← broken (@ai-sdk/google override)
When you pick a broken row, the TUI calls Kit.SetModel, which calls the same models.CreateProvider, which returns the same error. But the TUI handles it with a single printSystemMessage line in scrollback (internal/ui/model.go:1227-1228):
if err := m.setModel(msg.ModelString); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
} else {
// … success path: only here does the status bar / providerName update,
// and only here does SaveModelPreference fire …
}
So:
- The status bar still shows the previous (working) model.
- Subsequent prompts go to the previous model.
- The user reports "the TUI works fine for opencode/gemini" because the chat output did come back successfully — but from
anthropic/claude-opus-4-7 (or whatever their last working model was), not from Gemini.
I confirmed this against my own ~/.config/kit/preferences.yml: it still shows model: anthropic/claude-opus-4-7 even after picking opencode/gemini in the picker (because SaveModelPreference is only called inside the else success branch).
Root cause
internal/models/modelsdb.go:54-65 maps 10 npm packages:
var npmToLLMProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
"@ai-sdk/google-vertex": "google-vertex",
"@ai-sdk/google-vertex/anthropic": "google-vertex-anthropic",
"@ai-sdk/amazon-bedrock": "bedrock",
"@ai-sdk/azure": "azure",
"@openrouter/ai-sdk-provider": "openrouter",
"@ai-sdk/vercel": "vercel",
"@ai-sdk/openai-compatible": "openaicompat",
}
But internal/models/providers.go:352-367 only handles 3 of them:
switch llmProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
if config.ProviderURL == "" && providerInfo.API != "" {
config.ProviderURL = providerInfo.API
}
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case "openai":
if config.ProviderURL == "" && providerInfo.API != "" {
config.ProviderURL = providerInfo.API
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
}
So @ai-sdk/google, @ai-sdk/google-vertex, @ai-sdk/amazon-bedrock, @ai-sdk/azure, @openrouter/ai-sdk-provider, @ai-sdk/vercel, and @ai-sdk/google-vertex/anthropic all hit the default branch and die.
This bites first on opencode because every Gemini model under opencode has a per-model provider.npm: "@ai-sdk/google" override in models.dev:
"gemini-3-flash": { "provider": { "npm": "@ai-sdk/google" } }
"gemini-3-pro": { "provider": { "npm": "@ai-sdk/google" } }
"gemini-3.1-pro": { "provider": { "npm": "@ai-sdk/google" } }
"gemini-3.5-flash": { "provider": { "npm": "@ai-sdk/google" } }
These overrides override opencode's default @ai-sdk/openai-compatible (which would otherwise have made everything Just Work via the openaicompat case), and route them into the dead end.
Insight: fantasy is built on three wire protocols, not ten LLM providers
Looking at charm.land/fantasy@v0.25.0/providers/:
| Wire protocol |
Native provider |
Wrappers that reuse it |
OpenAI (/v1/chat/completions, /v1/responses) |
providers/openai |
providers/openaicompat (no default URL + hooks), providers/vercel (default https://ai-gateway.vercel.sh/v1), providers/openrouter (default https://openrouter.ai/api/v1), providers/azure (with deployment routing) |
Anthropic (/v1/messages) |
providers/anthropic |
providers/bedrock (= anthropic + WithBedrock() + AWS auth); Vertex via anthropic.WithVertex(...) |
Google Gemini (generateContent) |
providers/google |
Vertex via google.WithVertex(...) |
Look at how thin the OpenAI-wire wrappers are — providers/vercel/vercel.go and providers/openrouter/openrouter.go literally just call openai.New(...) with their respective default base URL prepended. Same shape for providers/bedrock/bedrock.go against anthropic.New(...). The "10 LLM providers" in npmToLLMProvider are really 3 wire protocols + 7 pre-baked bundles.
All three native providers expose WithBaseURL:
providers/openai/openai.go:69 — func WithBaseURL(url string) Option
providers/anthropic/anthropic.go:110 — func WithBaseURL(baseURL string) Option
providers/google/google.go:73 — func WithBaseURL(baseURL string) Option
So any proxy that forwards a re-flavored OpenAI/Anthropic/Google API just needs the native fantasy provider configured with the proxy's base URL. There's no need for kit to ship a separate per-provider auto-route helper for each entry in the npm table.
Empirical validation against models.dev
Scanning every per-model provider.npm override across the entire registry, only four npm packages ever appear:
| npm package |
# of override occurrences |
wire protocol |
examples |
@ai-sdk/anthropic |
37 |
anthropic |
opencode/claude-3-5-haiku, opencode/claude-haiku-4-5, opencode/claude-opus-4-1 |
@ai-sdk/openai |
32 |
openai |
opencode/gpt-5, opencode/gpt-5-codex, opencode/gpt-5-nano |
@ai-sdk/openai-compatible |
22 |
openai |
crof/deepseek-v4-flash, crof/deepseek-v4-pro |
@ai-sdk/google |
4 |
google ← missing |
opencode/gemini-3-flash, opencode/gemini-3-pro, opencode/gemini-3.1-pro, opencode/gemini-3.5-flash |
The other six npm packages mapped in npmToLLMProvider (google-vertex, google-vertex-anthropic, amazon-bedrock, azure, openrouter, vercel) never appear as per-model overrides on proxy providers. They only show up as the primary npm of their canonical provider — and every one of those providers already has a native top-level case in CreateProvider (providers.go:268-291), so they never even reach autoRouteProvider.
This means autoRouteProvider only ever needs to handle 4 npm packages, mapping to 3 wire protocols. The current npm-to-llmProvider table is unnecessarily wide and obscures this.
Proposed fix
Replace npmToLLMProvider with npmToWireProtocol, and switch autoRouteProvider on wire protocol instead of llmProvider name:
// internal/models/modelsdb.go
// wireProtocol identifies which LLM API protocol an npm package speaks.
// Fantasy implements three native protocols (openai, anthropic, google);
// everything else in providers/ is a thin wrapper around one of them with
// a pre-baked default URL or auth scheme.
type wireProtocol int
const (
wireUnknown wireProtocol = iota
wireOpenAI
wireAnthropic
wireGoogle
)
// npmToWireProtocol maps npm package names from models.dev to the wire
// protocol they speak. Provider-specific bundles (azure, bedrock, vercel,
// openrouter, google-vertex, google-vertex-anthropic) are intentionally
// absent — they have native top-level cases in CreateProvider and never
// reach the auto-router. If they do appear as a per-model override (data
// error), we fall back to wireOpenAI when the provider has an api URL,
// and surface a clear error when it doesn't.
var npmToWireProtocol = map[string]wireProtocol{
"@ai-sdk/openai": wireOpenAI,
"@ai-sdk/openai-compatible": wireOpenAI,
"@ai-sdk/anthropic": wireAnthropic,
"@ai-sdk/google": wireGoogle,
}
// internal/models/providers.go — replaces lines 333-368
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
providerInfo := registry.GetProviderInfo(provider)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
}
// Resolve npm: per-model override > provider default.
npmPackage := providerInfo.NPM
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
npmPackage = modelInfo.ProviderNPM
}
wire, known := npmToWireProtocol[npmPackage]
if !known {
// Unknown npm but the provider has an API URL → assume OpenAI-compatible.
// (Preserves today's "any provider in models.dev with an api URL is
// auto-routed through openaicompat" behaviour from providers.go:213-215.)
if providerInfo.API == "" {
return nil, fmt.Errorf(
"cannot auto-route provider %s: npm package %q has no known wire protocol "+
"and the registry has no API URL. Use --provider-url to override.",
provider, npmPackage,
)
}
wire = wireOpenAI
}
// All three wires use the provider's API URL from models.dev as the base.
if config.ProviderURL == "" && providerInfo.API != "" {
config.ProviderURL = providerInfo.API
}
switch wire {
case wireOpenAI:
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case wireAnthropic:
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case wireGoogle:
return createAutoRoutedGoogleProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("internal error: unknown wire protocol for provider %s", provider)
}
}
And one new helper, parallel to the existing createAutoRoutedAnthropicProvider (providers.go:411):
// createAutoRoutedGoogleProvider creates a Google (Gemini) provider for
// third-party providers with Gemini-compatible APIs (e.g. opencode's
// /zen Gemini routes).
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
}
opts := []google.Option{
google.WithGeminiAPIKey(apiKey),
google.WithName(info.ID),
}
if config.ProviderURL != "" {
opts = append(opts, google.WithBaseURL(config.ProviderURL))
}
if config.TLSSkipVerify {
opts = append(opts, google.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
p, err := google.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
}
return &ProviderResult{Model: model}, nil
}
isProviderLLMSupported (registry.go:401-414) needs the corresponding one-line update to consult npmToWireProtocol instead of npmToLLMProvider. The old npmToLLMProvider can either be deleted or kept as a deprecated alias.
Why this is better than a "just add case "google":" patch
-
Reflects fantasy's actual architecture. Fantasy's package layout already says "there are three wire protocols and a bunch of bundles". The current npmToLLMProvider map flattens that distinction and the result is the 6-case routing gap. Switching on wire protocol makes the routing match the upstream model.
-
Eliminates dead code paths. The current table maps 7 entries to llmProvider names the switch doesn't handle. They never reach auto-routing in practice (the top-level switch catches them first), but their presence makes the system look like it supports something it doesn't, and is exactly what tripped the original bug.
-
One new case per wire protocol, not per npm package. If models.dev adds @ai-sdk/some-new-anthropic-wrapper tomorrow, the fix is one line in npmToWireProtocol, not a new helper function + switch case + tests.
-
Better error message for the genuine "unknown" case. Instead of (npm: @ai-sdk/google has no LLM provider mapping) — which is misleading because the mapping does exist, just no implementation — the error now says either "fell back to openai-compat (because we have a URL)" silently, or "no API URL, can't auto-route, use --provider-url" explicitly.
-
Validates against real data. The empirical check above shows the wire-protocol model handles 100% of currently-observed per-model overrides in models.dev (37+32+22+4 = 95 overrides, all four npm packages covered).
Backwards compatibility
- All currently-working flows continue to work (the existing
openaicompat, anthropic, openai cases each collapse cleanly into one wire protocol).
- The
default → openaicompat fallback if API URL present behaviour at providers.go:347-350 is preserved.
- The native top-level provider cases (
anthropic, openai, google, azure, bedrock, openrouter, vercel, google-vertex-anthropic, ollama, custom) are untouched.
Bonus — make the TUI surface this kind of failure properly
The silent fallback in internal/ui/model.go:1227-1228 is what made this look like a phantom bug for so long. Two small UX improvements would help future users regardless of whether the routing refactor lands:
- Make the
Failed to switch model: message modal / toast-level rather than a system message that scrolls away into the buffer.
- Show the current model in the picker title bar (e.g.
Model Selector — current: anthropic/claude-opus-4-7) so users can verify the switch actually took effect.
Alternatively/additionally, ModelSelectorComponent.NewModelSelector could do a dry-run models.CreateProvider when filtering the list and grey out entries that can't actually be instantiated — that would prevent the misleading row from being selectable in the first place.
Workarounds users have today
- Use an
opencode/* model whose npm resolves to an already-handled wire:
opencode/kimi-k2.6, opencode/grok-*, etc. → @ai-sdk/openai-compatible ✅
opencode/claude-* → @ai-sdk/anthropic ✅
opencode/gpt-* → @ai-sdk/openai ✅
- Talk to Gemini directly:
AI_MODEL=google/gemini-3.5-flash + GOOGLE_GENERATIVE_AI_API_KEY=... (hits the native case "google": in the top-level switch at providers.go:268-291, which has nothing to do with the broken auto-route switch).
Happy to PR
Happy to send the refactor above as a PR if it's directionally what you want — or a smaller case "google":-only patch if you'd prefer to keep the diff minimal. Let me know which you'd prefer.
Summary
autoRouteProvider(ininternal/models/providers.go) silently fails for any model whose resolved npm package maps to a non-anthropic/openai/openaicompatLLM provider. This breaks every Gemini model proxied throughopencodetoday, and the same structural gap exists for five other entries in thenpmToLLMProvidertable.The TUI hits the same bug — the
/modelpicker silently swallows the failure and keeps you on the previous model, which is why this took a while to surface as a real bug report (it looked like Gemini-on-opencode was working when it wasn't).The proposed fix is a small refactor that aligns Kit's routing with fantasy's actual architecture: three wire protocols, not ten LLM providers.
Versions
v0.70.5(also reproduced onHEAD— commit1e12102"CLI & Config changes to support disabling core tools (CLI & Config changes to support disabling core tools #35)")charm.land/fantasy v0.25.0Reproduction
1. SDK (the consumer who originally hit the bug)
2. CLI — same failure
3. Why the TUI looks fine (it isn't)
The TUI's
/modelpicker shows five rows forgemini-3.5-flash(one per provider that registers it), distinguished only by a tiny grey[provider]description tag — easy to confuse:When you pick a broken row, the TUI calls
Kit.SetModel, which calls the samemodels.CreateProvider, which returns the same error. But the TUI handles it with a singleprintSystemMessageline in scrollback (internal/ui/model.go:1227-1228):So:
anthropic/claude-opus-4-7(or whatever their last working model was), not from Gemini.I confirmed this against my own
~/.config/kit/preferences.yml: it still showsmodel: anthropic/claude-opus-4-7even after picking opencode/gemini in the picker (becauseSaveModelPreferenceis only called inside theelsesuccess branch).Root cause
internal/models/modelsdb.go:54-65maps 10 npm packages:But
internal/models/providers.go:352-367only handles 3 of them:So
@ai-sdk/google,@ai-sdk/google-vertex,@ai-sdk/amazon-bedrock,@ai-sdk/azure,@openrouter/ai-sdk-provider,@ai-sdk/vercel, and@ai-sdk/google-vertex/anthropicall hit thedefaultbranch and die.This bites first on
opencodebecause every Gemini model underopencodehas a per-modelprovider.npm: "@ai-sdk/google"override inmodels.dev:These overrides override opencode's default
@ai-sdk/openai-compatible(which would otherwise have made everything Just Work via the openaicompat case), and route them into the dead end.Insight: fantasy is built on three wire protocols, not ten LLM providers
Looking at
charm.land/fantasy@v0.25.0/providers/:/v1/chat/completions,/v1/responses)providers/openaiproviders/openaicompat(no default URL + hooks),providers/vercel(defaulthttps://ai-gateway.vercel.sh/v1),providers/openrouter(defaulthttps://openrouter.ai/api/v1),providers/azure(with deployment routing)/v1/messages)providers/anthropicproviders/bedrock(= anthropic +WithBedrock()+ AWS auth); Vertex viaanthropic.WithVertex(...)generateContent)providers/googlegoogle.WithVertex(...)Look at how thin the OpenAI-wire wrappers are —
providers/vercel/vercel.goandproviders/openrouter/openrouter.goliterally just callopenai.New(...)with their respective default base URL prepended. Same shape forproviders/bedrock/bedrock.goagainstanthropic.New(...). The "10 LLM providers" innpmToLLMProviderare really 3 wire protocols + 7 pre-baked bundles.All three native providers expose
WithBaseURL:providers/openai/openai.go:69—func WithBaseURL(url string) Optionproviders/anthropic/anthropic.go:110—func WithBaseURL(baseURL string) Optionproviders/google/google.go:73—func WithBaseURL(baseURL string) OptionSo any proxy that forwards a re-flavored OpenAI/Anthropic/Google API just needs the native fantasy provider configured with the proxy's base URL. There's no need for kit to ship a separate per-provider auto-route helper for each entry in the npm table.
Empirical validation against
models.devScanning every per-model
provider.npmoverride across the entire registry, only four npm packages ever appear:@ai-sdk/anthropicopencode/claude-3-5-haiku,opencode/claude-haiku-4-5,opencode/claude-opus-4-1@ai-sdk/openaiopencode/gpt-5,opencode/gpt-5-codex,opencode/gpt-5-nano@ai-sdk/openai-compatiblecrof/deepseek-v4-flash,crof/deepseek-v4-pro@ai-sdk/googleopencode/gemini-3-flash,opencode/gemini-3-pro,opencode/gemini-3.1-pro,opencode/gemini-3.5-flashThe other six npm packages mapped in
npmToLLMProvider(google-vertex,google-vertex-anthropic,amazon-bedrock,azure,openrouter,vercel) never appear as per-model overrides on proxy providers. They only show up as the primarynpmof their canonical provider — and every one of those providers already has a native top-level case inCreateProvider(providers.go:268-291), so they never even reachautoRouteProvider.This means
autoRouteProvideronly ever needs to handle 4 npm packages, mapping to 3 wire protocols. The current npm-to-llmProvider table is unnecessarily wide and obscures this.Proposed fix
Replace
npmToLLMProviderwithnpmToWireProtocol, and switchautoRouteProvideron wire protocol instead of llmProvider name:And one new helper, parallel to the existing
createAutoRoutedAnthropicProvider(providers.go:411):isProviderLLMSupported(registry.go:401-414) needs the corresponding one-line update to consultnpmToWireProtocolinstead ofnpmToLLMProvider. The oldnpmToLLMProvidercan either be deleted or kept as a deprecated alias.Why this is better than a "just add
case "google":" patchReflects fantasy's actual architecture. Fantasy's package layout already says "there are three wire protocols and a bunch of bundles". The current
npmToLLMProvidermap flattens that distinction and the result is the 6-case routing gap. Switching on wire protocol makes the routing match the upstream model.Eliminates dead code paths. The current table maps 7 entries to llmProvider names the switch doesn't handle. They never reach auto-routing in practice (the top-level switch catches them first), but their presence makes the system look like it supports something it doesn't, and is exactly what tripped the original bug.
One new case per wire protocol, not per npm package. If
models.devadds@ai-sdk/some-new-anthropic-wrappertomorrow, the fix is one line innpmToWireProtocol, not a new helper function + switch case + tests.Better error message for the genuine "unknown" case. Instead of
(npm: @ai-sdk/google has no LLM provider mapping)— which is misleading because the mapping does exist, just no implementation — the error now says either "fell back to openai-compat (because we have a URL)" silently, or "no API URL, can't auto-route, use --provider-url" explicitly.Validates against real data. The empirical check above shows the wire-protocol model handles 100% of currently-observed per-model overrides in
models.dev(37+32+22+4 = 95 overrides, all four npm packages covered).Backwards compatibility
openaicompat,anthropic,openaicases each collapse cleanly into one wire protocol).default → openaicompat fallback if API URL presentbehaviour atproviders.go:347-350is preserved.anthropic,openai,google,azure,bedrock,openrouter,vercel,google-vertex-anthropic,ollama,custom) are untouched.Bonus — make the TUI surface this kind of failure properly
The silent fallback in
internal/ui/model.go:1227-1228is what made this look like a phantom bug for so long. Two small UX improvements would help future users regardless of whether the routing refactor lands:Failed to switch model:message modal / toast-level rather than a system message that scrolls away into the buffer.Model Selector — current: anthropic/claude-opus-4-7) so users can verify the switch actually took effect.Alternatively/additionally,
ModelSelectorComponent.NewModelSelectorcould do a dry-runmodels.CreateProviderwhen filtering the list and grey out entries that can't actually be instantiated — that would prevent the misleading row from being selectable in the first place.Workarounds users have today
opencode/*model whose npm resolves to an already-handled wire:opencode/kimi-k2.6,opencode/grok-*, etc. →@ai-sdk/openai-compatible✅opencode/claude-*→@ai-sdk/anthropic✅opencode/gpt-*→@ai-sdk/openai✅AI_MODEL=google/gemini-3.5-flash+GOOGLE_GENERATIVE_AI_API_KEY=...(hits the nativecase "google":in the top-level switch atproviders.go:268-291, which has nothing to do with the broken auto-route switch).Happy to PR
Happy to send the refactor above as a PR if it's directionally what you want — or a smaller
case "google":-only patch if you'd prefer to keep the diff minimal. Let me know which you'd prefer.