From 10abb29e4feeeef3ca839c4df1773ecb2acefffd Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 2 Jun 2026 15:07:07 +0300 Subject: [PATCH] fix(models): route auto-discovered providers by wire protocol (#41) - replace npmToLLMProvider map with npmToWireProtocol (openai/anthropic/google) - add createAutoRoutedGoogleProvider so @ai-sdk/google proxies work (fixes opencode/gemini-* failing with "no LLM provider mapping") - strip the genai-injected v1beta segment for proxies whose base URL already carries a version (e.g. opencode's /zen/v1) - preserve openai-compat fallback and clearer error for unroutable providers - document auto-routing in README and providers docs; update CreateProvider godoc - add regression tests for wire routing and version-path rewriting Fixes #41 --- README.md | 25 +++ internal/models/autoroute_test.go | 266 ++++++++++++++++++++++++++++++ internal/models/modelsdb.go | 38 +++-- internal/models/providers.go | 178 +++++++++++++++++--- internal/models/registry.go | 4 +- www/pages/providers.md | 33 ++++ 6 files changed, 505 insertions(+), 39 deletions(-) create mode 100644 internal/models/autoroute_test.go diff --git a/README.md b/README.md index f426e73d..79ee2181 100644 --- a/README.md +++ b/README.md @@ -972,6 +972,31 @@ This automatically defaults to `custom/custom` without needing to specify a mode - Reasoning and temperature support - Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag +### Auto-routed Providers + +Any provider in the [models.dev](https://models.dev) database can be used as +`provider/model` without a dedicated native integration. Kit auto-routes the +request through the matching **wire protocol** based on the provider's npm package +(or per-model override), using its `api` URL as the base: + +| npm package | Wire protocol | +|-------------|---------------| +| `@ai-sdk/openai` | OpenAI (Responses API) | +| `@ai-sdk/openai-compatible` | OpenAI (chat completions) | +| `@ai-sdk/anthropic` | Anthropic | +| `@ai-sdk/google` | Google Gemini | + +Providers with an `api` URL but an unrecognized npm package fall back to the +OpenAI-compatible wire. Because routing follows the wire protocol, aggregator/proxy +providers work across all of their models — including Claude, GPT, *and* Gemini +routes: + +```bash +kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire +kit --model opencode/gpt-5 "Hello" # → OpenAI wire +kit --model opencode/gemini-3.5-flash "Hello" # → Google wire +``` + ### Model String Format ```bash diff --git a/internal/models/autoroute_test.go b/internal/models/autoroute_test.go new file mode 100644 index 00000000..a463a8c1 --- /dev/null +++ b/internal/models/autoroute_test.go @@ -0,0 +1,266 @@ +package models + +import ( + "context" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +// TestNpmToWireProtocol documents the wire protocols that the auto-router +// understands. Provider-specific bundles (azure, bedrock, vercel, openrouter, +// google-vertex*) are intentionally absent — they have native top-level cases +// in CreateProvider and never reach the auto-router. +func TestNpmToWireProtocol(t *testing.T) { + want := map[string]wireProtocol{ + "@ai-sdk/openai": wireOpenAI, + "@ai-sdk/openai-compatible": wireOpenAI, + "@ai-sdk/anthropic": wireAnthropic, + "@ai-sdk/google": wireGoogle, + } + for npm, wire := range want { + if got := npmToWireProtocol[npm]; got != wire { + t.Errorf("npmToWireProtocol[%q] = %d, want %d", npm, got, wire) + } + } + + // Bundle packages must NOT be in the table (regression guard against the + // old npmToLLMProvider map that listed 10 entries but only handled 3). + for _, npm := range []string{ + "@ai-sdk/google-vertex", + "@ai-sdk/google-vertex/anthropic", + "@ai-sdk/amazon-bedrock", + "@ai-sdk/azure", + "@openrouter/ai-sdk-provider", + "@ai-sdk/vercel", + } { + if _, ok := npmToWireProtocol[npm]; ok { + t.Errorf("npmToWireProtocol unexpectedly contains bundle package %q", npm) + } + } +} + +// newTestRegistry builds a registry containing a single proxy-style provider +// ("testproxy") with the given default npm, plus one model that carries the +// given per-model npm override. +func newTestRegistry(api, defaultNPM, modelID, modelNPMOverride string) *ModelsRegistry { + return &ModelsRegistry{ + providers: map[string]ProviderInfo{ + "testproxy": { + ID: "testproxy", + Name: "Test Proxy", + Env: []string{"TESTPROXY_API_KEY"}, + NPM: defaultNPM, + API: api, + Models: map[string]ModelInfo{ + modelID: { + ID: modelID, + Name: modelID, + ProviderNPM: modelNPMOverride, + }, + }, + }, + }, + } +} + +// TestAutoRouteProvider_WireRouting verifies that autoRouteProvider routes each +// npm package to the correct fantasy provider implementation. This is the core +// regression test for issue #41: previously any npm that resolved to a +// non-openai/anthropic/openaicompat LLM provider (notably @ai-sdk/google) hit a +// dead `default` branch and failed with "has no LLM provider mapping". +func TestAutoRouteProvider_WireRouting(t *testing.T) { + tests := []struct { + name string + modelID string + defaultNPM string + overrideNPM string + // wantType is the concrete fantasy LanguageModel type the model should + // be routed to, identified by reflect type string. + wantType string + }{ + { + name: "openai-compatible default", + modelID: "test-model", + defaultNPM: "@ai-sdk/openai-compatible", + wantType: "openai.languageModel", + }, + { + name: "anthropic override", + modelID: "test-model", + defaultNPM: "@ai-sdk/openai-compatible", + overrideNPM: "@ai-sdk/anthropic", + wantType: "anthropic.languageModel", + }, + { + name: "openai (responses) override", + modelID: "gpt-4o", + defaultNPM: "@ai-sdk/openai-compatible", + overrideNPM: "@ai-sdk/openai", + wantType: "openai.responsesLanguageModel", + }, + { + // The bug: opencode's gemini-* models override the default + // openai-compatible npm with @ai-sdk/google. + name: "google override (issue #41)", + modelID: "gemini-3.5-flash", + defaultNPM: "@ai-sdk/openai-compatible", + overrideNPM: "@ai-sdk/google", + wantType: "*google.languageModel", + }, + { + // Unknown npm but provider has an API URL → openai-compatible fallback. + name: "unknown npm with API URL falls back to openai-compat", + modelID: "test-model", + defaultNPM: "@ai-sdk/some-future-thing", + wantType: "openai.languageModel", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := newTestRegistry("https://proxy.example/v1", tt.defaultNPM, tt.modelID, tt.overrideNPM) + config := &ProviderConfig{ProviderAPIKey: "test-key"} + + result, err := autoRouteProvider(context.Background(), config, "testproxy", tt.modelID, reg) + if err != nil { + t.Fatalf("autoRouteProvider returned error: %v", err) + } + if result == nil || result.Model == nil { + t.Fatalf("autoRouteProvider returned nil model") + } + + gotType := reflect.TypeOf(result.Model).String() + if gotType != tt.wantType { + t.Errorf("routed to %s, want %s", gotType, tt.wantType) + } + }) + } +} + +// TestAutoRouteProvider_UnknownNpmNoAPI verifies the improved error message for +// a provider whose npm has no known wire protocol and that has no API URL to +// fall back on. +func TestAutoRouteProvider_UnknownNpmNoAPI(t *testing.T) { + reg := newTestRegistry("", "@ai-sdk/unmapped", "test-model", "") + config := &ProviderConfig{ProviderAPIKey: "test-key"} + + _, err := autoRouteProvider(context.Background(), config, "testproxy", "test-model", reg) + if err == nil { + t.Fatal("expected error for unknown npm with no API URL, got nil") + } + if !strings.Contains(err.Error(), "cannot auto-route provider testproxy") { + t.Errorf("unexpected error message: %v", err) + } + if !strings.Contains(err.Error(), "--provider-url") { + t.Errorf("error should suggest --provider-url, got: %v", err) + } +} + +// TestAutoRouteProvider_UnknownProvider verifies the not-in-database error. +func TestAutoRouteProvider_UnknownProvider(t *testing.T) { + reg := newTestRegistry("https://proxy.example/v1", "@ai-sdk/openai-compatible", "test-model", "") + config := &ProviderConfig{ProviderAPIKey: "test-key"} + + _, err := autoRouteProvider(context.Background(), config, "does-not-exist", "test-model", reg) + if err == nil { + t.Fatal("expected error for unknown provider, got nil") + } + if !strings.Contains(err.Error(), "not found in model database") { + t.Errorf("unexpected error message: %v", err) + } +} + +// TestIsProviderLLMSupported_Google verifies that a provider whose npm is +// @ai-sdk/google is reported as supported (it now maps to a wire protocol). +func TestIsProviderLLMSupported_Google(t *testing.T) { + info := &ProviderInfo{ID: "testproxy", NPM: "@ai-sdk/google"} + if !isProviderLLMSupported("testproxy", info) { + t.Error("expected @ai-sdk/google provider to be LLM-supported") + } +} + +// TestVersionedBasePath verifies detection of proxy base URLs that already +// carry an API version segment (which collides with the genai SDK's injected +// version). +func TestVersionedBasePath(t *testing.T) { + tests := []struct { + rawURL string + want string + }{ + {"https://opencode.ai/zen/v1", "/zen/v1"}, + {"https://opencode.ai/zen/v1/", "/zen/v1"}, + {"https://example.com/api/v1beta", "/api/v1beta"}, + {"https://example.com/api/v2alpha", "/api/v2alpha"}, + {"https://generativelanguage.googleapis.com", ""}, + {"https://proxy.example/openai", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := versionedBasePath(tt.rawURL); got != tt.want { + t.Errorf("versionedBasePath(%q) = %q, want %q", tt.rawURL, got, tt.want) + } + } +} + +// recordingRoundTripper captures the path of the request it receives. +type recordingRoundTripper struct{ gotPath string } + +func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.gotPath = req.URL.Path + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("{}")), + Header: make(http.Header), + }, nil +} + +// TestGeminiProxyTransport_StripsInjectedVersion verifies that the transport +// collapses the genai-injected "/v1beta" segment that follows a proxy base +// URL which already carries its own version segment. This is the second-order +// fix that makes opencode/gemini-* actually reach the proxy (issue #41). +func TestGeminiProxyTransport_StripsInjectedVersion(t *testing.T) { + tests := []struct { + name string + basePath string + reqPath string + wantPath string + }{ + { + name: "strips doubled v1beta after /zen/v1", + basePath: "/zen/v1", + reqPath: "/zen/v1/v1beta/models/gemini-3.5-flash:generateContent", + wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent", + }, + { + name: "strips doubled v1beta1 after /zen/v1", + basePath: "/zen/v1", + reqPath: "/zen/v1/v1beta1/models/gemini-3.5-flash:generateContent", + wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent", + }, + { + name: "leaves non-matching path untouched", + basePath: "/zen/v1", + reqPath: "/other/v1beta/models/x:generateContent", + wantPath: "/other/v1beta/models/x:generateContent", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := &recordingRoundTripper{} + tr := &geminiProxyTransport{base: rec, basePath: tt.basePath} + req, err := http.NewRequest(http.MethodPost, "https://host"+tt.reqPath, nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if _, err := tr.RoundTrip(req); err != nil { + t.Fatalf("RoundTrip: %v", err) + } + if rec.gotPath != tt.wantPath { + t.Errorf("forwarded path = %q, want %q", rec.gotPath, tt.wantPath) + } + }) + } +} diff --git a/internal/models/modelsdb.go b/internal/models/modelsdb.go index 2b412c1f..72322dd0 100644 --- a/internal/models/modelsdb.go +++ b/internal/models/modelsdb.go @@ -48,18 +48,28 @@ type modelsDBLimit struct { Output int `json:"output"` } -// npmToLLMProvider maps npm package names from models.dev to LLM -// provider identifiers. Providers not in this map but with an api URL -// can be auto-routed through openaicompat. -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", +// wireProtocol identifies which LLM API protocol an npm package speaks. +// Fantasy implements three native protocols (openai, anthropic, google); +// everything else in its providers/ tree 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. Providers not in this map but with an api URL +// are auto-routed through the OpenAI-compatible wire. +var npmToWireProtocol = map[string]wireProtocol{ + "@ai-sdk/openai": wireOpenAI, + "@ai-sdk/openai-compatible": wireOpenAI, + "@ai-sdk/anthropic": wireAnthropic, + "@ai-sdk/google": wireGoogle, } diff --git a/internal/models/providers.go b/internal/models/providers.go index d79858d6..415a7a41 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -9,7 +9,9 @@ import ( "io" "maps" "net/http" + "net/url" "os" + "regexp" "strings" "time" @@ -220,8 +222,10 @@ func ParseModelString(modelString string) (provider, model string, err error) { // // Native providers: anthropic, openai, google, ollama, azure, google-vertex-anthropic, // openrouter, bedrock, vercel. -// Any provider in models.dev with an api URL or openai-compatible npm package -// is auto-routed through fantasy's openaicompat provider. +// Any other provider in models.dev is auto-routed by wire protocol: its npm +// package (or per-model override) selects the OpenAI, Anthropic, or Google +// transport, using the provider's api URL as the base. Providers with an api +// URL but an unrecognized npm package fall back to the OpenAI-compatible wire. func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResult, error) { provider, modelName, err := ParseModelString(config.ModelString) if err != nil { @@ -335,43 +339,62 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul // autoRouteProvider attempts to create a provider by looking up its npm package // in the models.dev database and routing through the appropriate fantasy provider. -// For openai-compatible providers, it uses the api URL from models.dev. -// Models may have a provider override that specifies a different npm package than -// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic). +// It routes on wire protocol (openai, anthropic, google) rather than per-npm +// provider name: fantasy implements three native wire protocols, and every other +// entry in its providers/ tree is a thin wrapper around one of them. Using the +// provider's api URL from models.dev as the base URL, any proxy that re-flavors +// one of these protocols (e.g. opencode's Gemini routes) Just Works. +// +// Models may carry a provider override that specifies a different npm package +// than the provider's default (e.g. opencode's claude-* uses @ai-sdk/anthropic +// and its gemini-* uses @ai-sdk/google), which is resolved first. 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) } - // Check for model-specific provider override + // Resolve npm: per-model override > provider default. npmPackage := providerInfo.NPM if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" { npmPackage = modelInfo.ProviderNPM } - // Determine the LLM provider for this npm package - llmProvider := npmToLLMProvider[npmPackage] - if llmProvider == "" && providerInfo.API != "" { - // Unknown npm but has API URL → route through openaicompat - llmProvider = "openaicompat" + wire, known := npmToWireProtocol[npmPackage] + if !known { + // Unknown npm but the provider has an API URL → assume OpenAI-compatible. + // (Preserves the long-standing "any provider in models.dev with an api URL + // is auto-routed through openaicompat" behaviour.) + 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 } - switch llmProvider { - case "openaicompat": + // 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: + // The native OpenAI SDK package (@ai-sdk/openai) speaks the Responses + // API; openai-compatible proxies (and unknown-npm fallbacks) use the + // chat-completions wire via fantasy's openaicompat provider. + if npmPackage == "@ai-sdk/openai" { + return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo) + } return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo) - case "anthropic": - if config.ProviderURL == "" && providerInfo.API != "" { - config.ProviderURL = providerInfo.API - } + case wireAnthropic: return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo) - case "openai": - if config.ProviderURL == "" && providerInfo.API != "" { - config.ProviderURL = providerInfo.API - } - return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo) + case wireGoogle: + return createAutoRoutedGoogleProvider(ctx, config, modelName, providerInfo) default: - return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage) + return nil, fmt.Errorf("internal error: unknown wire protocol for provider %s (npm: %s)", provider, npmPackage) } } @@ -488,6 +511,115 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil } +// createAutoRoutedGoogleProvider creates a Google (Gemini) provider for +// third-party providers that expose a Gemini-compatible API (e.g. opencode's +// Gemini routes, which carry an @ai-sdk/google per-model override). +// +// The underlying genai SDK always injects its own API version segment +// ("v1beta") between the base URL and the resource path. When the proxy's +// base URL from models.dev already carries a version segment (e.g. opencode's +// https://opencode.ai/zen/v1), that produces a doubled ".../v1/v1beta/..." +// path that the proxy rejects. In that case we install a transport that +// strips the injected segment so the proxy's own version is used. +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)) + } + + // Decide whether the genai-injected version segment needs stripping. + var httpClient *http.Client + if basePath := versionedBasePath(config.ProviderURL); basePath != "" { + httpClient = newGeminiProxyHTTPClient(basePath, config.TLSSkipVerify) + } else if config.TLSSkipVerify { + httpClient = createHTTPClientWithTLSConfig(true) + } + if httpClient != nil { + opts = append(opts, google.WithHTTPClient(httpClient)) + } + + 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 +} + +// versionSegmentRe matches a trailing API version segment in a URL path, +// e.g. "/v1", "/v1beta", "/v1beta1", "/v2alpha". +var versionSegmentRe = regexp.MustCompile(`/v\d+(?:beta\d*|alpha\d*)?$`) + +// versionedBasePath returns the path component of rawURL when that path ends +// with an API version segment (e.g. opencode's ".../zen/v1" → "/zen/v1"). +// It returns "" when rawURL is empty, unparseable, or has no version suffix +// — in which case the genai SDK's default version injection is correct and +// no rewriting is needed. +func versionedBasePath(rawURL string) string { + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + path := strings.TrimSuffix(u.Path, "/") + if versionSegmentRe.MatchString(path) { + return path + } + return "" +} + +// newGeminiProxyHTTPClient builds an HTTP client whose transport strips the +// genai-injected version segment ("v1beta"/"v1beta1") that directly follows +// basePath, collapsing "{basePath}/v1beta/..." back to "{basePath}/...". +func newGeminiProxyHTTPClient(basePath string, skipVerify bool) *http.Client { + var base http.RoundTripper + if skipVerify { + base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } else { + base = http.DefaultTransport + } + return &http.Client{ + Transport: &geminiProxyTransport{base: base, basePath: basePath}, + } +} + +// geminiProxyTransport removes the redundant API version segment that the +// genai SDK injects after a proxy base URL that already carries its own +// version segment. +type geminiProxyTransport struct { + base http.RoundTripper + basePath string +} + +func (t *geminiProxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for _, injected := range []string{"/v1beta1", "/v1beta"} { + prefix := t.basePath + injected + "/" + if strings.HasPrefix(req.URL.Path, prefix) { + newReq := req.Clone(req.Context()) + newReq.URL.Path = t.basePath + strings.TrimPrefix(req.URL.Path, t.basePath+injected) + return t.base.RoundTrip(newReq) + } + } + return t.base.RoundTrip(req) +} + // resolveAPIKey returns the first non-empty API key from the explicit key // or the environment variables. func resolveAPIKey(explicitKey string, envVars []string) string { diff --git a/internal/models/registry.go b/internal/models/registry.go index f3a6b6e2..7a1b0997 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -404,8 +404,8 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool { return true } - // Check if npm maps to an LLM provider - if _, ok := npmToLLMProvider[info.NPM]; ok { + // Check if npm maps to a known wire protocol + if _, ok := npmToWireProtocol[info.NPM]; ok { return true } diff --git a/www/pages/providers.md b/www/pages/providers.md index 2103c9a1..8b028a06 100644 --- a/www/pages/providers.md +++ b/www/pages/providers.md @@ -143,6 +143,39 @@ The `custom/custom` model has zero cost, 262K context window, and supports reaso Optionally set `CUSTOM_API_KEY` environment variable or use `--provider-api-key` for endpoints requiring authentication. +## Auto-routed providers + +Any provider in the [models.dev](https://models.dev) database can be used with the +standard `provider/model` format, even without a dedicated native integration. Kit +auto-routes the request through the matching **wire protocol** — the actual API +shape the provider speaks — rather than requiring a per-provider code path: + +| Wire protocol | npm package (models.dev) | Transport used | +|---------------|--------------------------|----------------| +| OpenAI (Responses API) | `@ai-sdk/openai` | OpenAI | +| OpenAI (chat completions) | `@ai-sdk/openai-compatible` | OpenAI-compatible | +| Anthropic | `@ai-sdk/anthropic` | Anthropic | +| Google Gemini | `@ai-sdk/google` | Google | + +The provider's `api` URL from the database is used as the base URL. A provider +whose npm package isn't recognized but that has an `api` URL falls back to the +OpenAI-compatible wire. + +Because routing follows the wire protocol, aggregator/proxy providers work across +**all** of their models — including ones they re-flavor onto a different protocol +via a per-model override. For example, an aggregator that proxies Claude, GPT, +*and* Gemini routes them to the Anthropic, OpenAI, and Google transports +respectively: + +```bash +kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire +kit --model opencode/gpt-5 "Hello" # → OpenAI wire +kit --model opencode/gemini-3.5-flash "Hello" # → Google wire +``` + +Provide the provider's API key the same way as any other — via its environment +variable (e.g. `OPENCODE_API_KEY`) or `--provider-api-key`. + ## Model database Kit ships with a local model database that maps provider names to API configurations. You can manage it with: