diff --git a/.env.template b/.env.template index e6f243de..b5319cbf 100644 --- a/.env.template +++ b/.env.template @@ -234,6 +234,10 @@ # ============================================================================= # Provider API Keys (uncomment and set the ones you need) # ============================================================================= +# Add more instances with __*. +# Example: OPENAI_EAST_API_KEY and OPENAI_EAST_BASE_URL register provider openai-east. +# Underscores in the suffix become hyphens in the provider name. + # OpenAI # OPENAI_API_KEY=sk-... # OPENAI_BASE_URL=https://api.openai.com/v1 diff --git a/README.md b/README.md index 455ea530..fc437523 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ Example model identifiers are illustrative and subject to change; consult provid For Z.ai's GLM Coding Plan, set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4`. For Oracle, set `ORACLE_MODELS=openai.gpt-oss-120b,xai.grok-3` when the upstream `/models` endpoint is unavailable. +To register multiple instances of the same provider type without `config.yaml`, +use suffixed env vars such as `OPENAI_EAST_API_KEY` and +`OPENAI_EAST_BASE_URL`; this registers provider `openai-east` with type +`openai`. --- diff --git a/docs/advanced/config-yaml.mdx b/docs/advanced/config-yaml.mdx index f0938990..e028aca5 100644 --- a/docs/advanced/config-yaml.mdx +++ b/docs/advanced/config-yaml.mdx @@ -11,13 +11,21 @@ injection. Use `config.yaml` when you need structure that env vars cannot express cleanly, especially: -- multiple providers with the same type -- custom provider instance names such as `ollama-a`, `ollama-b`, or `my-openai` +- per-provider resilience overrides +- custom provider instance names that do not fit the generated + `-` env naming - larger nested config that is easier to review in one file +For multiple provider instances, env vars support +`__API_KEY`, `__BASE_URL`, and +`__MODELS`. The suffix becomes a hyphenated provider name: +`OPENAI_EAST_API_KEY` registers `openai-east`, and +`OLLAMA_A_BASE_URL` registers `ollama-a`. Azure also supports +`__API_VERSION`. + For Oracle specifically, a single fallback model list can now stay in env via -`ORACLE_MODELS`. Use YAML when you need custom Oracle provider names or more -than one Oracle provider instance. +`ORACLE_MODELS`. Use suffixed env vars such as `ORACLE_US_MODELS` for multiple +Oracle instances without YAML. ## Priority Order diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 5755d4c9..9e5a200b 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -227,8 +227,9 @@ providers: The YAML file is entirely optional. Any setting you can put in YAML can also - be set via environment variables. Use YAML when you need to configure custom - providers or prefer a structured config file. + be set via environment variables. Use YAML when you need per-provider + resilience overrides, generated provider names are not enough, or you prefer + a structured config file. ## Provider Configuration @@ -254,12 +255,29 @@ export ORACLE_MODELS="openai.gpt-oss-120b,xai.grok-3" # Optional fallback invent export OLLAMA_BASE_URL="http://localhost:11434/v1" # Registers "ollama" provider ``` -GoModel also works with additional OpenAI-compatible providers out of the box -through YAML provider blocks. +Use suffixed variables to register more than one instance of the same provider +type without YAML. GoModel normalizes the suffix to lowercase and converts +underscores to hyphens in the configured provider name: + +```bash +export OPENAI_EAST_API_KEY="sk-..." # Registers "openai-east", type "openai" +export OPENAI_EAST_BASE_URL="https://east.example.com/v1" + +export OPENAI_WEST_API_KEY="sk-..." # Registers "openai-west", type "openai" +export OPENAI_WEST_BASE_URL="https://west.example.com/v1" +``` + +The same pattern works for every registered provider type: +`__API_KEY`, `__BASE_URL`, and +`__MODELS`. Azure also supports +`__API_VERSION`. Azure and Oracle still require their +suffixed `BASE_URL` values because their endpoints are deployment- or +region-specific. ### YAML Provider Blocks -For more control (custom base URLs, model restrictions, or custom provider names), use the YAML file: +For more control (custom names, per-provider resilience, or larger structured +settings), use the YAML file: ```yaml providers: @@ -297,7 +315,8 @@ providers: For Oracle, give GoModel a fallback inventory with `ORACLE_MODELS` or `models:`. `ORACLE_MODELS` is enough for the default single-provider setup; - use YAML when you need custom provider names or multiple Oracle providers. + use suffixed env vars such as `ORACLE_US_MODELS` for env-only multi-provider + setups. Use YAML when you need custom names or larger provider blocks. See the [Oracle guide](/guides/oracle) for the required OCI policy and a tested configuration. Automatic model discovery is not yet a reliable, validated path for this provider: GoModel can try Oracle's OpenAI-compatible diff --git a/internal/providers/config.go b/internal/providers/config.go index dbc99d2b..316958cf 100644 --- a/internal/providers/config.go +++ b/internal/providers/config.go @@ -37,62 +37,262 @@ func resolveProviders(raw map[string]config.RawProviderConfig, global config.Res func applyProviderEnvVars(raw map[string]config.RawProviderConfig, discovery map[string]DiscoveryConfig) map[string]config.RawProviderConfig { result := make(map[string]config.RawProviderConfig, len(raw)) maps.Copy(result, raw) + environ := os.Environ() for _, providerType := range sortedDiscoveryTypes(discovery) { spec := discovery[providerType] - envNames := derivedEnvNames(providerType) - - apiKey := os.Getenv(envNames.APIKey) - explicitBaseURL := normalizeResolvedBaseURL(os.Getenv(envNames.BaseURL)) - models := parseCSVEnvList(os.Getenv(envNames.Models)) - apiVersion := "" - if spec.SupportsAPIVersion { - apiVersion = os.Getenv(envNames.APIVersion) + envGroups := collectProviderEnvValues(providerType, spec, environ) + + if values, ok := envGroups[""]; ok { + applyUnsuffixedProviderEnvVars(result, providerType, spec, values) } - baseURL := explicitBaseURL - if baseURL == "" && apiKey != "" && spec.DefaultBaseURL != "" { - baseURL = spec.DefaultBaseURL + + for _, suffix := range sortedProviderEnvSuffixes(envGroups) { + if suffix == "" { + continue + } + applySuffixedProviderEnvVars(result, providerType, spec, suffix, envGroups[suffix]) } + } + + return result +} + +type providerEnvField int + +const ( + providerEnvFieldAPIKey providerEnvField = iota + providerEnvFieldBaseURL + providerEnvFieldAPIVersion + providerEnvFieldModels +) + +type providerEnvValues struct { + APIKey string + BaseURL string + APIVersion string + Models []string +} - if apiKey == "" && baseURL == "" && apiVersion == "" && len(models) == 0 { +func (v providerEnvValues) empty() bool { + return strings.TrimSpace(v.APIKey) == "" && + strings.TrimSpace(v.BaseURL) == "" && + strings.TrimSpace(v.APIVersion) == "" && + len(v.Models) == 0 +} + +func collectProviderEnvValues(providerType string, spec DiscoveryConfig, environ []string) map[string]providerEnvValues { + groups := make(map[string]providerEnvValues) + prefix := envPrefix(providerType) + prefixWithSeparator := prefix + "_" + + for _, entry := range environ { + key, value, ok := strings.Cut(entry, "=") + if !ok || value == "" || !strings.HasPrefix(key, prefixWithSeparator) { continue } - targetKey, matched, ambiguous := findEnvOverlayTarget(result, providerType) - if matched { - existing := result[targetKey] - if apiKey != "" { - existing.APIKey = apiKey - } - if explicitBaseURL != "" { - existing.BaseURL = baseURL - } else if normalizeResolvedBaseURL(existing.BaseURL) == "" && apiKey != "" && spec.DefaultBaseURL != "" { - existing.BaseURL = spec.DefaultBaseURL - } - if apiVersion != "" { - existing.APIVersion = apiVersion - } - if len(models) > 0 { - existing.Models = models - } - result[targetKey] = existing - } else if ambiguous { + suffix, field, ok := parseProviderEnvKey(prefix, key, spec) + if !ok { continue - } else { - if spec.RequireBaseURL && explicitBaseURL == "" { - continue - } - result[providerType] = config.RawProviderConfig{ - Type: providerType, - APIKey: apiKey, - BaseURL: baseURL, - APIVersion: apiVersion, - Models: models, - } } + + values := groups[suffix] + switch field { + case providerEnvFieldAPIKey: + values.APIKey = value + case providerEnvFieldBaseURL: + values.BaseURL = normalizeResolvedBaseURL(value) + case providerEnvFieldAPIVersion: + values.APIVersion = value + case providerEnvFieldModels: + values.Models = parseCSVEnvList(value) + } + groups[suffix] = values } - return result + for suffix, values := range groups { + if values.empty() { + delete(groups, suffix) + } + } + + return groups +} + +func parseProviderEnvKey(prefix, key string, spec DiscoveryConfig) (string, providerEnvField, bool) { + rest, ok := strings.CutPrefix(key, prefix+"_") + if !ok { + return "", 0, false + } + + // Match field names from the right so suffixes can contain underscores. + // Keep longer field tokens before their shorter overlapping forms; for + // example, API_VERSION must be checked before a future VERSION-like token. + fields := []struct { + name string + field providerEnvField + }{ + {name: "API_VERSION", field: providerEnvFieldAPIVersion}, + {name: "BASE_URL", field: providerEnvFieldBaseURL}, + {name: "API_KEY", field: providerEnvFieldAPIKey}, + {name: "MODELS", field: providerEnvFieldModels}, + } + + for _, candidate := range fields { + if candidate.field == providerEnvFieldAPIVersion && !spec.SupportsAPIVersion { + continue + } + if rest == candidate.name { + return "", candidate.field, true + } + suffix, found := strings.CutSuffix(rest, "_"+candidate.name) + if found && validProviderEnvSuffix(suffix) { + return suffix, candidate.field, true + } + } + + return "", 0, false +} + +func validProviderEnvSuffix(suffix string) bool { + suffix = strings.TrimSpace(suffix) + if suffix == "" || strings.HasPrefix(suffix, "_") || strings.HasSuffix(suffix, "_") { + return false + } + + lastUnderscore := false + hasAlnum := false + for _, r := range suffix { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + hasAlnum = true + lastUnderscore = false + case r == '_' && !lastUnderscore: + lastUnderscore = true + default: + return false + } + } + return hasAlnum +} + +func sortedProviderEnvSuffixes(groups map[string]providerEnvValues) []string { + suffixes := make([]string, 0, len(groups)) + for suffix := range groups { + suffixes = append(suffixes, suffix) + } + sort.Strings(suffixes) + return suffixes +} + +func applyUnsuffixedProviderEnvVars(result map[string]config.RawProviderConfig, providerType string, spec DiscoveryConfig, values providerEnvValues) { + if values.empty() { + return + } + + targetKey, matched, ambiguous := findEnvOverlayTarget(result, providerType) + if matched { + result[targetKey] = overlayProviderEnvValues(result[targetKey], values, spec) + return + } + if ambiguous { + return + } + if spec.RequireBaseURL && values.BaseURL == "" { + return + } + + result[providerType] = values.rawConfig(providerType, spec) +} + +func applySuffixedProviderEnvVars(result map[string]config.RawProviderConfig, providerType string, spec DiscoveryConfig, suffix string, values providerEnvValues) { + if values.empty() { + return + } + + targetKey := providerNameForEnvSuffix(providerType, suffix) + if targetKey == "" { + return + } + + if existing, ok := result[targetKey]; ok { + if !rawProviderMatchesType(existing, providerType) { + return + } + result[targetKey] = overlayProviderEnvValues(existing, values, spec) + return + } + + if spec.RequireBaseURL && values.BaseURL == "" { + return + } + + result[targetKey] = values.rawConfig(providerType, spec) +} + +func (v providerEnvValues) rawConfig(providerType string, spec DiscoveryConfig) config.RawProviderConfig { + return config.RawProviderConfig{ + Type: providerType, + APIKey: v.APIKey, + BaseURL: v.resolvedBaseURL(spec), + APIVersion: v.APIVersion, + Models: v.Models, + } +} + +func (v providerEnvValues) resolvedBaseURL(spec DiscoveryConfig) string { + baseURL := strings.TrimSpace(v.BaseURL) + if baseURL == "" && strings.TrimSpace(v.APIKey) != "" && spec.DefaultBaseURL != "" { + return spec.DefaultBaseURL + } + return baseURL +} + +func overlayProviderEnvValues(existing config.RawProviderConfig, values providerEnvValues, spec DiscoveryConfig) config.RawProviderConfig { + if values.APIKey != "" { + existing.APIKey = values.APIKey + } + if values.BaseURL != "" { + existing.BaseURL = values.BaseURL + } else if normalizeResolvedBaseURL(existing.BaseURL) == "" && values.APIKey != "" && spec.DefaultBaseURL != "" { + existing.BaseURL = spec.DefaultBaseURL + } + if values.APIVersion != "" { + existing.APIVersion = values.APIVersion + } + if len(values.Models) > 0 { + existing.Models = values.Models + } + return existing +} + +func providerNameForEnvSuffix(providerType, suffix string) string { + providerType = strings.TrimSpace(providerType) + suffixName := normalizeEnvSuffixForProviderName(suffix) + if suffixName == "" { + return providerType + } + if providerType == "" { + return suffixName + } + return providerType + "-" + suffixName +} + +func normalizeEnvSuffixForProviderName(suffix string) string { + var b strings.Builder + lastHyphen := false + for _, r := range strings.TrimSpace(suffix) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(unicode.ToLower(r)) + lastHyphen = false + case r == '_' && !lastHyphen: + b.WriteByte('-') + lastHyphen = true + } + } + return strings.Trim(b.String(), "-") } func findEnvOverlayTarget(raw map[string]config.RawProviderConfig, providerType string) (string, bool, bool) { diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index 1a1e1f08..84b28310 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -402,6 +402,82 @@ func TestApplyProviderEnvVars_DiscoversZAIWithExplicitBaseURL(t *testing.T) { } } +func TestApplyProviderEnvVars_DiscoversMultipleSuffixedOpenAIProviders(t *testing.T) { + t.Setenv("OPENAI_EAST_API_KEY", "sk-east") + t.Setenv("OPENAI_EAST_BASE_URL", "https://east.example.com/v1") + t.Setenv("OPENAI_WEST_API_KEY", "sk-west") + t.Setenv("OPENAI_WEST_BASE_URL", "") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + east, exists := got["openai-east"] + if !exists { + t.Fatal("expected openai-east to be discovered from suffixed env vars") + } + if east.Type != "openai" { + t.Errorf("openai-east Type = %q, want openai", east.Type) + } + if east.APIKey != "sk-east" { + t.Errorf("openai-east APIKey = %q, want sk-east", east.APIKey) + } + if east.BaseURL != "https://east.example.com/v1" { + t.Errorf("openai-east BaseURL = %q, want https://east.example.com/v1", east.BaseURL) + } + + west, exists := got["openai-west"] + if !exists { + t.Fatal("expected openai-west to be discovered from suffixed env vars") + } + if west.Type != "openai" { + t.Errorf("openai-west Type = %q, want openai", west.Type) + } + if west.APIKey != "sk-west" { + t.Errorf("openai-west APIKey = %q, want sk-west", west.APIKey) + } + if west.BaseURL != testDiscoveryConfigs["openai"].DefaultBaseURL { + t.Errorf("openai-west BaseURL = %q, want %q", west.BaseURL, testDiscoveryConfigs["openai"].DefaultBaseURL) + } + if _, exists := got["openai"]; exists { + t.Fatal("expected suffixed OpenAI env vars not to create unsuffixed openai provider") + } +} + +func TestApplyProviderEnvVars_DiscoversSuffixedProvidersForEveryRegisteredType(t *testing.T) { + for providerType, spec := range testDiscoveryConfigs { + prefix := envPrefix(providerType) + t.Setenv(prefix+"_EAST_API_KEY", "key-"+providerType) + if spec.RequireBaseURL { + t.Setenv(prefix+"_EAST_BASE_URL", "https://"+providerType+".example.com/v1") + } else { + t.Setenv(prefix+"_EAST_BASE_URL", "") + } + } + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + for providerType, spec := range testDiscoveryConfigs { + name := providerType + "-east" + p, exists := got[name] + if !exists { + t.Fatalf("expected %s to be discovered from suffixed env vars", name) + } + if p.Type != providerType { + t.Errorf("%s Type = %q, want %q", name, p.Type, providerType) + } + if p.APIKey != "key-"+providerType { + t.Errorf("%s APIKey = %q, want %q", name, p.APIKey, "key-"+providerType) + } + if spec.RequireBaseURL { + wantBaseURL := "https://" + providerType + ".example.com/v1" + if p.BaseURL != wantBaseURL { + t.Errorf("%s BaseURL = %q, want %q", name, p.BaseURL, wantBaseURL) + } + } else if spec.DefaultBaseURL != "" && p.BaseURL != spec.DefaultBaseURL { + t.Errorf("%s BaseURL = %q, want %q", name, p.BaseURL, spec.DefaultBaseURL) + } + } +} + func TestApplyProviderEnvVars_DiscoversAzureFromExplicitEnvVars(t *testing.T) { t.Setenv("AZURE_API_KEY", "sk-azure") t.Setenv("AZURE_BASE_URL", "https://example-resource.openai.azure.com/openai/deployments/gpt-4o") @@ -468,6 +544,41 @@ func TestApplyProviderEnvVars_DoesNotDiscoverAzureWithoutBaseURL(t *testing.T) { } } +func TestApplyProviderEnvVars_DiscoversSuffixedAzureWithAPIVersion(t *testing.T) { + t.Setenv("AZURE_GPT4O_API_KEY", "sk-azure") + t.Setenv("AZURE_GPT4O_BASE_URL", "https://example-resource.openai.azure.com/openai/deployments/gpt-4o") + t.Setenv("AZURE_GPT4O_API_VERSION", "2025-04-01-preview") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["azure-gpt4o"] + if !exists { + t.Fatal("expected azure-gpt4o to be discovered from suffixed env vars") + } + if p.Type != "azure" { + t.Errorf("Type = %q, want azure", p.Type) + } + if p.APIKey != "sk-azure" { + t.Errorf("APIKey = %q, want sk-azure", p.APIKey) + } + if p.BaseURL != "https://example-resource.openai.azure.com/openai/deployments/gpt-4o" { + t.Errorf("BaseURL = %q, want Azure API base", p.BaseURL) + } + if p.APIVersion != "2025-04-01-preview" { + t.Errorf("APIVersion = %q, want 2025-04-01-preview", p.APIVersion) + } +} + +func TestApplyProviderEnvVars_DoesNotDiscoverSuffixedAzureWithoutBaseURL(t *testing.T) { + t.Setenv("AZURE_EAST_API_KEY", "sk-azure") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + if _, exists := got["azure-east"]; exists { + t.Fatal("expected azure-east not to be discovered without AZURE_EAST_BASE_URL") + } +} + func TestApplyProviderEnvVars_DiscoversOracleFromExplicitEnvVars(t *testing.T) { t.Setenv("ORACLE_API_KEY", "oracle-key") t.Setenv("ORACLE_BASE_URL", "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/v1") @@ -493,6 +604,31 @@ func TestApplyProviderEnvVars_DiscoversOracleFromExplicitEnvVars(t *testing.T) { } } +func TestApplyProviderEnvVars_DiscoversSuffixedOracleModels(t *testing.T) { + t.Setenv("ORACLE_REGION_API_KEY", "oracle-key") + t.Setenv("ORACLE_REGION_BASE_URL", "https://oracle.example.com/v1") + t.Setenv("ORACLE_REGION_MODELS", " openai.gpt-oss-120b, xai.grok-3 ,, ") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["oracle-region"] + if !exists { + t.Fatal("expected oracle-region to be discovered from suffixed env vars") + } + if p.Type != "oracle" { + t.Errorf("Type = %q, want oracle", p.Type) + } + if p.APIKey != "oracle-key" { + t.Errorf("APIKey = %q, want oracle-key", p.APIKey) + } + if p.BaseURL != "https://oracle.example.com/v1" { + t.Errorf("BaseURL = %q, want https://oracle.example.com/v1", p.BaseURL) + } + if len(p.Models) != 2 || p.Models[0] != "openai.gpt-oss-120b" || p.Models[1] != "xai.grok-3" { + t.Errorf("Models = %v, want [openai.gpt-oss-120b xai.grok-3]", p.Models) + } +} + func TestApplyProviderEnvVars_DoesNotDiscoverOracleWithoutBaseURL(t *testing.T) { t.Setenv("ORACLE_API_KEY", "oracle-key") @@ -659,6 +795,39 @@ func TestApplyProviderEnvVars_PreservesYAMLResilience(t *testing.T) { } } +func TestApplyProviderEnvVars_SuffixedEnvOverlaysMatchingYAMLProvider(t *testing.T) { + t.Setenv("OPENAI_EAST_API_KEY", "sk-env-key") + t.Setenv("OPENAI_EAST_BASE_URL", "https://env.example.com/v1") + + maxRetries := 10 + raw := map[string]config.RawProviderConfig{ + "openai-east": { + Type: "openai", + APIKey: "sk-yaml-key", + BaseURL: "https://yaml.example.com/v1", + Resilience: &config.RawResilienceConfig{ + Retry: &config.RawRetryConfig{MaxRetries: &maxRetries}, + }, + }, + } + + got := applyProviderEnvVars(raw, testDiscoveryConfigs) + + p := got["openai-east"] + if p.APIKey != "sk-env-key" { + t.Errorf("APIKey = %q, want sk-env-key", p.APIKey) + } + if p.BaseURL != "https://env.example.com/v1" { + t.Errorf("BaseURL = %q, want https://env.example.com/v1", p.BaseURL) + } + if p.Resilience == nil || p.Resilience.Retry == nil { + t.Fatal("expected YAML resilience to be preserved after suffixed env var overlay") + } + if *p.Resilience.Retry.MaxRetries != 10 { + t.Errorf("MaxRetries = %d, want 10", *p.Resilience.Retry.MaxRetries) + } +} + func TestApplyProviderEnvVars_SkipsWhenNoEnvVars(t *testing.T) { // Ensure no ambient env vars interfere for providerType, spec := range testDiscoveryConfigs { @@ -859,6 +1028,52 @@ func TestResolveProviders_EmptyRaw_OnlyEnvVars(t *testing.T) { } } +func TestResolveProviders_EmptyRaw_SuffixedEnvVars(t *testing.T) { + t.Setenv("OPENAI_EAST_API_KEY", "sk-east") + t.Setenv("OPENAI_WEST_API_KEY", "sk-west") + t.Setenv("OPENAI_WEST_BASE_URL", "https://west.example.com/v1") + + got, filteredRaw := resolveProviders(map[string]config.RawProviderConfig{}, globalResilience, testDiscoveryConfigs) + + east, exists := got["openai-east"] + if !exists { + t.Fatal("expected openai-east in resolved providers") + } + if east.Type != "openai" { + t.Errorf("openai-east Type = %q, want openai", east.Type) + } + if east.APIKey != "sk-east" { + t.Errorf("openai-east APIKey = %q, want sk-east", east.APIKey) + } + if east.BaseURL != testDiscoveryConfigs["openai"].DefaultBaseURL { + t.Errorf("openai-east BaseURL = %q, want %q", east.BaseURL, testDiscoveryConfigs["openai"].DefaultBaseURL) + } + + west, exists := got["openai-west"] + if !exists { + t.Fatal("expected openai-west in resolved providers") + } + if west.Type != "openai" { + t.Errorf("openai-west Type = %q, want openai", west.Type) + } + if west.APIKey != "sk-west" { + t.Errorf("openai-west APIKey = %q, want sk-west", west.APIKey) + } + if west.BaseURL != "https://west.example.com/v1" { + t.Errorf("openai-west BaseURL = %q, want https://west.example.com/v1", west.BaseURL) + } + + if filteredRaw["openai-east"].APIKey != "sk-east" { + t.Errorf("filteredRaw openai-east APIKey = %q, want sk-east", filteredRaw["openai-east"].APIKey) + } + if filteredRaw["openai-west"].BaseURL != "https://west.example.com/v1" { + t.Errorf("filteredRaw openai-west BaseURL = %q, want https://west.example.com/v1", filteredRaw["openai-west"].BaseURL) + } + if _, exists := got["openai"]; exists { + t.Fatal("expected no unsuffixed openai provider from suffixed env vars") + } +} + func TestResolveProviders_SingleCustomNamedProviderDoesNotDuplicateTypeKey(t *testing.T) { t.Setenv("OPENAI_API_KEY", "sk-openai")