Files
李怡霖 · Yilin Li 1471cfa486 fix(config): support timeout_sec in config set (#1179)
* fix(config): support timeout_sec in config set

* fix(config): validate timeout overflow consistently

* fix(config): guard timeout updates for missing custom providers
2026-09-08 16:00:42 +08:00

981 lines
32 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/alibaba/open-code-review/internal/config/template"
"github.com/alibaba/open-code-review/internal/llm"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage configuration settings",
Long: `Configuration management.
Examples:
# Provider setup (interactive)
ocr config provider
ocr config model
# Provider setup (non-interactive)
ocr config set provider anthropic
ocr config set model claude-opus-4-6
ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY"
# Custom provider
ocr config set provider my-gateway
ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1
ocr config set custom_providers.my-gateway.protocol openai`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Example: " ocr config set llm.model claude-opus-4-6\n ocr config set provider anthropic",
Args: exactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigSet(args[0], args[1])
},
}
var configUnsetCmd = &cobra.Command{
Use: "unset <key>",
Short: "Remove a configuration value",
Long: "Remove a provider, custom_providers.<name>, or mcp_servers.<name>.",
Example: " ocr config unset provider\n ocr config unset custom_providers.my-provider\n ocr config unset mcp_servers.github",
Args: exactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigUnset(args[0])
},
}
var configProviderCmd = &cobra.Command{
Use: "provider",
Short: "Interactive provider setup",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigProvider()
},
}
var configModelCmd = &cobra.Command{
Use: "model",
Short: "Interactive model selection",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigModel()
},
}
func init() {
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configUnsetCmd)
configCmd.AddCommand(configProviderCmd)
configCmd.AddCommand(configModelCmd)
}
// Default config file location: ~/.opencodereview/config.json
func defaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot determine home directory: %w", err)
}
return filepath.Join(home, ".opencodereview", "config.json"), nil
}
// resolveConfigPath returns OCR_CONFIG_PATH when set, otherwise the default user config path.
// Intentionally used only by read-only commands (e.g. ocr llm test). Write paths such as
// config set and review keep defaultConfigPath() so a leaked OCR_CONFIG_PATH cannot redirect writes.
func resolveConfigPath() (string, error) {
if p := strings.TrimSpace(os.Getenv("OCR_CONFIG_PATH")); p != "" {
return p, nil
}
return defaultConfigPath()
}
func runConfigSet(key, value string) error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if err := setConfigValue(cfg, key, value); err != nil {
return err
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
displayValue := value
if shouldMaskConfigValue(key) {
displayValue = maskKey(value)
}
fmt.Printf("Set %s = %s\n", key, displayValue)
if warning := legacyLLMShadowWarning(cfg.Provider, key); warning != "" {
fmt.Fprint(os.Stderr, warning)
}
return nil
}
// shouldMaskConfigValue reports whether the echoed value of a config key holds a
// secret and must be masked. Matching on the normalized suffix covers both
// snake_case and Go field spellings of api_key/auth_token at any path depth,
// while the *_cmd variants stay unmasked: a command line is not a secret.
func shouldMaskConfigValue(key string) bool {
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
return strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken")
}
func runConfigUnset(key string) error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
if key == "provider" {
return unsetActiveProvider(configPath)
}
if key == "max_tokens" {
return unsetMaxTokens(configPath)
}
if key == "effort" {
return unsetEffort(configPath)
}
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 || parts[1] == "" {
return fmt.Errorf("unset supports provider, max_tokens, effort, custom_providers.<name>, and mcp_servers.<name>")
}
switch parts[0] {
case "custom_providers":
return unsetCustomProvider(configPath, parts[1])
case "mcp_servers":
return unsetMCPServer(configPath, parts[1])
default:
return fmt.Errorf("unset supports provider, max_tokens, effort, custom_providers.<name>, and mcp_servers.<name>")
}
}
func unsetMaxTokens(configPath string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.MaxTokens = 0
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("Cleared max_tokens; using the embedded template default.")
return nil
}
func unsetEffort(configPath string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.Effort = ""
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("Cleared effort; using the default medium preset.")
return nil
}
func unsetActiveProvider(configPath string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.Provider = ""
cfg.Model = ""
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("Cleared active provider and model.")
return nil
}
func legacyLLMShadowWarning(provider, key string) string {
if provider == "" || !strings.HasPrefix(key, "llm.") {
return ""
}
section := "custom_providers"
if _, isPreset := llm.LookupProvider(provider); isPreset {
section = "providers"
}
return fmt.Sprintf("[ocr] WARNING: provider %q is active and takes precedence over llm.* settings.\n"+
"[ocr] Use 'ocr config set %s.%s.<field> <value>' to configure the active provider,\n"+
"[ocr] or run 'ocr config unset provider' to disable provider-based config.\n", provider, section, provider)
}
func unsetCustomProvider(configPath, name string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
wasActive, err := deleteCustomProvider(cfg, name)
if err != nil {
return err
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("Deleted custom provider %q.\n", name)
if wasActive {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: active provider was deleted; 'provider' and 'model' have been cleared.\n")
fmt.Fprintf(os.Stderr, "[ocr] Run 'ocr config provider' to select a new provider.\n")
}
return nil
}
func unsetMCPServer(configPath, name string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.MCPServers == nil {
return fmt.Errorf("MCP server %q not found", name)
}
if _, exists := cfg.MCPServers[name]; !exists {
return fmt.Errorf("MCP server %q not found", name)
}
delete(cfg.MCPServers, name)
if len(cfg.MCPServers) == 0 {
cfg.MCPServers = nil
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("Deleted MCP server %q.\n", name)
return nil
}
// deleteCustomProvider removes a custom provider from cfg in memory.
// Returns true if the deleted provider was the active one.
func deleteCustomProvider(cfg *Config, name string) (bool, error) {
if cfg.CustomProviders == nil {
return false, fmt.Errorf("custom provider %q not found", name)
}
if _, exists := cfg.CustomProviders[name]; !exists {
return false, fmt.Errorf("custom provider %q not found", name)
}
wasActive := cfg.Provider == name
delete(cfg.CustomProviders, name)
if len(cfg.CustomProviders) == 0 {
cfg.CustomProviders = nil
}
if wasActive {
cfg.Provider = ""
cfg.Model = ""
}
return wasActive, nil
}
// ProviderEntry holds per-provider configuration in the providers map.
type ProviderEntry struct {
APIKey string `json:"api_key,omitempty"`
APIKeyCmd string `json:"api_key_cmd,omitempty"` // shell command whose stdout is the api key; used when api_key is empty
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
Models []string `json:"models,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
RetryCodes []int `json:"retry_codes,omitempty"`
// AWSProfile and AWSRegion pin the credentials and region for providers that
// authenticate from the AWS chain (bedrock). Both are optional — without
// them the standard chain decides, as with any other AWS tool. They must
// exist here as well as in the resolver's own view of the file: config is
// unmarshalled into this struct and marshalled back on every write, so a
// field missing from it is silently dropped from a hand-written config the
// first time any config command runs.
AWSProfile string `json:"aws_profile,omitempty"`
AWSRegion string `json:"aws_region,omitempty"`
}
// MCPServerConfig holds configuration for a single MCP server.
// Type "stdio" (default) uses a subprocess; type "remote" uses Streamable HTTP.
type MCPServerConfig struct {
Type string `json:"type,omitempty"` // "stdio" (default) or "remote"
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Tools []string `json:"tools,omitempty"`
Setup string `json:"setup,omitempty"`
}
// Config represents the user-level configuration file (~/.opencodereview/config.json).
type Config struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Effort string `json:"effort,omitempty"`
Providers map[string]ProviderEntry `json:"providers,omitempty"`
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"`
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
MCPServers map[string]MCPServerConfig `json:"mcp_servers,omitempty"`
}
type LlmConfig struct {
URL string `json:"url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
AuthTokenCmd string `json:"auth_token_cmd,omitempty"` // shell command whose stdout is the auth token; used when auth_token is empty
AuthHeader string `json:"auth_header,omitempty"`
Model string `json:"model,omitempty"`
Protocol string `json:"protocol,omitempty"` // canonical protocol name; takes priority over UseAnthropic
UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol (legacy fallback)
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
RetryCodes []int `json:"retry_codes,omitempty"`
}
// TelemetryConfig holds telemetry-specific settings.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // Master switch for telemetry
Exporter string `json:"exporter,omitempty"` // "console" or "otlp"
OTLPEndpoint string `json:"otlp_endpoint,omitempty"` // OTLP collector address
ContentLog bool `json:"content_logging,omitempty"` // Include prompt/response content
}
func loadOrCreateConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil
}
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
// LoadAppConfig loads config from path. Returns nil, nil if file does not exist.
func LoadAppConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read app config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse app config: %w", err)
}
return &cfg, nil
}
// supportedConfigKeys is the single source of truth for the top-level config
// keys accepted by setConfigValue. The unknown-key error message is generated
// from this list so the two cannot drift apart when a new key is added.
var supportedConfigKeys = []string{
"provider",
"model",
"max_tokens",
"effort",
"providers.<name>.<field>",
"custom_providers.<name>.<field>",
"mcp_servers.<name>.<field>",
"llm.url",
"llm.auth_token",
"llm.auth_token_cmd",
"llm.auth_header",
"llm.model",
"llm.timeout_sec",
"llm.protocol",
"llm.use_anthropic",
"llm.extra_body",
"llm.extra_headers",
"llm.retry_codes",
"language",
"telemetry.enabled",
"telemetry.exporter",
"telemetry.otlp_endpoint",
"telemetry.content_logging",
}
func setConfigValue(cfg *Config, key, value string) error {
// Handle providers.<name>.<field> paths.
if strings.HasPrefix(key, "providers.") {
return setProviderValue(cfg, key, value)
}
if strings.HasPrefix(key, "custom_providers.") {
return setCustomProviderValue(cfg, key, value)
}
if strings.HasPrefix(key, "mcp_servers.") {
return setMCPServerValue(cfg, key, value)
}
switch key {
case "provider":
if cfg.Provider != value {
cfg.Model = ""
}
cfg.Provider = value
if _, isPreset := llm.LookupProvider(value); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
if _, exists := cfg.Providers[value]; !exists {
cfg.Providers[value] = ProviderEntry{}
}
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
if _, exists := cfg.CustomProviders[value]; !exists {
cfg.CustomProviders[value] = ProviderEntry{}
}
}
case "model":
if cfg.Provider != "" {
if _, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = value
cfg.Providers[cfg.Provider] = entry
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = value
cfg.CustomProviders[cfg.Provider] = entry
}
} else {
cfg.Model = value
}
case "max_tokens":
maxTokens, err := strconv.Atoi(value)
if err != nil || maxTokens <= 0 {
return fmt.Errorf("invalid max_tokens %q: must be a positive integer", value)
}
cfg.MaxTokens = maxTokens
case "effort":
e, err := template.ParseEffort(value)
if err != nil {
return err
}
cfg.Effort = string(e)
case "llm.url", "llm.URL":
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":
cfg.Llm.AuthToken = value
case "llm.auth_token_cmd", "llm.AuthTokenCmd":
cfg.Llm.AuthTokenCmd = value
case "llm.auth_header", "llm.AuthHeader":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
return err
}
cfg.Llm.AuthHeader = normalized
case "llm.extra_headers", "llm.ExtraHeaders":
parsed, err := llm.ParseExtraHeaders(value)
if err != nil {
return err
}
cfg.Llm.ExtraHeaders = parsed
case "llm.model", "llm.Model":
cfg.Llm.Model = value
case "llm.timeout_sec", "llm.TimeoutSec":
timeout, err := parseTimeoutSeconds(value)
if err != nil {
return fmt.Errorf("invalid timeout_sec: %w", err)
}
cfg.Llm.TimeoutSec = timeout
case "llm.protocol", "llm.Protocol":
normalized := llm.NormalizeProtocol(value)
if err := llm.ValidateProtocol(normalized); err != nil {
return err
}
// The llm block is a single url + token endpoint. Bedrock needs neither
// and has nowhere here to put a region or a profile, so it is refused at
// the point of setting rather than accepted and ignored at resolve time.
if normalized == llm.ProtocolAnthropicBedrock {
return fmt.Errorf("llm.protocol cannot be %q: bedrock derives its host from aws_region and signs with the AWS credential chain, so it has no use for llm.url or llm.auth_token; run `ocr config set provider bedrock` instead", normalized)
}
cfg.Llm.Protocol = normalized
// Mirror use_anthropic so older binaries that predate llm.protocol
// still pick the right protocol family: anthropic -> true, the OpenAI
// family (including openai-responses) -> false.
if normalized == llm.ProtocolAnthropic {
t := true
cfg.Llm.UseAnthropic = &t
} else {
f := false
cfg.Llm.UseAnthropic = &f
}
case "llm.use_anthropic", "llm.UseAnthropic":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for llm.use_anthropic: %w", err)
}
cfg.Llm.UseAnthropic = &b
// Mirror protocol for backward compatibility. true always selects
// anthropic. false only mirrors to the legacy openai default when
// protocol is unset or already a legacy value (anthropic/openai);
// openai-responses is preserved to avoid a silent downgrade.
if b {
cfg.Llm.Protocol = llm.ProtocolAnthropic
} else if cfg.Llm.Protocol == "" || cfg.Llm.Protocol == llm.ProtocolAnthropic || cfg.Llm.Protocol == llm.ProtocolOpenAIChatCompletions {
cfg.Llm.Protocol = llm.ProtocolOpenAIChatCompletions
}
case "language", "Language":
cfg.Language = value
case "telemetry.enabled", "telemetry.Enabled":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.enabled: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.Enabled = b
case "telemetry.exporter", "telemetry.Exporter":
cfg.ensureTelemetry()
cfg.Telemetry.Exporter = value
case "telemetry.otlp_endpoint", "telemetry.OTLPEndpoint":
cfg.ensureTelemetry()
cfg.Telemetry.OTLPEndpoint = value
case "telemetry.content_logging", "telemetry.ContentLog":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.content_logging: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.ContentLog = b
case "llm.extra_body", "llm.ExtraBody":
var m map[string]any
if err := json.Unmarshal([]byte(value), &m); err != nil {
return fmt.Errorf("invalid JSON for llm.extra_body: %w", err)
}
cfg.Llm.ExtraBody = m
case "llm.retry_codes", "llm.RetryCodes":
codes, warnings, err := llm.ParseRetryCodes(value)
if err != nil {
return err
}
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: %s\n", w)
}
cfg.Llm.RetryCodes = codes
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: %s\nProvider fields: api_key, api_key_cmd, url, protocol, model, models, auth_header, timeout_sec, extra_body, extra_headers, retry_codes, aws_region, aws_profile\nProtocol values: anthropic, anthropic-bedrock, openai, openai-responses\nMCP server fields: type, command, args, env, url, headers, tools, setup", key, strings.Join(supportedConfigKeys, ", "))
}
return nil
}
func applyProviderField(providerName string, entry *ProviderEntry, field, key, value string) error {
switch field {
case "api_key":
entry.APIKey = value
case "api_key_cmd":
entry.APIKeyCmd = value
case "url":
trimmedURL := strings.TrimSpace(value)
if trimmedURL != "" {
if err := validateBaseURL(trimmedURL); err != nil {
return fmt.Errorf("invalid URL for %s: %w", key, err)
}
}
entry.URL = trimmedURL
case "protocol":
normalized := llm.NormalizeProtocol(value)
if err := llm.ValidateProtocol(normalized); err != nil {
return err
}
entry.Protocol = normalized
// Switching away from bedrock leaves aws_region/aws_profile as dead
// config that reads as applied but nothing reads it — clear both, the
// same way the TUI drops url/api_key/auth_header when switching onto
// bedrock (see cpAmbientProtocol in provider_tui.go).
if normalized != llm.ProtocolAnthropicBedrock && (entry.AWSRegion != "" || entry.AWSProfile != "") {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: clearing aws_region/aws_profile on %q: protocol %q does not use the AWS credential chain\n", providerName, normalized)
entry.AWSRegion = ""
entry.AWSProfile = ""
}
case "model":
entry.Model = value
case "models":
models, err := parseModelListValue(value)
if err != nil {
return fmt.Errorf("invalid model list for %s: %w", key, err)
}
entry.Models = models
case "auth_header":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
return err
}
entry.AuthHeader = normalized
case "extra_body":
var m map[string]any
if err := json.Unmarshal([]byte(value), &m); err != nil {
return fmt.Errorf("invalid JSON for %s: %w", key, err)
}
entry.ExtraBody = m
case "extra_headers":
parsed, err := llm.ParseExtraHeaders(value)
if err != nil {
return fmt.Errorf("invalid extra headers for %s: %w", key, err)
}
entry.ExtraHeaders = parsed
case "retry_codes":
codes, warnings, err := llm.ParseRetryCodes(value)
if err != nil {
return fmt.Errorf("invalid retry codes for %s: %w", key, err)
}
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: %s\n", w)
}
entry.RetryCodes = codes
case "timeout_sec":
timeout, err := parseTimeoutSeconds(value)
if err != nil {
return fmt.Errorf("invalid timeout_sec for %s: %w", key, err)
}
entry.TimeoutSec = timeout
case "aws_region", "aws_profile":
normalized, err := normalizeAWSSetting(field, key, value)
if err != nil {
return err
}
if !providerAcceptsAWSSettings(providerName, entry) {
return fmt.Errorf("%s does not apply to provider %q: aws_region and aws_profile are only used by providers that authenticate from the AWS credential chain (protocol %s)", field, providerName, llm.ProtocolAnthropicBedrock)
}
if field == "aws_region" {
entry.AWSRegion = normalized
} else {
entry.AWSProfile = normalized
}
default:
return fmt.Errorf("unknown provider field %q: supported fields are api_key, api_key_cmd, url, protocol, model, models, auth_header, timeout_sec, extra_body, extra_headers, retry_codes, aws_region, aws_profile", field)
}
return nil
}
func parseTimeoutSeconds(value string) (int, error) {
seconds, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("must be an integer, got %q", value)
}
if _, err := llm.ValidateTimeoutSec(seconds); err != nil {
return 0, err
}
return seconds, nil
}
// providerAcceptsAWSSettings reports whether aws_region / aws_profile mean
// anything for this provider. Storing them anywhere else would be dead config
// that reads as applied, so it is rejected instead.
//
// The entry's own protocol decides whenever it sets one: a preset's protocol can
// be overridden per entry (see tryProviderConfig), so `protocol: openai` on the
// bedrock preset would otherwise still accept AWS settings that nothing reads.
// Only when the entry is silent does the preset's own AmbientAuth flag answer.
func providerAcceptsAWSSettings(providerName string, entry *ProviderEntry) bool {
if entry.Protocol != "" {
return llm.NormalizeProtocol(entry.Protocol) == llm.ProtocolAnthropicBedrock
}
preset, isPreset := llm.LookupProvider(providerName)
return isPreset && preset.AmbientAuth
}
// normalizeAWSSetting trims the value and rejects the shapes AWS itself will
// not accept. Region names are deliberately not checked against a fixed list:
// AWS adds regions faster than any embedded list stays correct, and a wrong one
// already surfaces at request time.
func normalizeAWSSetting(field, key, value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil // clearing the field hands the decision back to the AWS chain
}
if strings.ContainsAny(trimmed, " \t\n") {
return "", fmt.Errorf("invalid %s for %s: %q contains whitespace", field, key, value)
}
return trimmed, nil
}
func parseModelListValue(value string) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
if strings.HasPrefix(value, "[") {
var models []string
if err := json.Unmarshal([]byte(value), &models); err == nil {
return normalizeModelList(models), nil
}
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
}
return normalizeModelList(strings.Split(value, ",")), nil
}
func activeModelForProvider(cfg *Config, providerName string, entry ProviderEntry) string {
if entry.Model != "" {
return entry.Model
}
if cfg != nil && cfg.Provider == providerName && cfg.Model != "" {
return cfg.Model
}
return ""
}
func normalizeModelList(models []string) []string {
out := make([]string, 0, len(models))
seen := make(map[string]struct{}, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
out = append(out, model)
}
return out
}
func mergeModelLists(lists ...[]string) []string {
var merged []string
for _, list := range lists {
merged = append(merged, list...)
}
return normalizeModelList(merged)
}
// ensureModelInList appends model to the end when missing; never reorders existing entries.
func ensureModelInList(models []string, model string) []string {
model = strings.TrimSpace(model)
if model == "" {
return models
}
if llm.ModelListContains(models, model) {
return models
}
out := append([]string(nil), models...)
return append(out, model)
}
func setProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid provider key %q: expected providers.<name>.<field>", key)
}
if _, isPreset := llm.LookupProvider(parts[1]); !isPreset {
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[parts[1]]
if err := applyProviderField(parts[1], &entry, parts[2], key, value); err != nil {
return err
}
cfg.Providers[parts[1]] = entry
return nil
}
func setCustomProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
}
if preset, isPreset := llm.LookupProvider(parts[1]); isPreset {
return fmt.Errorf("custom provider name %q conflicts with a preset provider; use providers.%s.%s to configure the preset or choose a different custom provider name", parts[1], preset.Name, parts[2])
}
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
func isAuxiliaryProviderField(field string) bool {
switch field {
case "extra_body", "extra_headers", "retry_codes", "timeout_sec":
return true
default:
return false
}
}
func setCustomProviderField(cfg *Config, name, field, key, value string) error {
if _, exists := cfg.CustomProviders[name]; isAuxiliaryProviderField(field) && !exists {
providerKey := strings.TrimSuffix(key, "."+field)
return fmt.Errorf("provider %q is not configured; set a core field first (protocol is required for every custom provider):\n ocr config set %s.protocol <protocol>", name, providerKey)
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[name]
if err := applyProviderField(name, &entry, field, key, value); err != nil {
return err
}
cfg.CustomProviders[name] = entry
return nil
}
func setMCPServerValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid MCP server key %q: expected mcp_servers.<name>.<field>", key)
}
name, field := parts[1], parts[2]
if cfg.MCPServers == nil {
cfg.MCPServers = make(map[string]MCPServerConfig)
}
entry := cfg.MCPServers[name]
switch field {
case "type":
if value != "stdio" && value != "remote" {
return fmt.Errorf("invalid MCP server type %q: must be \"stdio\" or \"remote\"", value)
}
entry.Type = value
case "command":
if value == "" {
return fmt.Errorf("MCP server command cannot be empty")
}
entry.Command = value
case "args":
var args []string
if err := json.Unmarshal([]byte(value), &args); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
entry.Args = args
case "env":
var env []string
if err := json.Unmarshal([]byte(value), &env); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
for _, e := range env {
idx := strings.Index(e, "=")
if idx <= 0 {
return fmt.Errorf("invalid env entry %q: must be in KEY=VALUE format", e)
}
}
entry.Env = env
case "url":
if value == "" {
return fmt.Errorf("MCP server URL cannot be empty")
}
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid MCP server URL %q: %w", value, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server URL must use http or https scheme, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return fmt.Errorf("MCP server URL %q must include a host", value)
}
entry.URL = value
case "headers":
parsed, err := parseMCPHeaders(value)
if err != nil {
return fmt.Errorf("invalid headers for %s: %w", key, err)
}
entry.Headers = parsed
case "tools":
var tools []string
if err := json.Unmarshal([]byte(value), &tools); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
seen := make(map[string]struct{}, len(tools))
filtered := make([]string, 0, len(tools))
for _, t := range tools {
if t == "" {
return fmt.Errorf("tool names in %s must not be empty", key)
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
filtered = append(filtered, t)
}
entry.Tools = filtered
case "setup":
entry.Setup = value
default:
return fmt.Errorf("unknown MCP server field %q: supported fields are type, command, args, env, url, headers, tools, setup", field)
}
cfg.MCPServers[name] = entry
return nil
}
// parseMCPHeaders parses a JSON object of header key-value pairs.
// Example: {"Authorization": "Bearer $TOKEN", "X-Custom": "value"}
func parseMCPHeaders(value string) (map[string]string, error) {
var m map[string]string
if err := json.Unmarshal([]byte(value), &m); err != nil {
return nil, fmt.Errorf("expected JSON object: %w", err)
}
for k, v := range m {
if k == "" {
return nil, fmt.Errorf("header name must not be empty")
}
if v == "" {
return nil, fmt.Errorf("header value for %q must not be empty", k)
}
}
return m, nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}
}
}