From 4c268420585e745f3abf35e2daa1b3b551ad0958 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 10 Jul 2026 15:26:55 +0300 Subject: [PATCH 1/4] Add explicit wire protocol declarations for providers Providers previously had their wire protocol (openai, anthropic, google) inferred solely from the models.dev npm-package heuristic, with no escape hatch besides --provider-url (which forces the OpenAI-compatible wire). This adopts the explicit provider-type design from charmbracelet/catwalk without switching data sources: - ProviderInfo gains Wire and Headers fields; the wireProtocol enum splits wireOpenAI (Responses API) from wireOpenAICompat (chat completions), removing the npm=="@ai-sdk/openai" dispatch special case - New `providers` config section declares or patches providers: wire, baseUrl, apiKeyEnv, headers, name. Unknown IDs register fresh, so internal gateways work without a database entry - New --provider-wire flag (also kit.Options.ProviderWire, inherited by subagents); with --provider-url it synthesizes unknown providers on the fly and skips the custom/ openai-compat rewrite - Wire resolution precedence: --provider-wire > providers config > npm heuristic > api-URL openaicompat fallback (unchanged long tail) - Per-provider default headers ride an http.RoundTripper wrapper on all four auto-routed constructors, composing with TLS and gemini-proxy transports --- README.md | 24 ++ cmd/root.go | 12 + internal/kitsetup/setup.go | 1 + internal/models/autoroute_test.go | 26 +- internal/models/modelsdb.go | 73 +++- internal/models/provider_overrides.go | 113 ++++++ internal/models/provider_overrides_test.go | 381 +++++++++++++++++++++ internal/models/providers.go | 146 ++++++-- internal/models/registry.go | 33 +- pkg/kit/kit.go | 12 + www/pages/providers.md | 46 +++ 11 files changed, 801 insertions(+), 66 deletions(-) create mode 100644 internal/models/provider_overrides.go create mode 100644 internal/models/provider_overrides_test.go diff --git a/README.md b/README.md index dbdf1154..dd593049 100644 --- a/README.md +++ b/README.md @@ -1163,6 +1163,30 @@ kit --model opencode/gpt-5 "Hello" # → OpenAI wire kit --model opencode/gemini-3.5-flash "Hello" # → Google wire ``` +### Provider Overrides + +Declare or patch providers in your config file's `providers` section — fix a +wire protocol the database gets wrong, or define an internal gateway the +database doesn't know about: + +```yaml +# ~/.kit.yml +providers: + minimax: # patch a known provider + wire: anthropic + corp-llm: # declare a new one + wire: anthropic + baseUrl: https://llm.internal.corp/api + apiKeyEnv: [CORP_LLM_KEY] + headers: + X-Team: platform +``` + +Accepted `wire` values: `openai` (Responses API), `openai-compat` (chat +completions), `anthropic`, `google`. Unset fields inherit database values. +For one-off use, `--provider-wire` with `--provider-url` routes any +`provider/model` through the declared wire without a config entry. + ### Model String Format ```bash diff --git a/cmd/root.go b/cmd/root.go index a3c5bcf2..8aa979b1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,6 +33,7 @@ var ( modelFlag string providerURL string providerAPIKey string + providerWire string debugMode bool positionalPrompt string // set by processPositionalArgs from CLI positional args positionalFiles []ui.FilePart // binary @file parts from processPositionalArgs @@ -314,6 +315,7 @@ func init() { flags := rootCmd.PersistentFlags() flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)") flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)") + flags.StringVar(&providerWire, "provider-wire", "", "wire protocol for auto-routed providers: openai, openai-compat, anthropic, google (overrides the model database)") flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)") // Prompt template flags @@ -345,6 +347,7 @@ func init() { _ = viper.BindPFlag("provider-url", rootCmd.PersistentFlags().Lookup("provider-url")) _ = viper.BindPFlag("provider-api-key", rootCmd.PersistentFlags().Lookup("provider-api-key")) + _ = viper.BindPFlag("provider-wire", rootCmd.PersistentFlags().Lookup("provider-wire")) _ = viper.BindPFlag("max-tokens", rootCmd.PersistentFlags().Lookup("max-tokens")) _ = viper.BindPFlag("temperature", rootCmd.PersistentFlags().Lookup("temperature")) _ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p")) @@ -764,6 +767,15 @@ func applyProviderURLRouting() { return } + // When --provider-wire is set alongside --provider-url the user is being + // explicit about the wire protocol; the custom/ rewrite below would force + // the OpenAI-compatible wire, so skip it and let the model's provider + // prefix route through the auto-router (which honors --provider-wire and + // synthesizes unknown providers when both flags are present). + if viper.GetString("provider-wire") != "" { + return + } + // When --provider-url is set but no explicit --model was provided, // default to "custom/custom" so the user doesn't need to remember a // provider/model pair for custom OpenAI-compatible endpoints. diff --git a/internal/kitsetup/setup.go b/internal/kitsetup/setup.go index 9be02436..fb7ef03a 100644 --- a/internal/kitsetup/setup.go +++ b/internal/kitsetup/setup.go @@ -126,6 +126,7 @@ func BuildProviderConfig(v *viper.Viper) (*models.ProviderConfig, string, error) SystemPrompt: systemPrompt, ProviderAPIKey: v.GetString("provider-api-key"), ProviderURL: v.GetString("provider-url"), + ProviderWire: v.GetString("provider-wire"), MaxTokens: v.GetInt("max-tokens"), StopSequences: v.GetStringSlice("stop-sequences"), NumGPU: &numGPU, diff --git a/internal/models/autoroute_test.go b/internal/models/autoroute_test.go index 0871103d..f53069dd 100644 --- a/internal/models/autoroute_test.go +++ b/internal/models/autoroute_test.go @@ -17,24 +17,24 @@ import ( func TestNpmToWireProtocol(t *testing.T) { want := map[string]wireProtocol{ "@ai-sdk/openai": wireOpenAI, - "@ai-sdk/openai-compatible": wireOpenAI, + "@ai-sdk/openai-compatible": wireOpenAICompat, "@ai-sdk/anthropic": wireAnthropic, "@ai-sdk/google": wireGoogle, // Thin OpenAI-compatible wrappers — routed via openaicompat using // the SDK's hard-coded default base URL (sdkDefaultBaseURL). - "@ai-sdk/groq": wireOpenAI, - "@ai-sdk/cerebras": wireOpenAI, - "@ai-sdk/perplexity": wireOpenAI, - "@ai-sdk/togetherai": wireOpenAI, - "@ai-sdk/xai": wireOpenAI, - "@ai-sdk/deepinfra": wireOpenAI, - "@ai-sdk/mistral": wireOpenAI, - "@ai-sdk/cohere": wireOpenAI, - "@ai-sdk/vercel": wireOpenAI, - "@aihubmix/ai-sdk-provider": wireOpenAI, - "venice-ai-sdk-provider": wireOpenAI, - "merge-gateway-ai-sdk-provider": wireOpenAI, + "@ai-sdk/groq": wireOpenAICompat, + "@ai-sdk/cerebras": wireOpenAICompat, + "@ai-sdk/perplexity": wireOpenAICompat, + "@ai-sdk/togetherai": wireOpenAICompat, + "@ai-sdk/xai": wireOpenAICompat, + "@ai-sdk/deepinfra": wireOpenAICompat, + "@ai-sdk/mistral": wireOpenAICompat, + "@ai-sdk/cohere": wireOpenAICompat, + "@ai-sdk/vercel": wireOpenAICompat, + "@aihubmix/ai-sdk-provider": wireOpenAICompat, + "venice-ai-sdk-provider": wireOpenAICompat, + "merge-gateway-ai-sdk-provider": wireOpenAICompat, } for npm, wire := range want { if got := npmToWireProtocol[npm]; got != wire { diff --git a/internal/models/modelsdb.go b/internal/models/modelsdb.go index c8862e27..88f30b05 100644 --- a/internal/models/modelsdb.go +++ b/internal/models/modelsdb.go @@ -1,5 +1,7 @@ package models +import "strings" + // ModelsDBProviders is the top-level type for models.dev/api.json data: // a map of provider ID → provider object. type ModelsDBProviders = map[string]modelsDBProvider @@ -48,19 +50,56 @@ type modelsDBLimit struct { Output int `json:"output"` } -// wireProtocol identifies which LLM API protocol an npm package speaks. +// wireProtocol identifies which LLM API protocol a provider 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. +// them with a pre-baked default URL or auth scheme. The OpenAI protocol is +// split into two wires: wireOpenAI (the Responses API, spoken only by +// api.openai.com and true mirrors of it) and wireOpenAICompat (the +// chat-completions wire spoken by virtually every OpenAI-compatible +// provider and proxy). type wireProtocol int const ( - wireUnknown wireProtocol = iota - wireOpenAI + wireUnknown wireProtocol = iota + wireOpenAI // OpenAI Responses API + wireOpenAICompat // OpenAI chat-completions API wireAnthropic wireGoogle ) +// Wire protocol names accepted in provider overrides (config `providers` +// section) and the --provider-wire flag. These are the user-facing, +// dependency-agnostic names for the wire protocols. +const ( + WireNameOpenAI = "openai" + WireNameOpenAICompat = "openai-compat" + WireNameAnthropic = "anthropic" + WireNameGoogle = "google" +) + +// parseWire maps a user-facing wire protocol name to its wireProtocol value. +// Accepts a few common aliases. Returns (wireUnknown, false) for empty or +// unrecognized names. +func parseWire(name string) (wireProtocol, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case WireNameOpenAI, "openai-responses": + return wireOpenAI, true + case WireNameOpenAICompat, "openai-compatible", "openai-chat": + return wireOpenAICompat, true + case WireNameAnthropic: + return wireAnthropic, true + case WireNameGoogle, "gemini": + return wireGoogle, true + } + return wireUnknown, false +} + +// wireNames lists the accepted canonical wire names for error messages. +func wireNames() string { + return strings.Join([]string{WireNameOpenAI, WireNameOpenAICompat, WireNameAnthropic, WireNameGoogle}, ", ") +} + // npmToWireProtocol maps npm package names from models.dev to the wire // protocol they speak. Provider-specific bundles that need bespoke auth or // URL templating (azure, bedrock, openrouter, google-vertex, google-vertex- @@ -75,7 +114,7 @@ const ( var npmToWireProtocol = map[string]wireProtocol{ // Native wires. "@ai-sdk/openai": wireOpenAI, - "@ai-sdk/openai-compatible": wireOpenAI, + "@ai-sdk/openai-compatible": wireOpenAICompat, "@ai-sdk/anthropic": wireAnthropic, "@ai-sdk/google": wireGoogle, @@ -83,18 +122,18 @@ var npmToWireProtocol = map[string]wireProtocol{ // in its JS SDK (see sdkDefaultBaseURL) but speaks the plain OpenAI chat // completions wire — so we can route them all through fantasy's // openaicompat provider once we supply the URL. - "@ai-sdk/groq": wireOpenAI, - "@ai-sdk/cerebras": wireOpenAI, - "@ai-sdk/perplexity": wireOpenAI, - "@ai-sdk/togetherai": wireOpenAI, - "@ai-sdk/xai": wireOpenAI, - "@ai-sdk/deepinfra": wireOpenAI, - "@ai-sdk/mistral": wireOpenAI, - "@ai-sdk/cohere": wireOpenAI, - "@ai-sdk/vercel": wireOpenAI, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway - "@aihubmix/ai-sdk-provider": wireOpenAI, - "venice-ai-sdk-provider": wireOpenAI, - "merge-gateway-ai-sdk-provider": wireOpenAI, + "@ai-sdk/groq": wireOpenAICompat, + "@ai-sdk/cerebras": wireOpenAICompat, + "@ai-sdk/perplexity": wireOpenAICompat, + "@ai-sdk/togetherai": wireOpenAICompat, + "@ai-sdk/xai": wireOpenAICompat, + "@ai-sdk/deepinfra": wireOpenAICompat, + "@ai-sdk/mistral": wireOpenAICompat, + "@ai-sdk/cohere": wireOpenAICompat, + "@ai-sdk/vercel": wireOpenAICompat, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway + "@aihubmix/ai-sdk-provider": wireOpenAICompat, + "venice-ai-sdk-provider": wireOpenAICompat, + "merge-gateway-ai-sdk-provider": wireOpenAICompat, } // sdkDefaultBaseURL maps an npm package name to the base URL its JavaScript diff --git a/internal/models/provider_overrides.go b/internal/models/provider_overrides.go new file mode 100644 index 00000000..9743ce88 --- /dev/null +++ b/internal/models/provider_overrides.go @@ -0,0 +1,113 @@ +package models + +import ( + "fmt" + "os" + + "github.com/spf13/viper" +) + +// ProviderOverrideConfig declares or patches a provider in the model registry +// via the `providers` config section. All fields are optional; non-empty +// fields override the corresponding registry values (which come from the +// models.dev database). A provider ID that is not in the database is +// registered fresh, so users can declare entirely new providers (e.g. +// internal LLM gateways) without touching the database: +// +// providers: +// # Patch the wire protocol of a known provider +// minimax: +// wire: anthropic +// +// # Declare a brand-new provider +// corp-llm: +// name: "Corp LLM Gateway" +// wire: anthropic +// baseUrl: https://llm.internal.corp/api +// apiKeyEnv: [CORP_LLM_KEY, LLM_GATEWAY_KEY] +// headers: +// X-Team: platform +type ProviderOverrideConfig struct { + // Name is the human-readable provider name. + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Wire is the wire protocol this provider speaks: "openai" (Responses + // API), "openai-compat" (chat completions), "anthropic", or "google". + Wire string `json:"wire,omitempty" yaml:"wire,omitempty"` + // BaseURL is the provider's API base URL. + BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"` + // APIKeyEnv lists environment variable names to try (in order) when + // resolving the API key. + APIKeyEnv []string `json:"apiKeyEnv,omitempty" yaml:"apiKeyEnv,omitempty"` + // Headers are default HTTP headers added to every request. + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` +} + +// loadProviderOverridesFromConfig loads the `providers` config section from +// the process-global viper store (the model registry is a process-global +// singleton, mirroring loadCustomModelsFromConfig). +func loadProviderOverridesFromConfig() map[string]ProviderOverrideConfig { + return loadProviderOverridesFrom(viper.GetViper()) +} + +// loadProviderOverridesFrom loads provider overrides from the supplied store. +// When v is nil the process-global store is used. Returns nil when the +// `providers` key is absent or malformed. +func loadProviderOverridesFrom(v *viper.Viper) map[string]ProviderOverrideConfig { + if v == nil { + v = viper.GetViper() + } + if !v.IsSet("providers") { + return nil + } + + var overrides map[string]ProviderOverrideConfig + if err := v.UnmarshalKey("providers", &overrides); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to parse providers config: %v\n", err) + return nil + } + return overrides +} + +// applyProviderOverrides merges provider overrides into the registry provider +// map. Non-empty override fields replace the registry values; unset fields +// inherit whatever the database (or special-case injection) provided. IDs not +// already present are registered as new providers with an empty model map, +// like ollama — model lookups are advisory for auto-routed providers. +func applyProviderOverrides(providers map[string]ProviderInfo, overrides map[string]ProviderOverrideConfig) { + for id, o := range overrides { + if o.Wire != "" { + if _, ok := parseWire(o.Wire); !ok { + fmt.Fprintf(os.Stderr, "Warning: provider %q has unknown wire %q (expected one of: %s); ignoring override\n", + id, o.Wire, wireNames()) + continue + } + } + + info, exists := providers[id] + if !exists { + info = ProviderInfo{ + ID: id, + Name: id, + Models: make(map[string]ModelInfo), + } + } + + if o.Name != "" { + info.Name = o.Name + } + if o.Wire != "" { + info.Wire = o.Wire + } + if o.BaseURL != "" { + info.API = o.BaseURL + } + if len(o.APIKeyEnv) > 0 { + info.Env = o.APIKeyEnv + } + if len(o.Headers) > 0 { + info.Headers = o.Headers + } + + providers[id] = info + } +} diff --git a/internal/models/provider_overrides_test.go b/internal/models/provider_overrides_test.go new file mode 100644 index 00000000..bc034551 --- /dev/null +++ b/internal/models/provider_overrides_test.go @@ -0,0 +1,381 @@ +package models + +import ( + "context" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// TestParseWire documents the accepted wire protocol names and aliases. +func TestParseWire(t *testing.T) { + tests := []struct { + name string + want wireProtocol + ok bool + }{ + {"openai", wireOpenAI, true}, + {"openai-responses", wireOpenAI, true}, + {"openai-compat", wireOpenAICompat, true}, + {"openai-compatible", wireOpenAICompat, true}, + {"openai-chat", wireOpenAICompat, true}, + {"anthropic", wireAnthropic, true}, + {"google", wireGoogle, true}, + {"gemini", wireGoogle, true}, + {"Anthropic", wireAnthropic, true}, // case-insensitive + {" anthropic ", wireAnthropic, true}, // trimmed + {"", wireUnknown, false}, + {"bogus", wireUnknown, false}, + } + for _, tt := range tests { + got, ok := parseWire(tt.name) + if got != tt.want || ok != tt.ok { + t.Errorf("parseWire(%q) = (%d, %v), want (%d, %v)", tt.name, got, ok, tt.want, tt.ok) + } + } +} + +// newWireTestRegistry builds a registry with a single provider carrying an +// explicit Wire declaration (as produced by a `providers` config override). +func newWireTestRegistry(wire, api string) *ModelsRegistry { + return &ModelsRegistry{ + providers: map[string]ProviderInfo{ + "wireproxy": { + ID: "wireproxy", + Name: "Wire Proxy", + Env: []string{"WIREPROXY_API_KEY"}, + API: api, + Wire: wire, + Models: map[string]ModelInfo{}, + }, + }, + } +} + +// TestAutoRouteProvider_ExplicitWire verifies that a provider-level Wire +// declaration routes to the right fantasy implementation without any npm +// package information. +func TestAutoRouteProvider_ExplicitWire(t *testing.T) { + tests := []struct { + wire string + model string + wantType string + }{ + {"openai", "gpt-4o", "openai.responsesLanguageModel"}, + {"openai-compat", "some-model", "openai.languageModel"}, + {"anthropic", "some-model", "anthropic.languageModel"}, + {"google", "some-model", "*google.languageModel"}, + } + for _, tt := range tests { + t.Run(tt.wire, func(t *testing.T) { + reg := newWireTestRegistry(tt.wire, "https://proxy.example/v1") + config := &ProviderConfig{ProviderAPIKey: "test-key"} + + result, err := autoRouteProvider(context.Background(), config, "wireproxy", tt.model, reg) + if err != nil { + t.Fatalf("autoRouteProvider returned error: %v", err) + } + gotType := reflect.TypeOf(result.Model).String() + if gotType != tt.wantType { + t.Errorf("routed to %s, want %s", gotType, tt.wantType) + } + }) + } +} + +// TestAutoRouteProvider_WireFlagBeatsRegistry verifies that +// config.ProviderWire (--provider-wire) takes precedence over both the +// registry Wire declaration and the npm heuristic. +func TestAutoRouteProvider_WireFlagBeatsRegistry(t *testing.T) { + // Registry says anthropic, flag says openai-compat. + reg := newWireTestRegistry("anthropic", "https://proxy.example/v1") + config := &ProviderConfig{ProviderAPIKey: "test-key", ProviderWire: "openai-compat"} + + result, err := autoRouteProvider(context.Background(), config, "wireproxy", "some-model", reg) + if err != nil { + t.Fatalf("autoRouteProvider returned error: %v", err) + } + if gotType := reflect.TypeOf(result.Model).String(); gotType != "openai.languageModel" { + t.Errorf("routed to %s, want openai.languageModel", gotType) + } +} + +// TestAutoRouteProvider_WireBeatsNpm verifies that an explicit Wire wins over +// a conflicting npm package mapping. +func TestAutoRouteProvider_WireBeatsNpm(t *testing.T) { + reg := &ModelsRegistry{ + providers: map[string]ProviderInfo{ + "wireproxy": { + ID: "wireproxy", + Name: "Wire Proxy", + Env: []string{"WIREPROXY_API_KEY"}, + API: "https://proxy.example/v1", + NPM: "@ai-sdk/openai-compatible", // heuristic would say openai-compat + Wire: "anthropic", // explicit declaration wins + Models: map[string]ModelInfo{}, + }, + }, + } + config := &ProviderConfig{ProviderAPIKey: "test-key"} + + result, err := autoRouteProvider(context.Background(), config, "wireproxy", "some-model", reg) + if err != nil { + t.Fatalf("autoRouteProvider returned error: %v", err) + } + if gotType := reflect.TypeOf(result.Model).String(); gotType != "anthropic.languageModel" { + t.Errorf("routed to %s, want anthropic.languageModel", gotType) + } +} + +// TestAutoRouteProvider_SynthesizedProvider verifies that a provider absent +// from the registry is synthesized when both --provider-url and +// --provider-wire are supplied. +func TestAutoRouteProvider_SynthesizedProvider(t *testing.T) { + reg := &ModelsRegistry{providers: map[string]ProviderInfo{}} + config := &ProviderConfig{ + ProviderAPIKey: "test-key", + ProviderURL: "https://llm.internal.corp/api", + ProviderWire: "anthropic", + } + + result, err := autoRouteProvider(context.Background(), config, "corp-llm", "some-model", reg) + if err != nil { + t.Fatalf("autoRouteProvider returned error: %v", err) + } + if gotType := reflect.TypeOf(result.Model).String(); gotType != "anthropic.languageModel" { + t.Errorf("routed to %s, want anthropic.languageModel", gotType) + } +} + +// TestAutoRouteProvider_SynthesizedProviderRequiresBoth verifies that an +// unknown provider without url+wire still errors with guidance. +func TestAutoRouteProvider_SynthesizedProviderRequiresBoth(t *testing.T) { + reg := &ModelsRegistry{providers: map[string]ProviderInfo{}} + + for _, config := range []*ProviderConfig{ + {ProviderAPIKey: "test-key", ProviderURL: "https://x.example"}, // wire missing + {ProviderAPIKey: "test-key", ProviderWire: "anthropic"}, // url missing + {ProviderAPIKey: "test-key"}, // both missing + } { + _, err := autoRouteProvider(context.Background(), config, "corp-llm", "some-model", reg) + if err == nil { + t.Fatalf("expected error for config %+v, got nil", config) + } + if !strings.Contains(err.Error(), "not found in model database") { + t.Errorf("unexpected error message: %v", err) + } + } +} + +// TestAutoRouteProvider_InvalidWireFlag verifies the error for a bogus +// --provider-wire value. +func TestAutoRouteProvider_InvalidWireFlag(t *testing.T) { + reg := newWireTestRegistry("", "https://proxy.example/v1") + config := &ProviderConfig{ProviderAPIKey: "test-key", ProviderWire: "carrier-pigeon"} + + _, err := autoRouteProvider(context.Background(), config, "wireproxy", "some-model", reg) + if err == nil { + t.Fatal("expected error for invalid wire, got nil") + } + if !strings.Contains(err.Error(), "carrier-pigeon") || !strings.Contains(err.Error(), WireNameOpenAICompat) { + t.Errorf("error should name the bad wire and list valid ones, got: %v", err) + } +} + +// TestApplyProviderOverrides_PatchExisting verifies field-level merge onto a +// provider that came from the database. +func TestApplyProviderOverrides_PatchExisting(t *testing.T) { + providers := map[string]ProviderInfo{ + "minimax": { + ID: "minimax", + Name: "MiniMax", + Env: []string{"MINIMAX_API_KEY"}, + NPM: "@ai-sdk/openai-compatible", + API: "https://api.minimax.io/v1", + Models: map[string]ModelInfo{ + "minimax-m2": {ID: "minimax-m2"}, + }, + }, + } + + applyProviderOverrides(providers, map[string]ProviderOverrideConfig{ + "minimax": {Wire: "anthropic"}, + }) + + got := providers["minimax"] + if got.Wire != "anthropic" { + t.Errorf("Wire = %q, want anthropic", got.Wire) + } + // Unset override fields must inherit database values. + if got.API != "https://api.minimax.io/v1" { + t.Errorf("API = %q, want inherited database URL", got.API) + } + if len(got.Env) != 1 || got.Env[0] != "MINIMAX_API_KEY" { + t.Errorf("Env = %v, want inherited [MINIMAX_API_KEY]", got.Env) + } + if len(got.Models) != 1 { + t.Errorf("Models lost during override merge: %v", got.Models) + } +} + +// TestApplyProviderOverrides_DeclareNew verifies that an unknown provider ID +// is registered fresh with an empty model map. +func TestApplyProviderOverrides_DeclareNew(t *testing.T) { + providers := map[string]ProviderInfo{} + + applyProviderOverrides(providers, map[string]ProviderOverrideConfig{ + "corp-llm": { + Name: "Corp LLM Gateway", + Wire: "anthropic", + BaseURL: "https://llm.internal.corp/api", + APIKeyEnv: []string{"CORP_LLM_KEY", "LLM_GATEWAY_KEY"}, + Headers: map[string]string{"X-Team": "platform"}, + }, + }) + + got, ok := providers["corp-llm"] + if !ok { + t.Fatal("corp-llm not registered") + } + if got.Name != "Corp LLM Gateway" || got.Wire != "anthropic" || got.API != "https://llm.internal.corp/api" { + t.Errorf("unexpected provider info: %+v", got) + } + if len(got.Env) != 2 || got.Env[0] != "CORP_LLM_KEY" { + t.Errorf("Env = %v, want [CORP_LLM_KEY LLM_GATEWAY_KEY]", got.Env) + } + if got.Headers["X-Team"] != "platform" { + t.Errorf("Headers = %v, want X-Team: platform", got.Headers) + } + if got.Models == nil { + t.Error("Models map must be initialized (advisory lookups)") + } +} + +// TestApplyProviderOverrides_InvalidWireIgnored verifies that an override +// with a bogus wire is skipped entirely (no partial application). +func TestApplyProviderOverrides_InvalidWireIgnored(t *testing.T) { + providers := map[string]ProviderInfo{ + "minimax": {ID: "minimax", Name: "MiniMax", API: "https://api.minimax.io/v1"}, + } + + applyProviderOverrides(providers, map[string]ProviderOverrideConfig{ + "minimax": {Wire: "smoke-signals", BaseURL: "https://evil.example"}, + }) + + got := providers["minimax"] + if got.Wire != "" || got.API != "https://api.minimax.io/v1" { + t.Errorf("invalid-wire override must be skipped entirely, got: %+v", got) + } +} + +// TestLoadProviderOverridesFrom verifies config parsing from a viper store. +func TestLoadProviderOverridesFrom(t *testing.T) { + v := viper.New() + v.Set("providers", map[string]any{ + "minimax": map[string]any{"wire": "anthropic"}, + "corp-llm": map[string]any{ + "name": "Corp LLM", + "wire": "openai-compat", + "baseUrl": "https://llm.internal.corp/api", + "apiKeyEnv": []string{"CORP_LLM_KEY"}, + "headers": map[string]string{"X-Team": "platform"}, + }, + }) + + overrides := loadProviderOverridesFrom(v) + if len(overrides) != 2 { + t.Fatalf("got %d overrides, want 2", len(overrides)) + } + if overrides["minimax"].Wire != "anthropic" { + t.Errorf("minimax.Wire = %q", overrides["minimax"].Wire) + } + corp := overrides["corp-llm"] + if corp.Name != "Corp LLM" || corp.BaseURL != "https://llm.internal.corp/api" { + t.Errorf("corp-llm parsed wrong: %+v", corp) + } + if len(corp.APIKeyEnv) != 1 || corp.APIKeyEnv[0] != "CORP_LLM_KEY" { + t.Errorf("corp-llm.APIKeyEnv = %v", corp.APIKeyEnv) + } + if corp.Headers["X-Team"] != "platform" { + t.Errorf("corp-llm.Headers = %v", corp.Headers) + } + + // Absent key → nil. + if got := loadProviderOverridesFrom(viper.New()); got != nil { + t.Errorf("expected nil for absent providers key, got %v", got) + } +} + +// TestIsProviderLLMSupported_ExplicitWire verifies that a wire-only override +// (no npm, no API URL) marks a provider as LLM-supported. +func TestIsProviderLLMSupported_ExplicitWire(t *testing.T) { + info := &ProviderInfo{ID: "corp-llm", Wire: "anthropic"} + if !isProviderLLMSupported("corp-llm", info) { + t.Error("expected wire-declared provider to be LLM-supported") + } +} + +// headerRecordingRoundTripper captures the headers of the request it receives. +type headerRecordingRoundTripper struct{ got http.Header } + +func (r *headerRecordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.got = req.Header.Clone() + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("{}")), + Header: make(http.Header), + }, nil +} + +// TestHeaderRoundTripper verifies default-header injection semantics: +// missing headers are added, existing request headers are not overwritten, +// and the original request is not mutated. +func TestHeaderRoundTripper(t *testing.T) { + rec := &headerRecordingRoundTripper{} + tr := &headerRoundTripper{ + base: rec, + headers: map[string]string{"X-Team": "platform", "Authorization": "Bearer default"}, + } + + req, err := http.NewRequest(http.MethodPost, "https://llm.internal.corp/api", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Authorization", "Bearer user-supplied") + + if _, err := tr.RoundTrip(req); err != nil { + t.Fatalf("RoundTrip: %v", err) + } + + if got := rec.got.Get("X-Team"); got != "platform" { + t.Errorf("X-Team = %q, want platform", got) + } + if got := rec.got.Get("Authorization"); got != "Bearer user-supplied" { + t.Errorf("Authorization = %q, existing header must not be overwritten", got) + } + if got := req.Header.Get("X-Team"); got != "" { + t.Errorf("original request mutated: X-Team = %q", got) + } +} + +// TestWithDefaultHeaders verifies the nil-client contract. +func TestWithDefaultHeaders(t *testing.T) { + if got := withDefaultHeaders(nil, nil); got != nil { + t.Error("nil client + no headers must stay nil") + } + if got := withDefaultHeaders(nil, map[string]string{"X": "y"}); got == nil { + t.Error("nil client + headers must produce a client") + } + base := &http.Client{} + if got := withDefaultHeaders(base, nil); got != base { + t.Error("client + no headers must be returned unchanged") + } + wrapped := withDefaultHeaders(&http.Client{}, map[string]string{"X": "y"}) + if _, ok := wrapped.Transport.(*headerRoundTripper); !ok { + t.Errorf("transport not wrapped: %T", wrapped.Transport) + } +} diff --git a/internal/models/providers.go b/internal/models/providers.go index 4b8d5af8..1132182b 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -169,10 +169,15 @@ func ParseThinkingLevel(s string) ThinkingLevel { // ProviderConfig holds configuration for creating LLM providers. type ProviderConfig struct { - ModelString string - SystemPrompt string - ProviderAPIKey string - ProviderURL string + ModelString string + SystemPrompt string + ProviderAPIKey string + ProviderURL string + // ProviderWire is an explicit wire protocol override ("openai", + // "openai-compat", "anthropic", "google") for auto-routed providers. + // Takes precedence over the registry's Wire declaration and the + // npm-package heuristic. Set from the --provider-wire flag. + ProviderWire string MaxTokens int Temperature *float32 TopP *float32 @@ -387,21 +392,44 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul return result, nil } -// autoRouteProvider attempts to create a provider by looking up its npm package -// in the models.dev database and routing through the appropriate fantasy provider. -// 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. +// autoRouteProvider attempts to create a provider by resolving its wire +// protocol (openai, openai-compat, anthropic, google) and routing through the +// matching fantasy provider. Fantasy implements three native wire protocols +// (the OpenAI one split into Responses API and chat-completions flavors), and +// every other entry in its providers/ tree is a thin wrapper around one of +// them. Using the provider's api URL from the registry 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. +// Wire resolution precedence: +// 1. config.ProviderWire (--provider-wire flag) — always wins. +// 2. providerInfo.Wire — explicit declaration from the `providers` config +// section. +// 3. npm-package heuristic (npmToWireProtocol), with per-model npm +// overrides resolved first (e.g. opencode's claude-* uses +// @ai-sdk/anthropic and its gemini-* uses @ai-sdk/google). +// 4. Providers with an api URL but no recognizable wire fall back to the +// OpenAI-compatible wire. +// +// A provider absent from the registry is synthesized on the fly when both +// --provider-url and --provider-wire are given, so ad-hoc proxies work +// without a database entry or config override. func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) { + if config.ProviderWire != "" { + if _, ok := parseWire(config.ProviderWire); !ok { + return nil, fmt.Errorf("unknown wire protocol %q (expected one of: %s)", config.ProviderWire, wireNames()) + } + } + providerInfo := registry.GetProviderInfo(provider) if providerInfo == nil { - return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider) + // Unknown provider: synthesize an entry when the user supplied both + // an endpoint and a wire — enough to route without a database entry. + if config.ProviderURL != "" && config.ProviderWire != "" { + providerInfo = &ProviderInfo{ID: provider, Name: provider} + } else { + return nil, fmt.Errorf("unsupported provider: %s (not found in model database; declare it in the `providers` config section or pass --provider-url with --provider-wire)", provider) + } } // Resolve npm: per-model override > provider default. @@ -410,7 +438,14 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo npmPackage = modelInfo.ProviderNPM } - wire, known := npmToWireProtocol[npmPackage] + // Wire resolution: explicit flag > config override > npm heuristic. + wire, known := parseWire(config.ProviderWire) + if !known { + wire, known = parseWire(providerInfo.Wire) + } + if !known { + 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 @@ -418,11 +453,12 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo 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)", + "and the registry has no API URL (use --provider-url to override, or "+ + "declare a wire in the `providers` config section)", provider, npmPackage, ) } - wire = wireOpenAI + wire = wireOpenAICompat } // All three wires use the provider's API URL from models.dev as the base. @@ -451,12 +487,11 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo 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) - } + // The native OpenAI wire speaks the Responses API; openai-compatible + // proxies (and unknown-npm fallbacks) use the chat-completions wire + // via fantasy's openaicompat provider. + return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo) + case wireOpenAICompat: return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo) case wireAnthropic: return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo) @@ -472,6 +507,9 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) { apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env) if apiKey == "" { + if len(info.Env) == 0 { + return "", fmt.Errorf("%s API key not provided. Use --provider-api-key, or declare apiKeyEnv for this provider in the `providers` config section", info.Name) + } return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s", info.Name, strings.Join(info.Env, " / ")) } @@ -485,6 +523,53 @@ func wrapProviderErr(name, kind string, err error) error { return fmt.Errorf("failed to create %s %s: %w", name, kind, err) } +// headerRoundTripper adds default headers to every outgoing request. Headers +// already present on the request are not overwritten. +type headerRoundTripper struct { + base http.RoundTripper + headers map[string]string +} + +func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + for k, v := range h.headers { + if req.Header.Get(k) == "" { + req.Header.Set(k, v) + } + } + return h.base.RoundTrip(req) +} + +// withDefaultHeaders wraps client's transport so that headers are added to +// every request. A nil client with non-empty headers produces a fresh +// client; a nil client with no headers stays nil (callers skip the +// WithHTTPClient option in that case). +func withDefaultHeaders(client *http.Client, headers map[string]string) *http.Client { + if len(headers) == 0 { + return client + } + if client == nil { + client = &http.Client{} + } + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + client.Transport = &headerRoundTripper{base: base, headers: headers} + return client +} + +// autoRouteHTTPClient builds the HTTP client for an auto-routed provider, +// combining TLS verification config with the provider's default headers. +// Returns nil when no customization is needed (callers use the SDK default). +func autoRouteHTTPClient(config *ProviderConfig, info *ProviderInfo) *http.Client { + var client *http.Client + if config.TLSSkipVerify { + client = createHTTPClientWithTLSConfig(true) + } + return withDefaultHeaders(client, info.Headers) +} + // createAutoRoutedOpenAICompatProvider creates an openaicompat provider using // the api URL and env vars from models.dev. func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) { @@ -506,8 +591,8 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC opts = append(opts, openaicompat.WithAPIKey(apiKey)) opts = append(opts, openaicompat.WithName(info.ID)) - if config.TLSSkipVerify { - opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true))) + if httpClient := autoRouteHTTPClient(config, info); httpClient != nil { + opts = append(opts, openaicompat.WithHTTPClient(httpClient)) } p, err := openaicompat.New(opts...) @@ -543,8 +628,8 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf opts = append(opts, anthropic.WithBaseURL(baseURL)) } - if config.TLSSkipVerify { - opts = append(opts, anthropic.WithHTTPClient(createHTTPClientWithTLSConfig(true))) + if httpClient := autoRouteHTTPClient(config, info); httpClient != nil { + opts = append(opts, anthropic.WithHTTPClient(httpClient)) } p, err := anthropic.New(opts...) @@ -576,8 +661,8 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, opts = append(opts, openai.WithBaseURL(config.ProviderURL)) } - if config.TLSSkipVerify { - opts = append(opts, openai.WithHTTPClient(createHTTPClientWithTLSConfig(true))) + if httpClient := autoRouteHTTPClient(config, info); httpClient != nil { + opts = append(opts, openai.WithHTTPClient(httpClient)) } p, err := openai.New(opts...) @@ -627,6 +712,7 @@ func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, } else if config.TLSSkipVerify { httpClient = createHTTPClientWithTLSConfig(true) } + httpClient = withDefaultHeaders(httpClient, info.Headers) if httpClient != nil { opts = append(opts, google.WithHTTPClient(httpClient)) } diff --git a/internal/models/registry.go b/internal/models/registry.go index 33dbec8a..e344d4f2 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -89,12 +89,22 @@ type Limit struct { // ProviderInfo represents information about a model provider. type ProviderInfo struct { - ID string - Env []string - NPM string // npm package identifier from models.dev (e.g. "@ai-sdk/openai-compatible") - API string // base API URL for openai-compatible providers - Name string - Models map[string]ModelInfo + ID string + Env []string + NPM string // npm package identifier from models.dev (e.g. "@ai-sdk/openai-compatible") + API string // base API URL for openai-compatible providers + Name string + // Wire is an explicit wire protocol declaration ("openai", + // "openai-compat", "anthropic", "google"). When set it takes precedence + // over the npm-package heuristic in auto-routing. Empty means "infer". + // Populated from the `providers` config section; not present in the + // models.dev data itself. + Wire string + // Headers are default HTTP headers added to every request to this + // provider (auto-routed wires only). Populated from the `providers` + // config section. + Headers map[string]string + Models map[string]ModelInfo } // ModelsRegistry provides validation and information about models. @@ -229,6 +239,12 @@ func buildFromModelsDB() map[string]ProviderInfo { } } + // Apply provider overrides from the config `providers` section as the + // final layer: explicit wire/URL/env/header declarations win over + // everything derived from the models.dev data, and unknown provider IDs + // are registered fresh (advisory model lookups tolerate empty model maps). + applyProviderOverrides(providers, loadProviderOverridesFromConfig()) + return providers } @@ -418,6 +434,11 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool { return true } + // Explicit wire declaration (from a provider override) is authoritative. + if _, ok := parseWire(info.Wire); ok { + return true + } + // Check if npm maps to a known wire protocol if _, ok := npmToWireProtocol[info.NPM]; ok { return true diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 971b4ba8..931673d5 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -760,6 +760,7 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error { SystemPrompt: systemPrompt, ProviderAPIKey: m.v.GetString("provider-api-key"), ProviderURL: m.v.GetString("provider-url"), + ProviderWire: m.v.GetString("provider-wire"), MaxTokens: m.v.GetInt("max-tokens"), TLSSkipVerify: m.v.GetBool("tls-skip-verify"), ThinkingLevel: thinkingLevel, @@ -1137,6 +1138,13 @@ type Options struct { // default URL. ProviderURL string + // ProviderWire overrides the wire protocol used for auto-routed + // providers: "openai" (Responses API), "openai-compat" (chat + // completions), "anthropic", or "google". "" = infer from the model + // database. Takes precedence over per-provider wire declarations in the + // `providers` config section. + ProviderWire string + // TLSSkipVerify disables TLS certificate verification on provider // HTTP clients. Only set this for self-signed certificates in // development. Once enabled here it cannot be disabled via Options @@ -1518,6 +1526,9 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { if opts.ProviderURL != "" { v.Set("provider-url", opts.ProviderURL) } + if opts.ProviderWire != "" { + v.Set("provider-wire", opts.ProviderWire) + } if opts.TLSSkipVerify { v.Set("tls-skip-verify", true) } @@ -2280,6 +2291,7 @@ func inheritProviderConfig(child *Options, v *viper.Viper) { } child.ProviderAPIKey = v.GetString("provider-api-key") child.ProviderURL = v.GetString("provider-url") + child.ProviderWire = v.GetString("provider-wire") child.TLSSkipVerify = v.GetBool("tls-skip-verify") child.ThinkingLevel = v.GetString("thinking-level") if v.IsSet("max-tokens") { diff --git a/www/pages/providers.md b/www/pages/providers.md index a2ead375..6b4cf6e8 100644 --- a/www/pages/providers.md +++ b/www/pages/providers.md @@ -192,6 +192,52 @@ 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`. +## Provider overrides + +The wire protocol is normally inferred from the model database, but you can +declare it explicitly — either to patch a provider the database routes wrongly, +or to define an entirely new provider (e.g. an internal LLM gateway) that the +database doesn't know about. Add a `providers` section to your config file: + +```yaml +# ~/.kit.yml +providers: + # Patch the wire protocol of a known provider + minimax: + wire: anthropic + + # Declare a brand-new provider + corp-llm: + name: "Corp LLM Gateway" + wire: anthropic + baseUrl: https://llm.internal.corp/api + apiKeyEnv: [CORP_LLM_KEY, LLM_GATEWAY_KEY] + headers: + X-Team: platform +``` + +```bash +kit --model corp-llm/claude-sonnet-4-5 "Hello" +``` + +All fields are optional; unset fields inherit the database values. Accepted +`wire` values are `openai` (Responses API), `openai-compat` (chat completions), +`anthropic`, and `google`. `apiKeyEnv` lists environment variables tried in +order, and `headers` adds default HTTP headers to every request. + +For one-off use without editing config, pass `--provider-wire` together with +`--provider-url`. Unlike `--provider-url` alone (which always speaks the +OpenAI-compatible wire via `custom/`), this routes the model's own provider +prefix through the declared wire — and works even for providers not in the +database: + +```bash +kit --model corp-llm/claude-sonnet-4-5 \ + --provider-url https://llm.internal.corp/api \ + --provider-wire anthropic \ + --provider-api-key "$CORP_LLM_KEY" "Hello" +``` + ## Model database Kit ships with a local model database that maps provider names to API configurations. You can manage it with: From 8529bfefc9bf159eab925d61b111056f7b44fa90 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 10 Jul 2026 15:30:22 +0300 Subject: [PATCH 2/4] Add WithProviderWire functional option for SDK parity Options.ProviderWire landed without a matching functional option; every other provider override field (ProviderAPIKey, ProviderURL) has one. --- pkg/kit/options.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/kit/options.go b/pkg/kit/options.go index 8e940a9b..3b2192b8 100644 --- a/pkg/kit/options.go +++ b/pkg/kit/options.go @@ -76,6 +76,12 @@ func WithProviderAPIKey(key string) Option { return func(o *Options) { o.Provide // OpenAI-compatible proxies (LiteLLM, vLLM, Azure OpenAI, etc.). func WithProviderURL(url string) Option { return func(o *Options) { o.ProviderURL = url } } +// WithProviderWire overrides the wire protocol used for auto-routed +// providers: "openai" (Responses API), "openai-compat" (chat completions), +// "anthropic", or "google". Combine with WithProviderURL to point Kit at a +// non-OpenAI-flavored proxy or a provider not in the model database. +func WithProviderWire(wire string) Option { return func(o *Options) { o.ProviderWire = wire } } + // WithConfigFile sets an explicit config file path, overriding the default // .kit.yml search. func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile = path } } From 2a91ba1bd1994b7b732a3071e0a3c5b2707dad45 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 10 Jul 2026 15:32:45 +0300 Subject: [PATCH 3/4] Document provider wire overrides across all doc surfaces Covers the --provider-wire flag, the providers config section, and Options.ProviderWire / WithProviderWire: - www/pages/configuration.md: provider-wire config key, new Provider overrides section with field reference, cross-ref from Custom models - www/pages/providers.md: link to field reference, when-to-use-which table comparing custom provider / customModels / providers overrides / --provider-wire - www/pages/cli/flags.md + README flag list: --provider-wire row - www/pages/sdk/options.md: ProviderWire field row + struct example - www/pages/sdk/overview.md: WithProviderWire functional option row Docs site verified with 'tome build'. --- README.md | 1 + www/pages/cli/flags.md | 1 + www/pages/configuration.md | 41 +++++++++++++++++++++++++++++++++++++- www/pages/providers.md | 13 +++++++++++- www/pages/sdk/options.md | 2 ++ www/pages/sdk/overview.md | 1 + 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd593049..4c4f2870 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ mcpServers: --model, -m Model to use (provider/model format) --provider-api-key API key for the provider --provider-url Base URL for provider API +--provider-wire Wire protocol for auto-routed providers (openai, openai-compat, anthropic, google) --tls-skip-verify Skip TLS certificate verification # Session management diff --git a/www/pages/cli/flags.md b/www/pages/cli/flags.md index a93dbb96..c79cda06 100644 --- a/www/pages/cli/flags.md +++ b/www/pages/cli/flags.md @@ -14,6 +14,7 @@ All flags can be passed to the root `kit` command. | `--model` | `-m` | `anthropic/claude-sonnet-latest` | Model to use (provider/model format) | | `--provider-api-key` | — | — | API key for the provider | | `--provider-url` | — | — | Base URL for provider API | +| `--provider-wire` | — | — | Wire protocol for auto-routed providers: `openai`, `openai-compat`, `anthropic`, `google` ([overrides the model database](/providers#provider-overrides)) | | `--tls-skip-verify` | — | `false` | Skip TLS certificate verification | ## Session management diff --git a/www/pages/configuration.md b/www/pages/configuration.md index ae47395f..5fa7b239 100644 --- a/www/pages/configuration.md +++ b/www/pages/configuration.md @@ -40,6 +40,7 @@ stream: true | `thinking-level` | string | `off` | Extended thinking: off, none, minimal, low, medium, high | | `provider-api-key` | string | — | API key for the provider | | `provider-url` | string | — | Base URL for provider API | +| `provider-wire` | string | — | Wire protocol for auto-routed providers: `openai`, `openai-compat`, `anthropic`, `google` (see [Provider overrides](#provider-overrides)) | | `tls-skip-verify` | bool | `false` | Skip TLS certificate verification | | `frequency-penalty` | float | `0.0` | Penalize frequent tokens (0.0–2.0) | | `presence-penalty` | float | `0.0` | Penalize present tokens (0.0–2.0) | @@ -143,9 +144,47 @@ Defaults are safe: any existing MCP server keeps its previous behaviour bit-for-bit. SDK consumers can also override the mode programmatically and plug in a progress callback — see [SDK options](/sdk/options#mcp-tasks). +## Provider overrides + +Declare or patch providers in the model registry with the `providers` section. Use it to fix a wire protocol the model database routes wrongly, or to define an entirely new provider (such as an internal LLM gateway) that the database doesn't know about: + +```yaml +providers: + # Patch the wire protocol of a known provider + minimax: + wire: anthropic + + # Declare a brand-new provider + corp-llm: + name: "Corp LLM Gateway" + wire: anthropic + baseUrl: "https://llm.internal.corp/api" + apiKeyEnv: [CORP_LLM_KEY, LLM_GATEWAY_KEY] + headers: + X-Team: platform +``` + +```bash +kit --model corp-llm/claude-sonnet-4-5 "Hello" +``` + +### Provider override fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Display name for the provider | +| `wire` | string | Wire protocol: `openai` (Responses API), `openai-compat` (chat completions), `anthropic`, or `google` | +| `baseUrl` | string | API base URL | +| `apiKeyEnv` | list | Environment variable names tried in order when resolving the API key | +| `headers` | map | Default HTTP headers added to every request | + +All fields are optional. Non-empty fields override the model database; unset fields inherit the database values. An invalid `wire` value causes the whole override to be skipped with a warning. Wire resolution precedence: `--provider-wire` flag > `providers` config > the database's own routing. + +Unlike [custom models](#custom-models) (which define individual models under the `custom` provider, always on the OpenAI-compatible wire), provider overrides operate at the provider level and support all four wire protocols. See [Auto-routed providers](/providers#auto-routed-providers) for how routing works. + ## Custom models -Define custom models in your `.kit.yml` for use with the `custom` provider. This is useful for self-hosted models or API endpoints not in the built-in database: +Define custom models in your `.kit.yml` for use with the `custom` provider. This is useful for self-hosted models or API endpoints not in the built-in database (for non-OpenAI wire protocols or provider-level declarations, use [provider overrides](#provider-overrides) instead): ```yaml customModels: diff --git a/www/pages/providers.md b/www/pages/providers.md index 6b4cf6e8..6f3fde1c 100644 --- a/www/pages/providers.md +++ b/www/pages/providers.md @@ -223,7 +223,9 @@ kit --model corp-llm/claude-sonnet-4-5 "Hello" All fields are optional; unset fields inherit the database values. Accepted `wire` values are `openai` (Responses API), `openai-compat` (chat completions), `anthropic`, and `google`. `apiKeyEnv` lists environment variables tried in -order, and `headers` adds default HTTP headers to every request. +order, and `headers` adds default HTTP headers to every request. See the +[provider override fields reference](/configuration#provider-overrides) for +the full field table. For one-off use without editing config, pass `--provider-wire` together with `--provider-url`. Unlike `--provider-url` alone (which always speaks the @@ -238,6 +240,15 @@ kit --model corp-llm/claude-sonnet-4-5 \ --provider-api-key "$CORP_LLM_KEY" "Hello" ``` +### When to use which + +| Mechanism | Wire protocols | Scope | Best for | +|-----------|---------------|-------|----------| +| `--provider-url` alone | OpenAI-compatible only | One-off (routes via `custom/`) | Quick tests against local/OpenAI-compatible endpoints | +| [`customModels`](/configuration#custom-models) | OpenAI-compatible only | Per-model, persistent | Self-hosted models needing cost/limit metadata | +| [`providers` overrides](/configuration#provider-overrides) | All four | Per-provider, persistent | Internal gateways, fixing database routing, non-OpenAI wires | +| `--provider-wire` + `--provider-url` | All four | One-off | Ad-hoc proxies on any wire, no config edit | + ## Model database Kit ships with a local model database that maps provider names to API configurations. You can manage it with: diff --git a/www/pages/sdk/options.md b/www/pages/sdk/options.md index 2a058894..e6349072 100644 --- a/www/pages/sdk/options.md +++ b/www/pages/sdk/options.md @@ -45,6 +45,7 @@ host, err := kit.New(ctx, &kit.Options{ // Provider configuration ProviderAPIKey: "sk-...", // "" = use config / provider env var ProviderURL: "https://proxy.internal/v1", // "" = provider default endpoint + ProviderWire: "anthropic", // "" = infer wire from model database TLSSkipVerify: false, // only effective when true // Session @@ -145,6 +146,7 @@ when embedding Kit as a library. |-------|------|---------|-------------| | `ProviderAPIKey` | `string` | — | API key used to authenticate with the provider. `""` falls back to config / provider-specific env var (e.g. `ANTHROPIC_API_KEY`). When set, it takes precedence over config and env values on this instance's store. | | `ProviderURL` | `string` | — | Override the provider endpoint (e.g. LiteLLM, vLLM, Azure OpenAI, internal proxy). `""` = provider default. | +| `ProviderWire` | `string` | — | Override the wire protocol for auto-routed providers: `openai` (Responses API), `openai-compat` (chat completions), `anthropic`, or `google`. `""` = infer from the model database. Takes precedence over per-provider `wire` declarations in the [`providers` config section](/configuration#provider-overrides). Combine with `ProviderURL` to target providers not in the database. | | `TLSSkipVerify` | `bool` | `false` | Disable TLS certificate verification on the provider HTTP client. Only effective when `true`; to force-disable, use config file or env var instead. For self-signed dev certs only. | ### Session diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index b0b09623..f53e29ea 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -78,6 +78,7 @@ Available options: | `WithExtraTools(...Tool)` | `Options.ExtraTools` (adds alongside defaults) | | `WithProviderAPIKey(string)` | `Options.ProviderAPIKey` | | `WithProviderURL(string)` | `Options.ProviderURL` | +| `WithProviderWire(string)` | `Options.ProviderWire` (wire protocol for auto-routed providers: `openai`, `openai-compat`, `anthropic`, `google`) | | `WithConfigFile(string)` | `Options.ConfigFile` | | `WithDebug()` | `Options.Debug = true` | | `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) | From 7c90e9b196bd5d6110d1803db741468c262e26ea Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 10 Jul 2026 17:37:57 +0300 Subject: [PATCH 4/4] fix(models): address CodeRabbit review on PR #93 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make the cache-options merge guard wire-aware: detect ResponsesProviderOptions by type via hasResponsesProviderOptions() instead of checking provider == "openai", so providers auto-routed to the Responses wire (wire: openai / npm @ai-sdk/openai) are guarded too; regression test ties the guard to the real options producer (buildOpenAIProviderOptions) - headerRoundTripper: presence-aware header check so an explicitly cleared (empty) request header is not replaced by the configured default; regression test added - Cover ProviderWire in TestInheritProviderConfig Skipped (intentional): wrapping h.base.RoundTrip errors — http.Client already wraps transport errors in *url.Error with method+URL context, and the sibling geminiProxyTransport uses the same pass-through pattern; wrapping inside middleware adds noise without new context. --- internal/models/provider_overrides_test.go | 53 ++++++++++++++++++++++ internal/models/providers.go | 28 +++++++++--- pkg/kit/mcp_tasks_test.go | 4 ++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/models/provider_overrides_test.go b/internal/models/provider_overrides_test.go index bc034551..fb606faa 100644 --- a/internal/models/provider_overrides_test.go +++ b/internal/models/provider_overrides_test.go @@ -319,6 +319,33 @@ func TestIsProviderLLMSupported_ExplicitWire(t *testing.T) { } } +// TestHasResponsesProviderOptions verifies the cache-merge guard predicate: +// options produced for a Responses reasoning model (the output of +// buildOpenAIProviderOptions, as returned by createAutoRoutedOpenAIProvider +// for any wire:openai provider) must be detected, while regular +// prompt-cache options and empty maps must not. +func TestHasResponsesProviderOptions(t *testing.T) { + // Real producer: an auto-routed Responses provider serving a reasoning + // model yields ResponsesProviderOptions regardless of the provider name. + respOpts := buildOpenAIProviderOptions(&ProviderConfig{}, "o3") + if respOpts == nil { + t.Fatal("buildOpenAIProviderOptions returned nil for reasoning model o3") + } + if !hasResponsesProviderOptions(respOpts) { + t.Error("Responses options must be detected (cache merge would corrupt them)") + } + + // Regular prompt-cache options use the same key but a different type. + cacheOpts := buildOpenAICacheOptions(&ProviderConfig{}, "gpt-4o") + if hasResponsesProviderOptions(cacheOpts) { + t.Error("regular ProviderOptions must not be flagged as Responses options") + } + + if hasResponsesProviderOptions(nil) { + t.Error("nil options must not be flagged") + } +} + // headerRecordingRoundTripper captures the headers of the request it receives. type headerRecordingRoundTripper struct{ got http.Header } @@ -362,6 +389,32 @@ func TestHeaderRoundTripper(t *testing.T) { } } +// TestHeaderRoundTripper_ExplicitlyEmptyHeader verifies that a header +// explicitly set to the empty string (e.g. a deliberately cleared +// Authorization) is not replaced by the configured default. +func TestHeaderRoundTripper_ExplicitlyEmptyHeader(t *testing.T) { + rec := &headerRecordingRoundTripper{} + tr := &headerRoundTripper{ + base: rec, + headers: map[string]string{"Authorization": "Bearer default"}, + } + + req, err := http.NewRequest(http.MethodPost, "https://llm.internal.corp/api", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Authorization", "") // explicitly cleared + + if _, err := tr.RoundTrip(req); err != nil { + t.Fatalf("RoundTrip: %v", err) + } + + vals, present := rec.got[http.CanonicalHeaderKey("Authorization")] + if !present || len(vals) != 1 || vals[0] != "" { + t.Errorf("Authorization = %v (present=%v), explicitly empty header must be preserved", vals, present) + } +} + // TestWithDefaultHeaders verifies the nil-client contract. func TestWithDefaultHeaders(t *testing.T) { if got := withDefaultHeaders(nil, nil); got != nil { diff --git a/internal/models/providers.go b/internal/models/providers.go index 1132182b..872723f4 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -374,12 +374,12 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul // different types for regular options vs cache control options). // // For OpenAI Responses API models, we skip merging entirely because - // ResponsesProviderOptions and ProviderOptions are incompatible types. - skipMerge := false - if provider == "openai" && openai.IsResponsesModel(modelName) { - skipMerge = true - } - if !skipMerge { + // ResponsesProviderOptions and ProviderOptions are incompatible types + // under the same options key. Detect this by type rather than by + // provider name so providers auto-routed to the Responses wire + // (wire: openai / npm @ai-sdk/openai) are guarded too, not just the + // native "openai" provider. + if !hasResponsesProviderOptions(result.ProviderOptions) { for k, v := range cacheOpts { if _, exists := result.ProviderOptions[k]; !exists { result.ProviderOptions[k] = v @@ -392,6 +392,17 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul return result, nil } +// hasResponsesProviderOptions reports whether opts already carries OpenAI +// Responses API options (openai.ResponsesProviderOptions), which are +// type-incompatible with the regular prompt-cache options under the same +// options key. Used to guard the cache-options merge in CreateProvider for +// both the native openai provider and providers auto-routed to the Responses +// wire. +func hasResponsesProviderOptions(opts fantasy.ProviderOptions) bool { + _, ok := opts[openai.Name].(*openai.ResponsesProviderOptions) + return ok +} + // autoRouteProvider attempts to create a provider by resolving its wire // protocol (openai, openai-compat, anthropic, google) and routing through the // matching fantasy provider. Fantasy implements three native wire protocols @@ -533,7 +544,10 @@ type headerRoundTripper struct { func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) for k, v := range h.headers { - if req.Header.Get(k) == "" { + // Presence-aware check: an explicitly set but empty header (e.g. a + // deliberately cleared Authorization) must not be replaced with the + // configured default. + if _, exists := req.Header[http.CanonicalHeaderKey(k)]; !exists { req.Header.Set(k, v) } } diff --git a/pkg/kit/mcp_tasks_test.go b/pkg/kit/mcp_tasks_test.go index b1188c68..9f749e5d 100644 --- a/pkg/kit/mcp_tasks_test.go +++ b/pkg/kit/mcp_tasks_test.go @@ -176,6 +176,7 @@ func TestInheritProviderConfig(t *testing.T) { v := viper.New() v.Set("provider-api-key", "sk-parent") v.Set("provider-url", "https://proxy.internal/v1") + v.Set("provider-wire", "anthropic") v.Set("tls-skip-verify", true) v.Set("thinking-level", "high") v.Set("max-tokens", 4321) @@ -194,6 +195,9 @@ func TestInheritProviderConfig(t *testing.T) { if child.ProviderURL != "https://proxy.internal/v1" { t.Errorf("ProviderURL = %q", child.ProviderURL) } + if child.ProviderWire != "anthropic" { + t.Errorf("ProviderWire = %q, want anthropic", child.ProviderWire) + } if !child.TLSSkipVerify { t.Error("TLSSkipVerify not propagated") }