Skip to content

autoRouteProvider fails for any non-OpenAI/Anthropic npm mapping (breaks opencode/gemini-*, plus 5 other providers); TUI hides the error #41

Description

@ezynda3

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:

  1. The status bar still shows the previous (working) model.
  2. Subsequent prompts go to the previous model.
  3. 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:69func WithBaseURL(url string) Option
  • providers/anthropic/anthropic.go:110func WithBaseURL(baseURL string) Option
  • providers/google/google.go:73func 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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:

  1. Make the Failed to switch model: message modal / toast-level rather than a system message that scrolls away into the buffer.
  2. 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

  1. 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
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions