From 16dfabc36a8d809dbf65ebea42178d42b103be15 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 30 Jun 2026 02:15:53 +0530 Subject: [PATCH 1/2] refactor: centralize runtime provider policy --- catalog/registry/derive.go | 76 +++++++++++++++++++ catalog/registry/provider_spec_test.go | 38 ++++++++++ catalog/registry/providers.go | 100 +++++++++++++------------ catalog/registry/spec.go | 5 ++ config/profiles.go | 36 +++++++++ runtime/default_provider.go | 99 +++++------------------- runtime/gateways.go | 21 +----- runtime/transport.go | 15 ++-- 8 files changed, 237 insertions(+), 153 deletions(-) diff --git a/catalog/registry/derive.go b/catalog/registry/derive.go index 0ed502b..c99aec5 100644 --- a/catalog/registry/derive.go +++ b/catalog/registry/derive.go @@ -46,6 +46,82 @@ func DisplayName(providerID string) string { return providerID } +// ChatProviderPreferenceOrder returns provider ids ordered by chat/runtime preference. +func ChatProviderPreferenceOrder() []string { + specs := DefaultRegistry.All() + sort.Slice(specs, func(i, j int) bool { + left := specs[i].ChatPreference + right := specs[j].ChatPreference + if left == 0 { + left = specs[i].SortOrder + 10_000 + } + if right == 0 { + right = specs[j].SortOrder + 10_000 + } + if left != right { + return left < right + } + return specs[i].ProviderID < specs[j].ProviderID + }) + out := make([]string, 0, len(specs)) + for _, spec := range specs { + if spec.ProviderID != "" { + out = append(out, spec.ProviderID) + } + } + return out +} + +// RuntimeProfileKey returns the config runtime-profile key for a provider. +func RuntimeProfileKey(providerID string) string { + if spec, ok := SpecByProviderID(providerID); ok { + return strings.TrimSpace(spec.RuntimeProfileKey) + } + return "" +} + +// DirectFallbackProviderIDs returns direct-provider fallback ids for providerID. +func DirectFallbackProviderIDs(providerID string) []string { + spec, ok := SpecByProviderID(providerID) + if !ok || len(spec.DirectFallbacks) == 0 { + return nil + } + out := make([]string, 0, len(spec.DirectFallbacks)) + for _, id := range spec.DirectFallbacks { + if trimmed := strings.TrimSpace(id); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// CredentialAliases returns compatibility env var names for providerID. +func CredentialAliases(providerID string) []string { + spec, ok := SpecByProviderID(providerID) + if !ok || len(spec.CredentialAliases) == 0 { + return nil + } + out := make([]string, 0, len(spec.CredentialAliases)) + for _, env := range spec.CredentialAliases { + if trimmed := strings.TrimSpace(env); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// CredentialEnvPreparedProviders returns providers that need config-derived env before discovery. +func CredentialEnvPreparedProviders() []string { + var out []string + for _, spec := range DefaultRegistry.All() { + if spec.PrepareCredentialEnv && spec.ProviderID != "" { + out = append(out, spec.ProviderID) + } + } + sort.Strings(out) + return out +} + // CredentialRegistry derives credential rows from provider specs. func CredentialRegistry() []CredentialSpec { specs := DefaultRegistry.All() diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index 829762c..4d8c431 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -46,6 +46,44 @@ func TestOpenCodeGo_HasProbeBaseURL(t *testing.T) { } } +func TestProviderRuntimePolicy_Metadata(t *testing.T) { + t.Parallel() + + order := registry.ChatProviderPreferenceOrder() + if len(order) < 3 { + t.Fatalf("runtime preference order too short: %v", order) + } + if order[0] != "openai" || order[1] != "anthropic" || order[2] != "openrouter" { + t.Fatalf("unexpected runtime preference prefix: %v", order[:3]) + } + + if got := registry.DirectFallbackProviderIDs("openai"); len(got) != 1 || got[0] != "anthropic" { + t.Fatalf("openai direct fallbacks = %v, want [anthropic]", got) + } + if got := registry.DirectFallbackProviderIDs("anthropic"); len(got) != 1 || got[0] != "openai" { + t.Fatalf("anthropic direct fallbacks = %v, want [openai]", got) + } + + if got := registry.CredentialAliases("anthropic"); len(got) != 1 || got[0] != "CLAUDE_API_KEY" { + t.Fatalf("anthropic credential aliases = %v", got) + } + + prepared := registry.CredentialEnvPreparedProviders() + wantPrepared := map[string]bool{ + "xiaomi_mimo_token_plan": true, + "zai_coding": true, + "zai_payg": true, + } + if len(prepared) != len(wantPrepared) { + t.Fatalf("prepared providers = %v", prepared) + } + for _, providerID := range prepared { + if !wantPrepared[providerID] { + t.Fatalf("unexpected prepared provider %q in %v", providerID, prepared) + } + } +} + func TestProviderSpecs_TableDriven(t *testing.T) { t.Parallel() tests := []struct { diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index fa47150..a98aa44 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -17,167 +17,175 @@ func providerSpecs() []ProviderSpec { return []ProviderSpec{ // ── Direct API providers ────────────────────────────────────────── { - ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, + ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", - BaseURLEnv: []string{"ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, - ProbeKind: ProbeAnthropic, - LiveFetcherKey: "anthropic", LiveCatalogKey: "anthropic", - APIProtocolID: "anthropic-messages", AdapterID: "anthropic", + CredentialAliases: []string{"CLAUDE_API_KEY"}, + BaseURLEnv: []string{"ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeAnthropic, + LiveFetcherKey: "anthropic", LiveCatalogKey: "anthropic", + APIProtocolID: "anthropic-messages", AdapterID: "anthropic", RuntimeProfileKey: "anthropic", + DirectFallbacks: []string{"openai"}, }, { - ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, + ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.openai.com/v1", LiveFetcherKey: "openai", LiveCatalogKey: "openai", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", + APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "openai", + DirectFallbacks: []string{"anthropic"}, }, { - ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, + ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", - BaseURLEnv: []string{"GEMINI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, - ProbeKind: ProbeGemini, - LiveFetcherKey: "gemini", LiveCatalogKey: "gemini", - APIProtocolID: "gemini-generate-content", AdapterID: "gemini", + CredentialAliases: []string{"GOOGLE_API_KEY"}, + BaseURLEnv: []string{"GEMINI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeGemini, + LiveFetcherKey: "gemini", LiveCatalogKey: "gemini", + APIProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { - ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, + ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, RequiresKey: true, CredentialEnv: "DEEPSEEK_API_KEY", BaseURLEnv: []string{"DEEPSEEK_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", LiveFetcherKey: "deepseek", LiveCatalogKey: "deepseek", - APIProtocolID: "openai-chat-completions", AdapterID: "deepseek", + APIProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { - ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, + ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, RequiresKey: true, CredentialEnv: "XAI_API_KEY", BaseURLEnv: []string{"XAI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", LiveFetcherKey: "grok", LiveCatalogKey: "grok", - APIProtocolID: "openai-chat-completions", AdapterID: "grok", + APIProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { - ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, + ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, RequiresKey: true, CredentialEnv: "MOONSHOT_API_KEY", BaseURLEnv: []string{"MOONSHOT_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", LiveFetcherKey: "kimi", LiveCatalogKey: "kimi", - APIProtocolID: "openai-chat-completions", AdapterID: "kimi", + APIProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { - ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, + ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", LiveFetcherKey: "zai_coding", LiveCatalogKey: "zai_coding", - APIProtocolID: "openai-chat-completions", AdapterID: "zai_coding", + APIProtocolID: "openai-chat-completions", AdapterID: "zai_coding", RuntimeProfileKey: "zai_coding", + PrepareCredentialEnv: true, }, { - ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, + ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, ChatPreference: 9, RequiresKey: true, CredentialEnv: "ZAI_API_KEY", BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", LiveFetcherKey: "zai_payg", LiveCatalogKey: "zai_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "zai_payg", + APIProtocolID: "openai-chat-completions", AdapterID: "zai_payg", RuntimeProfileKey: "zai_payg", + PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, + ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, ChatPreference: 16, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", LiveFetcherKey: "xiaomi_mimo_token_plan", LiveCatalogKey: "xiaomi_mimo_token_plan", - APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", + APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_token_plan", + PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, + ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, ChatPreference: 15, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_PAYG_API_KEY", - BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, - ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.xiaomimimo.com/v1", + CredentialAliases: []string{"XIAOMI_MIMO_API_KEY"}, + BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.xiaomimimo.com/v1", LiveFetcherKey: "xiaomi_mimo_payg", LiveCatalogKey: "xiaomi_mimo_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", + APIProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", }, { - ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, + ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, ChatPreference: 17, RequiresKey: true, CredentialEnv: "MINIMAX_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_token_plan", LiveCatalogKey: "minimax_token_plan", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", + APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", }, { - ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, + ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, ChatPreference: 18, RequiresKey: true, CredentialEnv: "MINIMAX_PAYG_API_KEY", BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", LiveFetcherKey: "minimax_payg", LiveCatalogKey: "minimax_payg", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", + APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_payg", }, // ── Cloud platform providers ────────────────────────────────────── { - ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, + ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, ProbeKind: ProbeNone, LiveFetcherKey: "azure", LiveCatalogKey: "azure", - APIProtocolID: "openai-chat-completions", AdapterID: "openai-azure", + APIProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { - ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, + ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, BaseURLEnv: []string{"AWS_REGION", "AWS_DEFAULT_REGION"}, ProbeKind: ProbeNone, LiveFetcherKey: "bedrock", LiveCatalogKey: "bedrock", - APIProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", + APIProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { - ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, + ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, BaseURLEnv: []string{"VERTEX_PROJECT_ID", "VERTEX_REGION"}, ProbeKind: ProbeNone, LiveFetcherKey: "vertex", LiveCatalogKey: "vertex", - APIProtocolID: "gemini-generate-content", AdapterID: "gemini-vertex", + APIProtocolID: "gemini-generate-content", AdapterID: "gemini-vertex", RuntimeProfileKey: "vertex", }, // ── Aggregators ─────────────────────────────────────────────────── { - ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, + ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, ChatPreference: 3, RequiresKey: true, CredentialEnv: "OPENROUTER_API_KEY", BaseURLEnv: []string{"OPENROUTER_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", LiveFetcherKey: "openrouter", LiveCatalogKey: "openrouter", - APIProtocolID: "openai-chat-completions", AdapterID: "openrouter", + APIProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", }, // ── Niche ───────────────────────────────────────────────────────── { - ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, + ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, ChatPreference: 10, RequiresKey: true, CredentialEnv: "CANOPYWAVE_API_KEY", BaseURLEnv: []string{"CANOPYWAVE_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", LiveFetcherKey: "canopywave", LiveCatalogKey: "canopywave", - APIProtocolID: "openai-chat-completions", AdapterID: "canopywave", + APIProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 18, + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 18, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: opencodego.DefaultBaseURL, LiveFetcherKey: "opencodego", LiveCatalogKey: "opencodego", - APIProtocolID: "openai-chat-completions", AdapterID: "opencodego", + APIProtocolID: "openai-chat-completions", AdapterID: "opencodego", RuntimeProfileKey: "opencodego", }, // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 19, ChatPreference: 19, RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, ProbeKind: ProbeOllama, LiveFetcherKey: "ollama", LiveCatalogKey: "ollama", - APIProtocolID: "openai-chat-completions", AdapterID: "openai", + APIProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "ollama", IsLocal: true, RetryConfig: &RetryConfig{ BaseDelayMs: 2000, MaxDelayMs: 10000, MaxRetries: 3, diff --git a/catalog/registry/spec.go b/catalog/registry/spec.go index 814ff47..ac422ab 100644 --- a/catalog/registry/spec.go +++ b/catalog/registry/spec.go @@ -28,9 +28,11 @@ type ProviderSpec struct { DisplayName string DeploymentID string SortOrder int + ChatPreference int RequiresKey bool CredentialEnv string CredentialEnvFallbacks []string // additional env var names for the same credential + CredentialAliases []string // compatibility env var names accepted by host UIs BaseURLEnv []string ProbeKind ProbeKind ProbeBaseURL string @@ -38,6 +40,9 @@ type ProviderSpec struct { LiveCatalogKey string // legacy provider key in ModelCatalog.Providers map APIProtocolID string AdapterID string + RuntimeProfileKey string + DirectFallbacks []string + PrepareCredentialEnv bool RetryConfig *RetryConfig IsLocal bool } diff --git a/config/profiles.go b/config/profiles.go index 64f552a..4b726ca 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -172,6 +172,13 @@ var ( BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, APIKeys: []APIKeyDef{{Env: "MINIMAX_PAYG_API_KEY", Source: "minimax_payg"}}, } + OllamaRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: OllamaDefaultBaseURL, + DetectionEnv: []string{"OLLAMA_BASE_URL"}, + ModelEnv: []string{"OLLAMA_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"OLLAMA_BASE_URL"}, + APIKeys: []APIKeyDef{{Env: "OLLAMA_API_KEY", Source: "ollama"}}, + } ) // APIProviderDetectionOrder is the priority order for provider detection. @@ -232,3 +239,32 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "minimax_token_plan": MiniMaxTokenPlanRuntimeProfile, "minimax_payg": MiniMaxPaygRuntimeProfile, } + +// RuntimeProviderProfiles maps provider/profile keys to runtime detection profiles. +var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{ + "anthropic": AnthropicRuntimeProfile, + "openai": OpenAIRuntimeProfile, + "grok": GrokRuntimeProfile, + "gemini": GeminiRuntimeProfile, + "vertex": VertexRuntimeProfile, + "azure": AzureRuntimeProfile, + "bedrock": BedrockRuntimeProfile, + "openrouter": OpenRouterRuntimeProfile, + "zai_payg": ZAIPaygRuntimeProfile, + "zai_coding": ZAICodingRuntimeProfile, + "canopywave": CanopyWaveRuntimeProfile, + "deepseek": DeepSeekRuntimeProfile, + "opencodego": OpenCodeGoRuntimeProfile, + "kimi": KimiRuntimeProfile, + "xiaomi_mimo_payg": XiaomiPaygRuntimeProfile, + "xiaomi_mimo_token_plan": XiaomiTokenPlanRuntimeProfile, + "minimax_token_plan": MiniMaxTokenPlanRuntimeProfile, + "minimax_payg": MiniMaxPaygRuntimeProfile, + "ollama": OllamaRuntimeProfile, +} + +// RuntimeProfileByKey returns the provider runtime profile registered for key. +func RuntimeProfileByKey(key string) (RuntimeProviderProfile, bool) { + profile, ok := RuntimeProviderProfiles[key] + return profile, ok +} diff --git a/runtime/default_provider.go b/runtime/default_provider.go index 7926614..650e39d 100644 --- a/runtime/default_provider.go +++ b/runtime/default_provider.go @@ -8,32 +8,11 @@ import ( "time" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" ) -var chatProviderPreferenceOrder = []string{ - "openai", - "anthropic", - "openrouter", - "grok", - "gemini", - "vertex", - "bedrock", - "zai_coding", - "zai_payg", - "canopywave", - "deepseek", - "azure", - "opencodego", - "kimi", - "xiaomi_mimo_payg", - "xiaomi_mimo_token_plan", - "minimax_token_plan", - "minimax_payg", - "ollama", -} - // DefaultModelProviderFilter returns the catalog provider id to use when listing models // with no explicit provider (e.g. /config model picker after paste-key). // Order: provider.json default → first configured deployment (stable sort by id). @@ -102,7 +81,7 @@ func preferredConfiguredProvider(ctx context.Context) string { configured[provider] = struct{}{} } } - for _, provider := range chatProviderPreferenceOrder { + for _, provider := range registry.ChatProviderPreferenceOrder() { if _, ok := configured[provider]; ok { return provider } @@ -120,73 +99,31 @@ func preferredConfiguredProvider(ctx context.Context) string { } func preferredDetectedProvider() string { - for _, provider := range chatProviderPreferenceOrder { - switch provider { - case "ollama": - if runtimeEnvValue("OLLAMA_BASE_URL") != "" { - return provider - } - default: - profile, ok := runtimeProfileForProvider(provider) - if !ok { - continue - } - ready := true - for _, envKey := range profile.DetectionEnv { - if runtimeEnvValue(envKey) == "" { - ready = false - break - } - } - if ready { - return provider + for _, provider := range registry.ChatProviderPreferenceOrder() { + profile, ok := runtimeProfileForProvider(provider) + if !ok { + continue + } + ready := true + for _, envKey := range profile.DetectionEnv { + if runtimeEnvValue(envKey) == "" { + ready = false + break } } + if ready { + return provider + } } return "" } func runtimeProfileForProvider(provider string) (config.RuntimeProviderProfile, bool) { - switch provider { - case "anthropic": - return config.AnthropicRuntimeProfile, true - case "openai": - return config.OpenAIRuntimeProfile, true - case "openrouter": - return config.OpenRouterRuntimeProfile, true - case "grok": - return config.GrokRuntimeProfile, true - case "gemini": - return config.GeminiRuntimeProfile, true - case "vertex": - return config.VertexRuntimeProfile, true - case "bedrock": - return config.BedrockRuntimeProfile, true - case "zai_coding": - return config.ZAICodingRuntimeProfile, true - case "zai_payg": - return config.ZAIPaygRuntimeProfile, true - case "canopywave": - return config.CanopyWaveRuntimeProfile, true - case "deepseek": - return config.DeepSeekRuntimeProfile, true - case "azure": - return config.AzureRuntimeProfile, true - case "opencodego": - return config.OpenCodeGoRuntimeProfile, true - case "kimi": - return config.KimiRuntimeProfile, true - case "xiaomi_mimo_payg": - return config.XiaomiPaygRuntimeProfile, true - case "xiaomi_mimo_token_plan": - return config.XiaomiTokenPlanRuntimeProfile, true - case "minimax_token_plan": - return config.MiniMaxTokenPlanRuntimeProfile, true - case "minimax_payg": - return config.MiniMaxPaygRuntimeProfile, true - default: + key := registry.RuntimeProfileKey(provider) + if key == "" { return config.RuntimeProviderProfile{}, false } + return config.RuntimeProfileByKey(key) } func runtimeEnvValue(key string) string { diff --git a/runtime/gateways.go b/runtime/gateways.go index 848e4ad..0db487b 100644 --- a/runtime/gateways.go +++ b/runtime/gateways.go @@ -225,30 +225,17 @@ func gatewayCredentialEnvKeys(providerID string) []string { for _, env := range spec.CredentialEnvFallbacks { add(env) } - for _, env := range providerCredentialAliases(providerID) { + for _, env := range registry.CredentialAliases(providerID) { add(env) } return out } -func providerCredentialAliases(providerID string) []string { - switch providerID { - case "anthropic": - return []string{"CLAUDE_API_KEY"} - case "gemini": - return []string{"GOOGLE_API_KEY"} - case "xiaomi_mimo_payg": - return []string{"XIAOMI_MIMO_API_KEY"} - default: - return nil - } -} - // PrepareCredentialDiscovery applies runtime-owned gateway env derivations before probe/discovery. func PrepareCredentialDiscovery(ctx context.Context) { - ApplyGatewayEnv(ctx, GatewayXiaomiTokenPlan) - ApplyGatewayEnv(ctx, gatewayZAIPayg) - ApplyGatewayEnv(ctx, gatewayZAICoding) + for _, providerID := range registry.CredentialEnvPreparedProviders() { + ApplyGatewayEnv(ctx, providerID) + } } // ApplyGatewayEnv applies derived env settings from provider.json for gateways that need them. diff --git a/runtime/transport.go b/runtime/transport.go index bb34c98..a8b17c2 100644 --- a/runtime/transport.go +++ b/runtime/transport.go @@ -3,6 +3,7 @@ package runtime import ( "context" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/client" ) @@ -64,15 +65,11 @@ func directChatProvider(ctx context.Context, primary string) client.Provider { func directFallbackProviderIDs(ctx context.Context, primary string) []string { primary = NormalizeProviderID(primary) - switch primary { - case "openai": - if providerConfigured(ctx, "anthropic") { - return []string{"anthropic"} - } - case "anthropic": - if providerConfigured(ctx, "openai") { - return []string{"openai"} + var out []string + for _, providerID := range registry.DirectFallbackProviderIDs(primary) { + if providerConfigured(ctx, providerID) { + out = append(out, providerID) } } - return nil + return out } From 6dd69b4ded40a2ebd36080a5786d70a8d68ab0b6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 30 Jun 2026 02:25:41 +0530 Subject: [PATCH 2/2] test: isolate opencodego protocol map tests --- catalog/opencodego/opencodego_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/catalog/opencodego/opencodego_test.go b/catalog/opencodego/opencodego_test.go index 8913e5e..7f59fd4 100644 --- a/catalog/opencodego/opencodego_test.go +++ b/catalog/opencodego/opencodego_test.go @@ -48,9 +48,9 @@ func TestProtocolForModel(t *testing.T) { } func TestUsesMessagesAPI_HeuristicFallback(t *testing.T) { - t.Parallel() // Reset map so we test the heuristic fallback. ResetProtocolMap() + t.Cleanup(ResetProtocolMap) tests := []struct { model string want bool @@ -75,8 +75,8 @@ func TestUsesMessagesAPI_HeuristicFallback(t *testing.T) { } func TestUsesMessagesAPI_DynamicMapOverrides(t *testing.T) { - t.Parallel() ResetProtocolMap() + t.Cleanup(ResetProtocolMap) // Simulate live fetch returning protocol data. UpdateProtocolMap([]struct{ ID, Protocol string }{ {"kimi-k2.6", "openai"}, @@ -100,13 +100,11 @@ func TestUsesMessagesAPI_DynamicMapOverrides(t *testing.T) { if UsesMessagesAPI("totally-new-model") { t.Error("totally-new-model should default to openai (heuristic fallback)") } - - ResetProtocolMap() } func TestProtocolMapSnapshot(t *testing.T) { - t.Parallel() ResetProtocolMap() + t.Cleanup(ResetProtocolMap) UpdateProtocolMap([]struct{ ID, Protocol string }{ {"kimi-k2.6", "openai"}, {"minimax-m3", "anthropic"}, @@ -118,7 +116,6 @@ func TestProtocolMapSnapshot(t *testing.T) { if snap["minimax-m3"] != "anthropic" { t.Errorf("snapshot minimax-m3 = %q, want anthropic", snap["minimax-m3"]) } - ResetProtocolMap() } func TestUsageTracker_RecordAndSpend(t *testing.T) {