diff --git a/config/provider_env.go b/config/provider_env.go index 459e37c..4c5b8e3 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" ) // ProviderConfig mirrors the Hawk provider.json file. @@ -252,6 +253,26 @@ var providerFields = map[string]providerFieldMap{ Models: func(c *ProviderConfig) []string { return []string{c.StepFunModel} }, BaseURL: func(c *ProviderConfig) string { return c.StepFunBaseURL }, }, + ProviderOpenGateway: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.OpenGatewayAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.OpenGatewayModel} }, + BaseURL: func(c *ProviderConfig) string { return c.OpenGatewayBaseURL }, + }, + ProviderAgnes: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.AgnesAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.AgnesModel} }, + BaseURL: func(c *ProviderConfig) string { return c.AgnesBaseURL }, + }, + ProviderMiniMaxTokenPlan: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.MiniMaxTokenPlanAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.MiniMaxModel} }, + BaseURL: func(c *ProviderConfig) string { return c.MiniMaxTokenPlanBaseURL }, + }, + ProviderMiniMaxPayg: { + APIKeys: func(c *ProviderConfig) []string { return []string{c.MiniMaxPaygAPIKey} }, + Models: func(c *ProviderConfig) []string { return []string{c.MiniMaxModel} }, + BaseURL: func(c *ProviderConfig) string { return c.MiniMaxPaygBaseURL }, + }, } func firstNonEmpty(ss ...string) string { @@ -316,12 +337,28 @@ func GetProviderModel(config *ProviderConfig, provider string) string { return "" } -// GetProviderAPIKey returns the configured API key for a provider. +// GetProviderAPIKey returns the configured API key for a provider. Credential +// env var names come from the provider registry, so new providers are covered +// by their registry entry alone. func GetProviderAPIKey(config *ProviderConfig, provider string) string { - if f, ok := providerFields[provider]; ok { - for _, k := range f.APIKeys(config) { - if v := AsNonEmptyString(k); v != "" { - return v + if config == nil { + return "" + } + spec, ok := registry.SpecByProviderID(provider) + if !ok { + return "" + } + credentialEnvs := []string{strings.TrimSpace(spec.CredentialEnv)} + if runtime := strings.TrimSpace(spec.RuntimeCredentialEnv); runtime != "" { + credentialEnvs = append(credentialEnvs, runtime) + } + for _, env := range credentialEnvs { + for _, field := range providerCredentialFields { + if field.env != env { + continue + } + if value := AsNonEmptyString(field.value(config)); value != "" { + return value } } } diff --git a/config/provider_secrets.go b/config/provider_secrets.go index 157d43a..ae03318 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -4,46 +4,161 @@ import ( "fmt" "sort" "strings" + + "github.com/GrayCodeAI/eyrie/catalog/registry" ) -// ProviderConfigContainsSecrets reports whether legacy provider state contains -// credential material. It never returns or formats credential values. +// providerCredentialField maps one ProviderConfig field to the canonical +// secret-store environment variable for its provider. The env var must match +// the provider's registry CredentialEnv so that setup, discovery, and the +// credential store agree on a single key per provider. +type providerCredentialField struct { + label string // json field name, used in diagnostics + env string // canonical secret-store environment variable + value func(*ProviderConfig) string + clear func(*ProviderConfig) +} + +// providerCredentialFields is the single registry of typed credential fields +// on ProviderConfig. Sanitization, secret detection, and store import all +// derive from this table. +var providerCredentialFields = []providerCredentialField{ + { + label: "anthropic_api_key", env: "ANTHROPIC_API_KEY", + value: func(c *ProviderConfig) string { return c.AnthropicAPIKey }, + clear: func(c *ProviderConfig) { c.AnthropicAPIKey = "" }, + }, + { + label: "grok_api_key", env: "XAI_API_KEY", + value: func(c *ProviderConfig) string { return c.GrokAPIKey }, + clear: func(c *ProviderConfig) { c.GrokAPIKey = "" }, + }, + { + label: "xai_api_key", env: "XAI_API_KEY", + value: func(c *ProviderConfig) string { return c.XAIAPIKey }, + clear: func(c *ProviderConfig) { c.XAIAPIKey = "" }, + }, + { + label: "openai_api_key", env: "OPENAI_API_KEY", + value: func(c *ProviderConfig) string { return c.OpenAIAPIKey }, + clear: func(c *ProviderConfig) { c.OpenAIAPIKey = "" }, + }, + { + label: "canopywave_api_key", env: "CANOPYWAVE_API_KEY", + value: func(c *ProviderConfig) string { return c.CanopyWaveAPIKey }, + clear: func(c *ProviderConfig) { c.CanopyWaveAPIKey = "" }, + }, + { + label: "deepseek_api_key", env: "DEEPSEEK_API_KEY", + value: func(c *ProviderConfig) string { return c.DeepSeekAPIKey }, + clear: func(c *ProviderConfig) { c.DeepSeekAPIKey = "" }, + }, + { + label: "zai_api_key", env: "ZAI_API_KEY", + value: func(c *ProviderConfig) string { return c.ZAIAPIKey }, + clear: func(c *ProviderConfig) { c.ZAIAPIKey = "" }, + }, + { + label: "zai_coding_api_key", env: "ZAI_CODING_API_KEY", + value: func(c *ProviderConfig) string { return c.ZAICodingAPIKey }, + clear: func(c *ProviderConfig) { c.ZAICodingAPIKey = "" }, + }, + { + label: "openrouter_api_key", env: "OPENROUTER_API_KEY", + value: func(c *ProviderConfig) string { return c.OpenRouterAPIKey }, + clear: func(c *ProviderConfig) { c.OpenRouterAPIKey = "" }, + }, + { + label: "gemini_api_key", env: "GEMINI_API_KEY", + value: func(c *ProviderConfig) string { return c.GeminiAPIKey }, + clear: func(c *ProviderConfig) { c.GeminiAPIKey = "" }, + }, + { + label: "opencodego_api_key", env: "OPENCODEGO_API_KEY", + value: func(c *ProviderConfig) string { return c.OpenCodeGoAPIKey }, + clear: func(c *ProviderConfig) { c.OpenCodeGoAPIKey = "" }, + }, + { + label: "moonshot_api_key", env: "MOONSHOT_API_KEY", + value: func(c *ProviderConfig) string { return c.MoonshotAPIKey }, + clear: func(c *ProviderConfig) { c.MoonshotAPIKey = "" }, + }, + { + label: "xiaomi_mimo_payg_api_key", env: "XIAOMI_MIMO_PAYG_API_KEY", + value: func(c *ProviderConfig) string { return c.XiaomiMimoPaygAPIKey }, + clear: func(c *ProviderConfig) { c.XiaomiMimoPaygAPIKey = "" }, + }, + { + label: "xiaomi_mimo_token_plan_api_key", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + value: func(c *ProviderConfig) string { return c.XiaomiMimoTokenPlanAPIKey }, + clear: func(c *ProviderConfig) { c.XiaomiMimoTokenPlanAPIKey = "" }, + }, + { + label: "minimax_token_plan_api_key", env: "MINIMAX_TOKEN_PLAN_API_KEY", + value: func(c *ProviderConfig) string { return c.MiniMaxTokenPlanAPIKey }, + clear: func(c *ProviderConfig) { c.MiniMaxTokenPlanAPIKey = "" }, + }, + { + label: "minimax_payg_api_key", env: "MINIMAX_PAYG_API_KEY", + value: func(c *ProviderConfig) string { return c.MiniMaxPaygAPIKey }, + clear: func(c *ProviderConfig) { c.MiniMaxPaygAPIKey = "" }, + }, + { + label: "poolside_api_key", env: "POOLSIDE_API_KEY", + value: func(c *ProviderConfig) string { return c.PoolsideAPIKey }, + clear: func(c *ProviderConfig) { c.PoolsideAPIKey = "" }, + }, + { + label: "groq_api_key", env: "GROQ_API_KEY", + value: func(c *ProviderConfig) string { return c.GroqAPIKey }, + clear: func(c *ProviderConfig) { c.GroqAPIKey = "" }, + }, + { + label: "clinepass_api_key", env: "CLINE_API_KEY", + value: func(c *ProviderConfig) string { return c.ClinePassAPIKey }, + clear: func(c *ProviderConfig) { c.ClinePassAPIKey = "" }, + }, + { + label: "stepfun_api_key", env: "STEP_API_KEY", + value: func(c *ProviderConfig) string { return c.StepFunAPIKey }, + clear: func(c *ProviderConfig) { c.StepFunAPIKey = "" }, + }, + { + label: "concentrate_api_key", env: "CONCENTRATE_API_KEY", + value: func(c *ProviderConfig) string { return c.ConcentrateAPIKey }, + clear: func(c *ProviderConfig) { c.ConcentrateAPIKey = "" }, + }, + { + label: "opengateway_api_key", env: "OPENGATEWAY_API_KEY", + value: func(c *ProviderConfig) string { return c.OpenGatewayAPIKey }, + clear: func(c *ProviderConfig) { c.OpenGatewayAPIKey = "" }, + }, + { + label: "agnes_api_key", env: "AGNES_API_KEY", + value: func(c *ProviderConfig) string { return c.AgnesAPIKey }, + clear: func(c *ProviderConfig) { c.AgnesAPIKey = "" }, + }, +} + +// ProviderConfigContainsSecrets reports whether provider state contains +// credential material, including unrepresentable fields on unregistered +// deployments. It never returns or formats credential values. func ProviderConfigContainsSecrets(cfg ProviderConfig) bool { - for _, secret := range providerConfigSecrets(cfg) { - if strings.TrimSpace(secret) != "" { - return true - } - } for _, deployment := range cfg.Deployments { if deploymentContainsSecrets(deployment) { return true } } - return false + secrets, _ := ProviderConfigSecrets(cfg) + return len(secrets) > 0 } -// SanitizeProviderConfigForDisk removes every historical credential field -// while preserving provider selection, routing, endpoints, and model metadata. +// SanitizeProviderConfigForDisk removes every credential field while +// preserving provider selection, routing, endpoints, and model metadata. func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { - cfg.AnthropicAPIKey = "" - cfg.GrokAPIKey = "" - cfg.XAIAPIKey = "" - cfg.OpenAIAPIKey = "" - cfg.CanopyWaveAPIKey = "" - cfg.DeepSeekAPIKey = "" - cfg.ZAIAPIKey = "" - cfg.ZAICodingAPIKey = "" - cfg.OpenRouterAPIKey = "" - cfg.GeminiAPIKey = "" - cfg.OpenCodeGoAPIKey = "" - cfg.MoonshotAPIKey = "" - cfg.XiaomiMimoPaygAPIKey = "" - cfg.XiaomiMimoTokenPlanAPIKey = "" - cfg.MiniMaxTokenPlanAPIKey = "" - cfg.MiniMaxPaygAPIKey = "" - cfg.PoolsideAPIKey = "" - cfg.GroqAPIKey = "" - cfg.ClinePassAPIKey = "" + for _, field := range providerCredentialFields { + field.clear(&cfg) + } if cfg.Deployments != nil { deployments := make(map[string]DeploymentConfig, len(cfg.Deployments)) for id, deployment := range cfg.Deployments { @@ -54,18 +169,12 @@ func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { return cfg } -// LegacyProviderSecrets maps historical provider.json credential fields to -// their canonical secret-store environment names. Placeholder values are -// omitted. Deployment credentials take precedence over older top-level fields. -func LegacyProviderSecrets(cfg ProviderConfig) map[string]string { - out, _ := LegacyProviderSecretsStrict(cfg) - return out -} - -// LegacyProviderSecretsStrict returns every effective legacy credential or an -// error naming fields that cannot be represented by the canonical secret -// store. Callers must not sanitize provider state when this returns an error. -func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) { +// ProviderConfigSecrets maps every credential stored in provider state to its +// canonical secret-store environment variable. Placeholder values are omitted. +// Deployment credentials take precedence over older top-level fields. It +// returns an error naming fields that cannot be represented by the canonical +// secret store; callers must not sanitize provider state on error. +func ProviderConfigSecrets(cfg ProviderConfig) (map[string]string, error) { out := map[string]string{} put := func(envKey, secret string) { secret = strings.TrimSpace(secret) @@ -73,25 +182,9 @@ func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) out[envKey] = secret } } - put("ANTHROPIC_API_KEY", cfg.AnthropicAPIKey) - put("XAI_API_KEY", firstNonEmpty(cfg.XAIAPIKey, cfg.GrokAPIKey)) - put("OPENAI_API_KEY", cfg.OpenAIAPIKey) - put("CANOPYWAVE_API_KEY", cfg.CanopyWaveAPIKey) - put("DEEPSEEK_API_KEY", cfg.DeepSeekAPIKey) - put("ZAI_API_KEY", cfg.ZAIAPIKey) - put("ZAI_CODING_API_KEY", cfg.ZAICodingAPIKey) - put("OPENROUTER_API_KEY", cfg.OpenRouterAPIKey) - put("GEMINI_API_KEY", cfg.GeminiAPIKey) - put("OPENCODEGO_API_KEY", cfg.OpenCodeGoAPIKey) - put("MOONSHOT_API_KEY", cfg.MoonshotAPIKey) - put("XIAOMI_MIMO_PAYG_API_KEY", cfg.XiaomiMimoPaygAPIKey) - put("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", cfg.XiaomiMimoTokenPlanAPIKey) - put("MINIMAX_TOKEN_PLAN_API_KEY", cfg.MiniMaxTokenPlanAPIKey) - put("MINIMAX_PAYG_API_KEY", cfg.MiniMaxPaygAPIKey) - put("POOLSIDE_API_KEY", cfg.PoolsideAPIKey) - put("GROQ_API_KEY", cfg.GroqAPIKey) - put("CLINE_API_KEY", cfg.ClinePassAPIKey) - + for _, field := range providerCredentialFields { + put(field.env, field.value(&cfg)) + } deploymentIDs := make([]string, 0, len(cfg.Deployments)) for id := range cfg.Deployments { deploymentIDs = append(deploymentIDs, id) @@ -99,65 +192,78 @@ func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) sort.Strings(deploymentIDs) for _, id := range deploymentIDs { deployment := cfg.Deployments[id] - switch id { - case "anthropic-bedrock": - put("AWS_ACCESS_KEY_ID", firstNonEmpty(deployment.AccessKeyID, deployment.APIKey)) - put("AWS_SECRET_ACCESS_KEY", firstNonEmpty(deployment.SecretAccessKey, deployment.Token)) - put("AWS_SESSION_TOKEN", deployment.SessionToken) - case "anthropic-vertex", "gemini-vertex": - if strings.TrimSpace(deployment.AccessKeyID) != "" || strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.SessionToken) != "" { - return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) - } - put("VERTEX_ACCESS_TOKEN", firstNonEmpty(deployment.Token, deployment.APIKey)) - default: - envKey := legacyDeploymentCredentialEnv(id) - if envKey == "" && deploymentContainsSecrets(deployment) { - return nil, fmt.Errorf("provider deployment %q has no safe credential mapping", id) - } - if strings.TrimSpace(deployment.Token) != "" || strings.TrimSpace(deployment.SecretAccessKey) != "" || - strings.TrimSpace(deployment.AccessKeyID) != "" || strings.TrimSpace(deployment.SessionToken) != "" { - return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) - } - put(envKey, deployment.APIKey) + secrets, err := providerDeploymentSecrets(id, deployment) + if err != nil { + return nil, err + } + for envKey, secret := range secrets { + out[envKey] = secret } } return out, nil } -func legacyDeploymentCredentialEnv(deploymentID string) string { - return map[string]string{ - "anthropic-direct": "ANTHROPIC_API_KEY", - "openai-direct": "OPENAI_API_KEY", - "openai-azure": "AZURE_OPENAI_API_KEY", - "grok-direct": "XAI_API_KEY", - "gemini-direct": "GEMINI_API_KEY", - "openrouter": "OPENROUTER_API_KEY", - "canopywave": "CANOPYWAVE_API_KEY", - "deepseek-direct": "DEEPSEEK_API_KEY", - "poolside": "POOLSIDE_API_KEY", - "groq-direct": "GROQ_API_KEY", - "clinepass": "CLINE_API_KEY", - "zai_payg-direct": "ZAI_API_KEY", - "zai_coding-direct": "ZAI_CODING_API_KEY", - "opencodego": "OPENCODEGO_API_KEY", - "kimi-direct": "MOONSHOT_API_KEY", - "xiaomi_mimo_payg-direct": "XIAOMI_MIMO_PAYG_API_KEY", - "xiaomi_mimo_token_plan-direct": "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", - "minimax_token_plan-direct": "MINIMAX_TOKEN_PLAN_API_KEY", - "minimax_payg-direct": "MINIMAX_PAYG_API_KEY", - "ollama-local": "OLLAMA_API_KEY", - }[deploymentID] +// providerDeploymentSecrets maps one deployment's credential fields to +// canonical secret-store env vars. Credential shapes are enforced per +// deployment: single-key providers accept only APIKey, while Bedrock +// (AWS_*) and Vertex (VERTEX_ACCESS_TOKEN) accept their native shapes. +func providerDeploymentSecrets(id string, deployment DeploymentConfig) (map[string]string, error) { + out := map[string]string{} + put := func(envKey, secret string) { + secret = strings.TrimSpace(secret) + if envKey != "" && secret != "" && !LooksLikePlaceholderSecret(secret) { + out[envKey] = secret + } + } + hasAmbiguousFields := strings.TrimSpace(deployment.Token) != "" || + strings.TrimSpace(deployment.AccessKeyID) != "" || + strings.TrimSpace(deployment.SecretAccessKey) != "" || + strings.TrimSpace(deployment.SessionToken) != "" + switch id { + case "anthropic-bedrock": + put("AWS_ACCESS_KEY_ID", firstNonEmpty(deployment.AccessKeyID, deployment.APIKey)) + put("AWS_SECRET_ACCESS_KEY", firstNonEmpty(deployment.SecretAccessKey, deployment.Token)) + put("AWS_SESSION_TOKEN", deployment.SessionToken) + return out, nil + case "gemini-vertex", "anthropic-vertex": + if strings.TrimSpace(deployment.AccessKeyID) != "" || + strings.TrimSpace(deployment.SecretAccessKey) != "" || + strings.TrimSpace(deployment.SessionToken) != "" { + return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) + } + put("VERTEX_ACCESS_TOKEN", firstNonEmpty(deployment.Token, deployment.APIKey)) + return out, nil + } + envKey, known := credentialEnvForDeployment(id) + if !known && deploymentContainsSecrets(deployment) { + return nil, fmt.Errorf("provider deployment %q has no safe credential mapping", id) + } + if hasAmbiguousFields { + return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) + } + put(envKey, deployment.APIKey) + return out, nil } -func providerConfigSecrets(cfg ProviderConfig) []string { - return []string{ - cfg.AnthropicAPIKey, cfg.GrokAPIKey, cfg.XAIAPIKey, cfg.OpenAIAPIKey, - cfg.CanopyWaveAPIKey, cfg.DeepSeekAPIKey, cfg.ZAIAPIKey, cfg.ZAICodingAPIKey, - cfg.OpenRouterAPIKey, cfg.GeminiAPIKey, cfg.OpenCodeGoAPIKey, cfg.MoonshotAPIKey, - cfg.XiaomiMimoPaygAPIKey, cfg.XiaomiMimoTokenPlanAPIKey, - cfg.MiniMaxTokenPlanAPIKey, cfg.MiniMaxPaygAPIKey, - cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, +// credentialEnvForDeployment resolves the canonical secret-store env var for +// a deployment from the provider registry. New providers register their +// deployment and credential env in the registry; this function needs no +// per-provider changes. +func credentialEnvForDeployment(deploymentID string) (string, bool) { + // anthropic-vertex predates the registry's vertex deployment id. + if deploymentID == "anthropic-vertex" { + deploymentID = "gemini-vertex" } + for _, spec := range registry.All() { + if spec.DeploymentID != deploymentID { + continue + } + if env := strings.TrimSpace(spec.RuntimeCredentialEnv); env != "" { + return env, true + } + return strings.TrimSpace(spec.CredentialEnv), true + } + return "", false } func deploymentContainsSecrets(deployment DeploymentConfig) bool { @@ -165,3 +271,82 @@ func deploymentContainsSecrets(deployment DeploymentConfig) bool { strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.AccessKeyID) != "" || strings.TrimSpace(deployment.SessionToken) != "" } + +// providerBaseURLEnv maps flat provider.json base-url fields to the env var +// names declared in each provider's registry spec. +var providerBaseURLEnv = map[string]func(*ProviderConfig) string{ + "ANTHROPIC_BASE_URL": func(c *ProviderConfig) string { return c.AnthropicBaseURL }, + "OPENAI_BASE_URL": func(c *ProviderConfig) string { return c.OpenAIBaseURL }, + "OPENAI_API_BASE": func(c *ProviderConfig) string { return c.OpenAIBaseURL }, + "GEMINI_BASE_URL": func(c *ProviderConfig) string { return c.GeminiBaseURL }, + "DEEPSEEK_BASE_URL": func(c *ProviderConfig) string { return c.DeepSeekBaseURL }, + "XAI_BASE_URL": func(c *ProviderConfig) string { return firstNonEmpty(c.XAIBaseURL, c.GrokBaseURL) }, + "MOONSHOT_BASE_URL": func(c *ProviderConfig) string { return c.MoonshotBaseURL }, + "ZAI_BASE_URL": func(c *ProviderConfig) string { return c.ZAIBaseURL }, + "ZAI_API_BASE": func(c *ProviderConfig) string { return c.ZAIBaseURL }, + "ZAI_CODING_BASE_URL": func(c *ProviderConfig) string { return c.ZAICodingBaseURL }, + "XIAOMI_MIMO_TOKEN_PLAN_BASE_URL": func(c *ProviderConfig) string { + return c.XiaomiMimoTokenPlanBaseURL + }, + "XIAOMI_MIMO_PAYG_BASE_URL": func(c *ProviderConfig) string { return c.XiaomiMimoPaygBaseURL }, + "XIAOMI_BASE_URL": func(c *ProviderConfig) string { return c.XiaomiBaseURL }, + "MINIMAX_TOKEN_PLAN_BASE_URL": func(c *ProviderConfig) string { + return c.MiniMaxTokenPlanBaseURL + }, + "MINIMAX_PAYG_BASE_URL": func(c *ProviderConfig) string { return c.MiniMaxPaygBaseURL }, + "MINIMAX_BASE_URL": func(c *ProviderConfig) string { return c.MiniMaxPaygBaseURL }, + "OPENROUTER_BASE_URL": func(c *ProviderConfig) string { return c.OpenRouterBaseURL }, + "CONCENTRATE_BASE_URL": func(c *ProviderConfig) string { return c.ConcentrateBaseURL }, + "OPENGATEWAY_BASE_URL": func(c *ProviderConfig) string { return c.OpenGatewayBaseURL }, + "STEP_BASE_URL": func(c *ProviderConfig) string { return c.StepFunBaseURL }, + "AGNES_BASE_URL": func(c *ProviderConfig) string { return c.AgnesBaseURL }, + "CANOPYWAVE_BASE_URL": func(c *ProviderConfig) string { return c.CanopyWaveBaseURL }, + "POOLSIDE_BASE_URL": func(c *ProviderConfig) string { return c.PoolsideBaseURL }, + "GROQ_BASE_URL": func(c *ProviderConfig) string { return c.GroqBaseURL }, + "CLINE_API_BASE": func(c *ProviderConfig) string { return c.ClinePassBaseURL }, + "OPENCODEGO_BASE_URL": func(c *ProviderConfig) string { return c.OpenCodeGoBaseURL }, + "OLLAMA_BASE_URL": func(c *ProviderConfig) string { return c.OllamaBaseURL }, +} + +// DeploymentConfigFromProviderState builds a deployment for provider from flat +// provider.json fields, resolving canonical credential and base-url env var +// names from the provider registry. Providers without flat fields return an +// empty deployment. +func DeploymentConfigFromProviderState(cfg *ProviderConfig, provider string) DeploymentConfig { + if cfg == nil { + return DeploymentConfig{} + } + spec, ok := registry.SpecByProviderID(provider) + if !ok { + return DeploymentConfig{} + } + credentialEnvs := []string{strings.TrimSpace(spec.CredentialEnv)} + if runtime := strings.TrimSpace(spec.RuntimeCredentialEnv); runtime != "" { + credentialEnvs = append(credentialEnvs, runtime) + } + out := DeploymentConfig{} + for _, field := range providerCredentialFields { + for _, env := range credentialEnvs { + if field.env != env || out.APIKey != "" { + continue + } + if value := AsNonEmptyString(field.value(cfg)); value != "" { + out.APIKey = value + } + } + } + for _, baseEnv := range spec.BaseURLEnv { + if get := providerBaseURLEnv[baseEnv]; get != nil { + if value := AsNonEmptyString(get(cfg)); value != "" { + out.BaseURL = value + break + } + } + } + if provider == ProviderXiaomiMimoTokenPlan { + if base, err := ResolveXiaomiOpenAIBase(provider, cfg); err == nil && base != "" { + out.BaseURL = base + } + } + return out +} diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go index c6e4ca9..c72e2fd 100644 --- a/config/provider_secrets_test.go +++ b/config/provider_secrets_test.go @@ -1,17 +1,22 @@ package config -import "testing" +import ( + "strings" + "testing" -func TestSanitizeProviderConfigForDiskRemovesLegacyAndDeploymentSecrets(t *testing.T) { + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +func TestSanitizeProviderConfigForDiskRemovesTypedAndDeploymentSecrets(t *testing.T) { original := ProviderConfig{ - OpenAIAPIKey: "sk-legacy", AnthropicAPIKey: "sk-ant-legacy", + OpenAIAPIKey: "sk-typed-value-1234567890", AnthropicAPIKey: "sk-ant-typed-value-1234567890", OpenAIBaseURL: "https://example.test", ActiveModel: "custom/model", Deployments: map[string]DeploymentConfig{ - "openai-direct": {APIKey: "sk-deployment", BaseURL: "https://deployment.test"}, + "openai-direct": {APIKey: "sk-deployment-value-1234567890", BaseURL: "https://deployment.test"}, }, } if !ProviderConfigContainsSecrets(original) { - t.Fatal("expected legacy provider state to contain secrets") + t.Fatal("expected typed provider state to contain secrets") } sanitized := SanitizeProviderConfigForDisk(original) if ProviderConfigContainsSecrets(sanitized) { @@ -26,51 +31,113 @@ func TestSanitizeProviderConfigForDiskRemovesLegacyAndDeploymentSecrets(t *testi } } -func TestLegacyProviderSecretsMapsTopLevelAndDeploymentFields(t *testing.T) { +func TestSanitizeProviderConfigForDiskClearsEveryTypedField(t *testing.T) { + cfg := ProviderConfig{} + for _, field := range providerCredentialFields { + cfg = *populateField(&cfg, field, "sk-clear-check-1234567890") + } + if !ProviderConfigContainsSecrets(cfg) { + t.Fatal("expected every typed credential field to be detected") + } + sanitized := SanitizeProviderConfigForDisk(cfg) + for _, field := range providerCredentialFields { + if got := field.value(&sanitized); strings.TrimSpace(got) != "" { + t.Fatalf("field %s not cleared, got %q", field.label, got) + } + } +} + +func TestProviderConfigSecretsMapsTopLevelAndDeploymentFields(t *testing.T) { cfg := ProviderConfig{ - OpenAIAPIKey: "sk-legacy-value-1234567890", + OpenAIAPIKey: "sk-top-level-1234567890", Deployments: map[string]DeploymentConfig{ - "openai-direct": {APIKey: "sk-current-value-1234567890"}, + "openai-direct": {APIKey: "sk-deployment-value-1234567890"}, "anthropic-bedrock": {AccessKeyID: "AKIAEXAMPLE12345678", SecretAccessKey: "aws-secret-value-long-enough", SessionToken: "aws-session-token-long-enough"}, }, } - secrets := LegacyProviderSecrets(cfg) - if secrets["OPENAI_API_KEY"] != "sk-current-value-1234567890" || + secrets, err := ProviderConfigSecrets(cfg) + if err != nil { + t.Fatal(err) + } + if secrets["OPENAI_API_KEY"] != "sk-deployment-value-1234567890" || secrets["AWS_ACCESS_KEY_ID"] != "AKIAEXAMPLE12345678" || secrets["AWS_SECRET_ACCESS_KEY"] != "aws-secret-value-long-enough" || secrets["AWS_SESSION_TOKEN"] != "aws-session-token-long-enough" { - t.Fatalf("LegacyProviderSecrets() = %#v", secrets) + t.Fatalf("ProviderConfigSecrets() = %#v", secrets) + } +} + +func TestProviderConfigSecretsMapsEveryRegisteredDeployment(t *testing.T) { + for _, spec := range registry.All() { + deploymentID := strings.TrimSpace(spec.DeploymentID) + if deploymentID == "" { + continue + } + t.Run(deploymentID, func(t *testing.T) { + wantEnv := strings.TrimSpace(spec.CredentialEnv) + if runtime := strings.TrimSpace(spec.RuntimeCredentialEnv); runtime != "" { + wantEnv = runtime + } + deployment := DeploymentConfig{APIKey: "sk-registry-value-1234567890"} + if wantEnv == "AWS_SECRET_ACCESS_KEY" { + deployment = DeploymentConfig{SecretAccessKey: "sk-registry-value-1234567890"} + } + cfg := ProviderConfig{Deployments: map[string]DeploymentConfig{deploymentID: deployment}} + secrets, err := ProviderConfigSecrets(cfg) + if err != nil { + t.Fatal(err) + } + if got := secrets[wantEnv]; got != "sk-registry-value-1234567890" { + t.Fatalf("deployment env %q mismatch: got %q, want %q", wantEnv, got, "sk-registry-value-1234567890") + } + }) } } -func TestLegacyProviderSecretsStrictRejectsUnmappedDeploymentFields(t *testing.T) { - _, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ +func TestProviderConfigSecretsRejectsUnregisteredDeploymentFields(t *testing.T) { + _, err := ProviderConfigSecrets(ProviderConfig{Deployments: map[string]DeploymentConfig{ "future-provider": {APIKey: "future-secret-1234567890"}, }}) if err == nil { - t.Fatal("unmapped future deployment credential was accepted") + t.Fatal("unregistered future deployment credential was accepted") } } -func TestLegacyProviderSecretsStrictMapsBedrockCompatibilityFields(t *testing.T) { - secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ - "anthropic-bedrock": {APIKey: "AKIALEGACY123456789", Token: "legacy-secret-1234567890"}, +func TestProviderConfigSecretsMapsBedrockCompatibilityFields(t *testing.T) { + secrets, err := ProviderConfigSecrets(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "anthropic-bedrock": {APIKey: "AKIACOMPAT123456789", Token: "compat-secret-1234567890"}, }}) if err != nil { t.Fatal(err) } - if secrets["AWS_ACCESS_KEY_ID"] != "AKIALEGACY123456789" || secrets["AWS_SECRET_ACCESS_KEY"] != "legacy-secret-1234567890" { + if secrets["AWS_ACCESS_KEY_ID"] != "AKIACOMPAT123456789" || secrets["AWS_SECRET_ACCESS_KEY"] != "compat-secret-1234567890" { t.Fatalf("Bedrock compatibility secrets = %#v", secrets) } } -func TestLegacyProviderSecretsStrictRejectsAmbiguousFieldsOnDirectDeployment(t *testing.T) { +func TestProviderConfigSecretsMapsVertexDeployments(t *testing.T) { + for _, id := range []string{"gemini-vertex", "anthropic-vertex"} { + t.Run(id, func(t *testing.T) { + secrets, err := ProviderConfigSecrets(ProviderConfig{Deployments: map[string]DeploymentConfig{ + id: {Token: "vertex-token-1234567890"}, + }}) + if err != nil { + t.Fatal(err) + } + if got := secrets["VERTEX_ACCESS_TOKEN"]; got != "vertex-token-1234567890" { + t.Fatalf("VERTEX_ACCESS_TOKEN mismatch: got %q, want %q", got, "vertex-token-1234567890") + } + }) + } +} + +func TestProviderConfigSecretsRejectsAmbiguousFieldsOnDirectDeployment(t *testing.T) { for name, deployment := range map[string]DeploymentConfig{ "token": {Token: "ambiguous-token-1234567890"}, "secret_access": {SecretAccessKey: "ambiguous-secret-1234567890"}, } { t.Run(name, func(t *testing.T) { - _, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ + _, err := ProviderConfigSecrets(ProviderConfig{Deployments: map[string]DeploymentConfig{ "openai-direct": deployment, }}) if err == nil { @@ -79,3 +146,126 @@ func TestLegacyProviderSecretsStrictRejectsAmbiguousFieldsOnDirectDeployment(t * }) } } + +func TestProviderConfigSecretsDeploymentTakesPrecedenceOverTopLevel(t *testing.T) { + secrets, err := ProviderConfigSecrets(ProviderConfig{ + OpenAIAPIKey: "sk-top-level-1234567890", + Deployments: map[string]DeploymentConfig{ + "openai-direct": {APIKey: "sk-deployment-value-1234567890"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if got := secrets["OPENAI_API_KEY"]; got != "sk-deployment-value-1234567890" { + t.Fatalf("OPENAI_API_KEY mismatch: got %q, want %q", got, "sk-deployment-value-1234567890") + } +} + +func TestProviderConfigSecretsMapsTypedFieldForEveryRequiresKeyDeployment(t *testing.T) { + for _, spec := range registry.All() { + if !spec.RequiresKey || strings.TrimSpace(spec.DeploymentID) == "" { + continue + } + t.Run(spec.ProviderID, func(t *testing.T) { + wantEnv := strings.TrimSpace(spec.CredentialEnv) + deployment := DeploymentConfig{APIKey: "sk-typed-deployment-1234567890"} + if wantEnv == "AWS_SECRET_ACCESS_KEY" { + deployment = DeploymentConfig{SecretAccessKey: "sk-typed-deployment-1234567890"} + } + cfg := ProviderConfig{Deployments: map[string]DeploymentConfig{ + spec.DeploymentID: deployment, + }} + secrets, err := ProviderConfigSecrets(cfg) + if err != nil { + t.Fatal(err) + } + if got := secrets[wantEnv]; got != "sk-typed-deployment-1234567890" { + t.Fatalf("env %q mismatch: got %q, want %q", wantEnv, got, "sk-typed-deployment-1234567890") + } + }) + } +} + +func TestProviderCredentialFieldsAlignWithRegistry(t *testing.T) { + referenced := map[string]bool{} + for _, spec := range registry.All() { + referenced[strings.TrimSpace(spec.CredentialEnv)] = true + referenced[strings.TrimSpace(spec.RuntimeCredentialEnv)] = true + for _, env := range spec.CredentialEnvFallbacks { + referenced[strings.TrimSpace(env)] = true + } + for _, env := range spec.CredentialAliases { + referenced[strings.TrimSpace(env)] = true + } + } + for _, field := range providerCredentialFields { + if !referenced[field.env] { + t.Fatalf("typed field %s env %q is not referenced by any registry provider spec", field.label, field.env) + } + } +} + +func TestProviderConfigContainsSecretsRejectsOnlyUnmappedDeploymentValues(t *testing.T) { + if ProviderConfigContainsSecrets(ProviderConfig{}) { + t.Fatal("empty provider state reported secrets") + } + if !ProviderConfigContainsSecrets(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "future-provider": {APIKey: "future-secret-1234567890"}, + }}) { + t.Fatal("unmapped deployment credential was not counted as a secret") + } +} + +func populateField(cfg *ProviderConfig, field providerCredentialField, value string) *ProviderConfig { + field.clear(cfg) + switch field.label { + case "anthropic_api_key": + cfg.AnthropicAPIKey = value + case "grok_api_key": + cfg.GrokAPIKey = value + case "xai_api_key": + cfg.XAIAPIKey = value + case "openai_api_key": + cfg.OpenAIAPIKey = value + case "canopywave_api_key": + cfg.CanopyWaveAPIKey = value + case "deepseek_api_key": + cfg.DeepSeekAPIKey = value + case "zai_api_key": + cfg.ZAIAPIKey = value + case "zai_coding_api_key": + cfg.ZAICodingAPIKey = value + case "openrouter_api_key": + cfg.OpenRouterAPIKey = value + case "gemini_api_key": + cfg.GeminiAPIKey = value + case "opencodego_api_key": + cfg.OpenCodeGoAPIKey = value + case "moonshot_api_key": + cfg.MoonshotAPIKey = value + case "xiaomi_mimo_payg_api_key": + cfg.XiaomiMimoPaygAPIKey = value + case "xiaomi_mimo_token_plan_api_key": + cfg.XiaomiMimoTokenPlanAPIKey = value + case "minimax_token_plan_api_key": + cfg.MiniMaxTokenPlanAPIKey = value + case "minimax_payg_api_key": + cfg.MiniMaxPaygAPIKey = value + case "poolside_api_key": + cfg.PoolsideAPIKey = value + case "groq_api_key": + cfg.GroqAPIKey = value + case "clinepass_api_key": + cfg.ClinePassAPIKey = value + case "stepfun_api_key": + cfg.StepFunAPIKey = value + case "concentrate_api_key": + cfg.ConcentrateAPIKey = value + case "opengateway_api_key": + cfg.OpenGatewayAPIKey = value + case "agnes_api_key": + cfg.AgnesAPIKey = value + } + return cfg +} diff --git a/credentials/combined_test.go b/credentials/combined_test.go index e4ec9ce..860ad3d 100644 --- a/credentials/combined_test.go +++ b/credentials/combined_test.go @@ -26,7 +26,7 @@ func TestCombinedStore_WritesKeychainOnly(t *testing.T) { } } -func TestMigrateLegacyEnvFile(t *testing.T) { +func TestMigrateEnvFileCredentials(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) hawkDir := filepath.Join(dir, ".hawk") @@ -39,7 +39,7 @@ func TestMigrateLegacyEnvFile(t *testing.T) { } ctx := context.Background() - n, err := MigrateLegacyEnvFile(ctx) + n, err := MigrateEnvFileCredentials(ctx) if err != nil { t.Fatal(err) } @@ -47,7 +47,7 @@ func TestMigrateLegacyEnvFile(t *testing.T) { t.Fatalf("migrated = %d, want 1", n) } if _, err := os.Stat(envPath); !os.IsNotExist(err) { - t.Fatal("legacy ~/.hawk/env should be removed after migration") + t.Fatal("old ~/.hawk/env should be removed after migration") } store := NewCombinedStore() got, err := store.Get(ctx, AccountForEnv("ANTHROPIC_API_KEY")) diff --git a/credentials/doc.go b/credentials/doc.go index 023a72f..1111303 100644 --- a/credentials/doc.go +++ b/credentials/doc.go @@ -1,4 +1,4 @@ // Package credentials manages provider API-key storage for eyrie, combining an -// OS keychain and an env-file store (CombinedStore) and providing migration of -// legacy env-file and keychain credentials into the current scheme. +// OS keychain and an env-file store (CombinedStore) and providing import of +// env-file and deprecated keychain credentials into the current scheme. package credentials diff --git a/credentials/lookup.go b/credentials/lookup.go index 0d6d8d1..a844ec5 100644 --- a/credentials/lookup.go +++ b/credentials/lookup.go @@ -40,15 +40,15 @@ func lookupSecretAccount(ctx context.Context, account string) (string, error) { if err == nil && strings.TrimSpace(secret) != "" { return secret, nil } - if legacy := legacyKeychainAccountFor(account); legacy != "" { - if legacySecret, legacyErr := DefaultStore().Get(ctx, legacy); legacyErr == nil && strings.TrimSpace(legacySecret) != "" { - return legacySecret, nil + if alias := keychainAccountAliasFor(account); alias != "" { + if aliasSecret, aliasErr := DefaultStore().Get(ctx, alias); aliasErr == nil && strings.TrimSpace(aliasSecret) != "" { + return aliasSecret, nil } } return secret, err } -func legacyKeychainAccountFor(account string) string { +func keychainAccountAliasFor(account string) string { switch strings.ToLower(strings.TrimSpace(account)) { case "xiaomi_mimo_payg_api_key": return "xiaomi_mimo_api_key" diff --git a/credentials/migrate.go b/credentials/migrate.go index ce05f94..59590ba 100644 --- a/credentials/migrate.go +++ b/credentials/migrate.go @@ -7,55 +7,58 @@ import ( "strings" ) -// MigrateLegacyEnvFile imports API keys from legacy plaintext credential files -// (~/.hawk/env, ~/.hawk/.env) into the OS secret store and removes them. -// It also copies legacy keychain account names (e.g. xiaomi_mimo_api_key → payg). -func legacyEnvMigrationMarkerPath() string { +// envFileMigrationMarkerPath is the on-disk marker that env-file import ran +// once. The marker file name is stable so upgraded installs do not re-import. +func envFileMigrationMarkerPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".hawk", ".legacy-env-migrated") } -func legacyEnvMigrationDone() bool { - _, err := os.Stat(legacyEnvMigrationMarkerPath()) +func envFileMigrationDone() bool { + _, err := os.Stat(envFileMigrationMarkerPath()) return err == nil } -func markLegacyEnvMigrationDone() { - _ = os.MkdirAll(filepath.Dir(legacyEnvMigrationMarkerPath()), 0o700) - _ = os.WriteFile(legacyEnvMigrationMarkerPath(), []byte("ok\n"), 0o600) +func markEnvFileMigrationDone() { + _ = os.MkdirAll(filepath.Dir(envFileMigrationMarkerPath()), 0o700) + _ = os.WriteFile(envFileMigrationMarkerPath(), []byte("ok\n"), 0o600) } -func MigrateLegacyEnvFile(ctx context.Context) (int, error) { +// MigrateEnvFileCredentials imports API keys from plaintext credential files +// (~/.hawk/env, ~/.hawk/.env) into the OS secret store and removes them. +// It also copies deprecated keychain account names (e.g. xiaomi_mimo_api_key → payg). +func MigrateEnvFileCredentials(ctx context.Context) (int, error) { if ctx == nil { ctx = context.Background() } - if legacyEnvMigrationDone() { - n, _ := MigrateLegacyKeychainAccounts(ctx) + if envFileMigrationDone() { + n, _ := MigrateKeychainAccountAliases(ctx) return n, nil } total := 0 - for _, path := range []string{legacyEnvPath(), legacyHawkDotEnvPath()} { - n, err := migrateLegacyEnvFileAt(ctx, path) + for _, path := range []string{hawkEnvPath(), hawkDotEnvPath()} { + n, err := migrateEnvFileAt(ctx, path) if err != nil && !os.IsNotExist(err) { return total, err } total += n } - n, err := MigrateLegacyKeychainAccounts(ctx) + n, err := MigrateKeychainAccountAliases(ctx) if err != nil { return total, err } total += n - markLegacyEnvMigrationDone() + markEnvFileMigrationDone() return total, nil } -var legacyKeychainAccountCopies = []struct{ from, to string }{ +var keychainAccountAliases = []struct{ from, to string }{ {"xiaomi_mimo_api_key", "xiaomi_mimo_payg_api_key"}, } -// MigrateLegacyKeychainAccounts copies secrets from deprecated keychain accounts when the new account is empty. -func MigrateLegacyKeychainAccounts(ctx context.Context) (int, error) { +// MigrateKeychainAccountAliases copies secrets from deprecated keychain +// accounts to their canonical account when the canonical one is empty. +func MigrateKeychainAccountAliases(ctx context.Context) (int, error) { if ctx == nil { ctx = context.Background() } @@ -64,16 +67,16 @@ func MigrateLegacyKeychainAccounts(ctx context.Context) (int, error) { return 0, nil } migrated := 0 - for _, pair := range legacyKeychainAccountCopies { + for _, pair := range keychainAccountAliases { existing, err := cs.Keychain.Get(ctx, pair.to) if err == nil && strings.TrimSpace(existing) != "" { continue } - legacy, err := cs.Keychain.Get(ctx, pair.from) - if err != nil || strings.TrimSpace(legacy) == "" { + secret, err := cs.Keychain.Get(ctx, pair.from) + if err != nil || strings.TrimSpace(secret) == "" { continue } - if err := cs.Keychain.Set(ctx, pair.to, strings.TrimSpace(legacy)); err != nil { + if err := cs.Keychain.Set(ctx, pair.to, strings.TrimSpace(secret)); err != nil { continue } migrated++ @@ -81,8 +84,8 @@ func MigrateLegacyKeychainAccounts(ctx context.Context) (int, error) { return migrated, nil } -func migrateLegacyEnvFileAt(ctx context.Context, path string) (int, error) { - secrets, err := readLegacyEnvFile(path) +func migrateEnvFileAt(ctx context.Context, path string) (int, error) { + secrets, err := readEnvFile(path) if err != nil { if os.IsNotExist(err) { return 0, nil @@ -119,18 +122,18 @@ func migrateLegacyEnvFileAt(ctx context.Context, path string) (int, error) { return migrated, nil } -func legacyEnvPath() string { +func hawkEnvPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".hawk", "env") } -func legacyHawkDotEnvPath() string { +func hawkDotEnvPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".hawk", ".env") } -func readLegacyEnvFile(path string) (map[string]string, error) { - data, err := os.ReadFile(path) // #nosec G304 -- path is built from os.UserHomeDir() in legacyHawkDotEnvPath, not untrusted input +func readEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is built from os.UserHomeDir() in hawkDotEnvPath, not untrusted input if err != nil { return nil, err } diff --git a/credentials/migrate_test.go b/credentials/migrate_test.go index 44a81bb..1c0a4ed 100644 --- a/credentials/migrate_test.go +++ b/credentials/migrate_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestReadLegacyEnvFile(t *testing.T) { +func TestReadEnvFile(t *testing.T) { tests := []struct { name string content string @@ -110,7 +110,7 @@ func TestReadLegacyEnvFile(t *testing.T) { t.Fatal(err) } - got, err := readLegacyEnvFile(path) + got, err := readEnvFile(path) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -118,7 +118,7 @@ func TestReadLegacyEnvFile(t *testing.T) { return } if err != nil { - t.Fatalf("readLegacyEnvFile error: %v", err) + t.Fatalf("readEnvFile error: %v", err) } if len(got) != len(tt.want) { t.Fatalf("got %d entries, want %d: %v", len(got), len(tt.want), got) @@ -132,16 +132,16 @@ func TestReadLegacyEnvFile(t *testing.T) { } } -func TestReadLegacyEnvFile_FileNotFound(t *testing.T) { - _, err := readLegacyEnvFile("/nonexistent/path/env") +func TestReadEnvFile_FileNotFound(t *testing.T) { + _, err := readEnvFile("/nonexistent/path/env") if !os.IsNotExist(err) { t.Fatalf("expected os.IsNotExist, got: %v", err) } } -func TestMigrateLegacyEnvFileAt_NoFile(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_NoFile(t *testing.T) { ctx := context.Background() - n, err := migrateLegacyEnvFileAt(ctx, "/nonexistent/path/env") + n, err := migrateEnvFileAt(ctx, "/nonexistent/path/env") if err != nil { t.Fatalf("expected nil error for missing file, got: %v", err) } @@ -150,7 +150,7 @@ func TestMigrateLegacyEnvFileAt_NoFile(t *testing.T) { } } -func TestMigrateLegacyEnvFileAt_EmptyFile(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_EmptyFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "env") if err := os.WriteFile(path, []byte(""), 0o600); err != nil { @@ -158,7 +158,7 @@ func TestMigrateLegacyEnvFileAt_EmptyFile(t *testing.T) { } ctx := context.Background() - n, err := migrateLegacyEnvFileAt(ctx, path) + n, err := migrateEnvFileAt(ctx, path) if err != nil { t.Fatalf("error: %v", err) } @@ -167,11 +167,11 @@ func TestMigrateLegacyEnvFileAt_EmptyFile(t *testing.T) { } // Empty file should be removed. if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("empty legacy file should be removed") + t.Fatal("empty env file should be removed") } } -func TestMigrateLegacyEnvFileAt_OnlyComments(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_OnlyComments(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "env") if err := os.WriteFile(path, []byte("# just a comment\n"), 0o600); err != nil { @@ -179,7 +179,7 @@ func TestMigrateLegacyEnvFileAt_OnlyComments(t *testing.T) { } ctx := context.Background() - n, err := migrateLegacyEnvFileAt(ctx, path) + n, err := migrateEnvFileAt(ctx, path) if err != nil { t.Fatalf("error: %v", err) } @@ -188,12 +188,12 @@ func TestMigrateLegacyEnvFileAt_OnlyComments(t *testing.T) { } } -func TestMigrateLegacyEnvFile_NilContext(t *testing.T) { +func TestMigrateEnvFileCredentials_NilContext(t *testing.T) { // Should not panic with nil context. dir := t.TempDir() t.Setenv("HOME", dir) - n, err := MigrateLegacyEnvFile(context.Background()) + n, err := MigrateEnvFileCredentials(context.Background()) if err != nil { t.Fatalf("error: %v", err) } @@ -202,7 +202,7 @@ func TestMigrateLegacyEnvFile_NilContext(t *testing.T) { } } -func TestMigrateLegacyEnvFileAt_KeychainSkipsExisting(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_KeychainSkipsExisting(t *testing.T) { // Use mocked keyring via the global default store. ms := &MapStore{} cs := &CombinedStore{Keychain: ms} @@ -219,7 +219,7 @@ func TestMigrateLegacyEnvFileAt_KeychainSkipsExisting(t *testing.T) { t.Fatal(err) } - n, err := migrateLegacyEnvFileAt(ctx, path) + n, err := migrateEnvFileAt(ctx, path) if err != nil { t.Fatalf("error: %v", err) } @@ -233,11 +233,11 @@ func TestMigrateLegacyEnvFileAt_KeychainSkipsExisting(t *testing.T) { } // File should be removed. if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("legacy file should be removed after migration") + t.Fatal("env file should be removed after migration") } } -func TestMigrateLegacyEnvFileAt_MultipleKeys(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_MultipleKeys(t *testing.T) { ms := &MapStore{} cs := &CombinedStore{Keychain: ms} SetDefaultStore(cs) @@ -251,7 +251,7 @@ func TestMigrateLegacyEnvFileAt_MultipleKeys(t *testing.T) { t.Fatal(err) } - n, err := migrateLegacyEnvFileAt(ctx, path) + n, err := migrateEnvFileAt(ctx, path) if err != nil { t.Fatalf("error: %v", err) } @@ -269,7 +269,7 @@ func TestMigrateLegacyEnvFileAt_MultipleKeys(t *testing.T) { } } -func TestMigrateLegacyEnvFile_BothPaths(t *testing.T) { +func TestMigrateEnvFileCredentials_BothPaths(t *testing.T) { ms := &MapStore{} cs := &CombinedStore{Keychain: ms} SetDefaultStore(cs) @@ -283,7 +283,7 @@ func TestMigrateLegacyEnvFile_BothPaths(t *testing.T) { t.Fatal(err) } - // Write both legacy files. + // Write both env files. envPath := filepath.Join(hawkDir, "env") dotEnvPath := filepath.Join(hawkDir, ".env") if err := os.WriteFile(envPath, []byte("ANTHROPIC_API_KEY=sk-from-env\n"), 0o600); err != nil { @@ -294,7 +294,7 @@ func TestMigrateLegacyEnvFile_BothPaths(t *testing.T) { } ctx := context.Background() - n, err := MigrateLegacyEnvFile(ctx) + n, err := MigrateEnvFileCredentials(ctx) if err != nil { t.Fatalf("error: %v", err) } @@ -311,7 +311,7 @@ func TestMigrateLegacyEnvFile_BothPaths(t *testing.T) { } } -func TestMigrateLegacyEnvFileAt_NilKeychain(t *testing.T) { +func TestMigrateEnvFileCredentialsAt_NilKeychain(t *testing.T) { // When DefaultStore is a CombinedStore with nil Keychain, migration should fail. cs := &CombinedStore{Keychain: nil} SetDefaultStore(cs) @@ -324,7 +324,7 @@ func TestMigrateLegacyEnvFileAt_NilKeychain(t *testing.T) { } ctx := context.Background() - _, err := migrateLegacyEnvFileAt(ctx, path) + _, err := migrateEnvFileAt(ctx, path) if err == nil { t.Fatal("expected error when keychain is nil") } @@ -333,17 +333,17 @@ func TestMigrateLegacyEnvFileAt_NilKeychain(t *testing.T) { } } -func TestMigrateLegacyKeychainAccounts_XiaomiPayg(t *testing.T) { +func TestMigrateKeychainAccountAliases_XiaomiPayg(t *testing.T) { ms := &MapStore{} cs := &CombinedStore{Keychain: ms} SetDefaultStore(cs) t.Cleanup(func() { SetDefaultStore(nil) }) ctx := context.Background() - if err := ms.Set(ctx, "xiaomi_mimo_api_key", "sk-legacy-mimo"); err != nil { + if err := ms.Set(ctx, "xiaomi_mimo_api_key", "sk-stored-mimo"); err != nil { t.Fatal(err) } - n, err := MigrateLegacyKeychainAccounts(ctx) + n, err := MigrateKeychainAccountAliases(ctx) if err != nil { t.Fatal(err) } @@ -351,7 +351,7 @@ func TestMigrateLegacyKeychainAccounts_XiaomiPayg(t *testing.T) { t.Fatalf("expected 1 migrated, got %d", n) } got, err := ms.Get(ctx, "xiaomi_mimo_payg_api_key") - if err != nil || got != "sk-legacy-mimo" { + if err != nil || got != "sk-stored-mimo" { t.Fatalf("payg account = %q err=%v", got, err) } } diff --git a/credentials/status_test.go b/credentials/status_test.go index b8402d3..3c7fb23 100644 --- a/credentials/status_test.go +++ b/credentials/status_test.go @@ -35,7 +35,7 @@ func TestFormatStorageReport_ListsStoredKeys(t *testing.T) { t.Fatalf("expected env keys in output, got:\n%s", out) } if strings.Contains(out, "Keys stored:") { - t.Fatal("should not show legacy key count line") + t.Fatal("should not show stale key count line") } } diff --git a/engine/control_plane.go b/engine/control_plane.go index 62870dd..458248a 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -65,6 +65,13 @@ func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { return out } +// RegisteredGatewayCount returns the first-class provider count from the +// provider registry. Hosts derive provider counts from Eyrie instead of +// hard-coding them, so new providers require no host changes. +func RegisteredGatewayCount() int { + return len(registry.CredentialRegistry()) +} + // GatewayDefinitions returns pure registry/custom metadata in setup UI order. // It does not read credentials, provider state, or the model catalog. func (e *Engine) GatewayDefinitions() []Gateway { diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index d70bc13..5450024 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -110,14 +110,14 @@ func TestGatewayReadinessRejectsPlaceholderAndDiskSecrets(t *testing.T) { cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ "openai-direct": {APIKey: "sk-secret-on-disk"}, }} - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) for _, gateway := range eng.Gateways(ctx) { if gateway.ID == "openai" && (gateway.CredentialConfigured || gateway.DeploymentConfigured) { - t.Fatalf("legacy or placeholder secret counted as ready: %+v", gateway) + t.Fatalf("stored or placeholder secret counted as ready: %+v", gateway) } } if selection := eng.EffectiveSelection(ctx, SelectionOptions{}); selection.HasConfiguredDeployment { - t.Fatalf("legacy disk secret made selection ready: %+v", selection) + t.Fatalf("stored disk secret made selection ready: %+v", selection) } } @@ -217,12 +217,12 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { t.Fatal(err) } cfg := &config.ProviderConfig{ - OpenAIAPIKey: "sk-top-level-legacy", + OpenAIAPIKey: "sk-top-level-stored", Deployments: map[string]config.DeploymentConfig{ - "openai-direct": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, + "openai-direct": {APIKey: "sk-stored", BaseURL: "https://example.test"}, }, } - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) if status := eng.ProviderStateSecurityStatus(); !status.HasSecrets { t.Fatal("expected secret-bearing provider state") } @@ -234,14 +234,14 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { } saved := config.LoadProviderConfig(eng.providerConfigPath) if saved.OpenAIAPIKey != "" { - t.Fatal("migration retained a top-level legacy credential") + t.Fatal("migration retained a top-level stored credential") } if saved.Deployments["openai-direct"].BaseURL != "https://example.test" { t.Fatal("migration lost non-secret routing metadata") } // A marker is an audit artifact, not permission to trust later plaintext. saved.OpenAIAPIKey = "sk-reintroduced" - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, saved) + writeProviderConfigFixture(t, eng.providerConfigPath, saved) if err := eng.MigrateProviderSecrets(); err != nil { t.Fatal(err) } diff --git a/engine/engine.go b/engine/engine.go index 19346ba..0986422 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -118,11 +118,11 @@ func New(opts Options) (*Engine, error) { cacheConfig: opts.CacheConfig, } engine.resolveTransport = engine.defaultTransport - migrateLegacyProviderConfigOnce.Do(migrateLegacyProviderConfig) + migrateProviderConfigDirOnce.Do(migrateProviderConfigDir) return engine, nil } -// migrateLegacyProviderConfig copies a provider.json left in the old +// migrateProviderConfigDir copies a provider.json left in the old // product-specific "hawk" config dir into the new host-neutral "eyrie" dir the // first time an engine starts after the rename. Without this, upgrading users // silently lose their active provider/model selection, deployments, and routing @@ -130,9 +130,9 @@ func New(opts Options) (*Engine, error) { // // The copy only happens when the eyrie-dir provider.json does not yet exist, so // it is a one-time, idempotent migration that never overwrites newer state. -var migrateLegacyProviderConfigOnce sync.Once +var migrateProviderConfigDirOnce sync.Once -func migrateLegacyProviderConfig() { +func migrateProviderConfigDir() { // Resolve the target dir from the same source eyrie reads provider.json // from. Honors EYRIE_CONFIG_DIR and the HAWK_CONFIG_DIR compat fallback, // instead of hard-coding the default user-config dir. @@ -144,11 +144,11 @@ func migrateLegacyProviderConfig() { if err != nil || userDir == "" { return } - // The legacy "hawk" subdir lived in the user-config root. If a custom - // EYRIE_CONFIG_DIR is in use, there is no legacy "hawk" subdir to migrate + // The old "hawk" subdir lived in the user-config root. If a custom + // EYRIE_CONFIG_DIR is in use, there is no old "hawk" subdir to migrate // from; skip. - legacyDir := filepath.Join(userDir, "hawk") - // Copy legacy // the first time an + oldDir := filepath.Join(userDir, "hawk") + // Copy old // the first time an // engine starts after the rename, for each file that does not yet exist // in the destination. One-time, idempotent, never overwrites newer state. // The .tmp+rename pair makes the write atomic, so a parallel engine @@ -158,24 +158,24 @@ func migrateLegacyProviderConfig() { if _, err := os.Stat(dest); err == nil { continue // already present (fresh install or previously migrated) } - legacyPath := filepath.Join(legacyDir, name) - data, readErr := os.ReadFile(legacyPath) + oldPath := filepath.Join(oldDir, name) + data, readErr := os.ReadFile(oldPath) if readErr != nil { - continue // no legacy file to migrate; nothing to do + continue // no old file to migrate; nothing to do } if err := os.MkdirAll(resolvedDir, 0o700); err != nil { - slog.Warn("config: legacy migration mkdir failed", + slog.Warn("config: config-dir migration mkdir failed", "dir", resolvedDir, "name", name, "error", err) continue } tmp := dest + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { - slog.Warn("config: legacy migration write failed", + slog.Warn("config: config-dir migration write failed", "path", tmp, "name", name, "error", err) continue } if err := os.Rename(tmp, dest); err != nil { - slog.Warn("config: legacy migration rename failed", + slog.Warn("config: config-dir migration rename failed", "from", tmp, "to", dest, "name", name, "error", err) _ = os.Remove(tmp) } diff --git a/engine/host_control.go b/engine/host_control.go index 3664896..d881b17 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -78,8 +78,8 @@ func (e *Engine) StatePaths() StatePaths { return StatePaths{Catalog: e.catalogPath, ProviderConfig: e.providerConfigPath} } -func MigrateLegacyCredentials(ctx context.Context) (int, error) { - return credentials.MigrateLegacyEnvFile(nonNilContext(ctx)) +func MigrateEnvFileCredentials(ctx context.Context) (int, error) { + return credentials.MigrateEnvFileCredentials(nonNilContext(ctx)) } // HasCredentialEnv reports presence without exposing the credential value. @@ -539,8 +539,9 @@ func (e *Engine) ProviderStateSecurityStatus() ProviderStateSecurity { return status } -// MigrateProviderSecrets imports historical credential fields into the -// Engine's secret store, then atomically strips them from provider.json. +// MigrateProviderSecrets imports credential fields persisted in provider +// state into the Engine's secret store, then atomically strips them from +// provider.json. func (e *Engine) MigrateProviderSecrets() error { return e.MigrateProviderSecretsContext(context.Background()) } @@ -559,9 +560,9 @@ func (e *Engine) MigrateProviderSecretsContext(ctx context.Context) error { return nil } cfg := *cfgState - writes, err := e.importLegacyProviderSecrets(ctx, cfg) + writes, err := e.importProviderConfigSecrets(ctx, cfg) if err != nil { - return &Error{Code: ErrorInternal, Operation: "migrate_provider_secrets", Message: "eyrie engine: could not import legacy credentials", Cause: err} + return &Error{Code: ErrorInternal, Operation: "migrate_provider_secrets", Message: "eyrie engine: could not import provider credentials", Cause: err} } sanitized := config.SanitizeProviderConfigForDisk(cfg) if err := writeProviderConfigAtomic(path, &sanitized); err != nil { diff --git a/engine/migration_test.go b/engine/migration_test.go index 600d704..c09ad05 100644 --- a/engine/migration_test.go +++ b/engine/migration_test.go @@ -9,12 +9,12 @@ import ( "github.com/GrayCodeAI/eyrie/config" ) -// TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR verifies H1 fix: when +// TestMigrateConfigDirHonorsEYRIE_CONFIG_DIR verifies H1 fix: when // EYRIE_CONFIG_DIR is set, the migration copies from // /hawk/ → /, not the default path. -func TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR(t *testing.T) { +func TestMigrateConfigDirHonorsEYRIE_CONFIG_DIR(t *testing.T) { // Fresh state for this test. - migrateLegacyProviderConfigOnce = sync.Once{} + migrateProviderConfigDirOnce = sync.Once{} userDir := t.TempDir() customDir := t.TempDir() @@ -22,23 +22,23 @@ func TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR(t *testing.T) { t.Setenv("HAWK_CONFIG_DIR", "") t.Setenv("XDG_CONFIG_HOME", "") - // Compute the same legacy dir the migration will read: it derives from + // Compute the same old dir the migration will read: it derives from // os.UserConfigDir(), which on macOS/Linux appends "Library/Application // Support" (or XDG_CONFIG_HOME) under $HOME. Replicate that to put the - // legacy file in the same path the migration will look at. + // old file in the same path the migration will look at. t.Setenv("HOME", userDir) userConfigDir, err := os.UserConfigDir() if err != nil || userConfigDir == "" { t.Skipf("UserConfigDir unavailable on this platform: %v", err) } - legacyDir := filepath.Join(userConfigDir, "hawk") - if err := os.MkdirAll(legacyDir, 0o700); err != nil { - t.Fatalf("mkdir legacy: %v", err) + oldDir := filepath.Join(userConfigDir, "hawk") + if err := os.MkdirAll(oldDir, 0o700); err != nil { + t.Fatalf("mkdir old: %v", err) } - legacyData := []byte(`{"version":1,"active":{"provider":"openai","model":"gpt-4o"}}`) - legacyPath := filepath.Join(legacyDir, "provider.json") - if err := os.WriteFile(legacyPath, legacyData, 0o600); err != nil { - t.Fatalf("write legacy: %v", err) + oldData := []byte(`{"version":1,"active":{"provider":"openai","model":"gpt-4o"}}`) + oldPath := filepath.Join(oldDir, "provider.json") + if err := os.WriteFile(oldPath, oldData, 0o600); err != nil { + t.Fatalf("write old: %v", err) } // Sanity: the resolved dir is the custom one. @@ -50,7 +50,7 @@ func TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR(t *testing.T) { t.Fatalf("GetProviderConfigDir = %q, want %q", got, customDir) } - migrateLegacyProviderConfig() + migrateProviderConfigDir() // Should have copied to /provider.json. dst := filepath.Join(customDir, "provider.json") @@ -58,7 +58,7 @@ func TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR(t *testing.T) { if err != nil { t.Fatalf("read %s: %v (migration should target the custom dir)", dst, err) } - if string(data) != string(legacyData) { + if string(data) != string(oldData) { t.Fatalf("migration content mismatch") } // Should NOT have created a file at the default path. @@ -68,10 +68,10 @@ func TestMigrateLegacyConfigHonorsEYRIE_CONFIG_DIR(t *testing.T) { } } -// TestMigrateLegacyConfigAtomicAndIdempotent verifies the .tmp+rename write +// TestMigrateConfigDirAtomicAndIdempotent verifies the .tmp+rename write // does not leave a partial file and is safe to call twice. -func TestMigrateLegacyConfigAtomicAndIdempotent(t *testing.T) { - migrateLegacyProviderConfigOnce = sync.Once{} +func TestMigrateConfigDirAtomicAndIdempotent(t *testing.T) { + migrateProviderConfigDirOnce = sync.Once{} userDir := t.TempDir() customDir := t.TempDir() t.Setenv("EYRIE_CONFIG_DIR", customDir) @@ -81,16 +81,16 @@ func TestMigrateLegacyConfigAtomicAndIdempotent(t *testing.T) { if err != nil || userConfigDir == "" { t.Skipf("UserConfigDir unavailable: %v", err) } - legacyDir := filepath.Join(userConfigDir, "hawk") - if err := os.MkdirAll(legacyDir, 0o700); err != nil { - t.Fatalf("mkdir legacy: %v", err) + oldDir := filepath.Join(userConfigDir, "hawk") + if err := os.MkdirAll(oldDir, 0o700); err != nil { + t.Fatalf("mkdir old: %v", err) } - legacyData := []byte(`{"version":2}`) - if err := os.WriteFile(filepath.Join(legacyDir, "provider.json"), legacyData, 0o600); err != nil { - t.Fatalf("write legacy: %v", err) + oldData := []byte(`{"version":2}`) + if err := os.WriteFile(filepath.Join(oldDir, "provider.json"), oldData, 0o600); err != nil { + t.Fatalf("write old: %v", err) } - migrateLegacyProviderConfig() + migrateProviderConfigDir() // First call: file present, no .tmp left. if _, err := os.Stat(filepath.Join(customDir, "provider.json")); err != nil { t.Fatalf("first migration: %v", err) @@ -99,10 +99,10 @@ func TestMigrateLegacyConfigAtomicAndIdempotent(t *testing.T) { t.Fatalf("atomic .tmp file leaked after migration") } // Reset the once and run again; must not overwrite (idempotent). - migrateLegacyProviderConfigOnce = sync.Once{} - migrateLegacyProviderConfig() + migrateProviderConfigDirOnce = sync.Once{} + migrateProviderConfigDir() got, _ := os.ReadFile(filepath.Join(customDir, "provider.json")) - if string(got) != string(legacyData) { + if string(got) != string(oldData) { t.Fatalf("second migration changed file content") } } diff --git a/engine/state.go b/engine/state.go index 4003879..e5e07ba 100644 --- a/engine/state.go +++ b/engine/state.go @@ -20,9 +20,9 @@ type importedCredential struct { hadValue bool } -func (e *Engine) importLegacyProviderSecrets(ctx context.Context, cfg config.ProviderConfig) ([]importedCredential, error) { +func (e *Engine) importProviderConfigSecrets(ctx context.Context, cfg config.ProviderConfig) ([]importedCredential, error) { var writes []importedCredential - secrets, err := config.LegacyProviderSecretsStrict(cfg) + secrets, err := config.ProviderConfigSecrets(cfg) if err != nil { return nil, err } @@ -72,7 +72,7 @@ func (e *Engine) saveProviderConfig(ctx context.Context, cfg *config.ProviderCon if cfg == nil { return nil } - writes, err := e.importLegacyProviderSecrets(nonNilContext(ctx), *cfg) + writes, err := e.importProviderConfigSecrets(nonNilContext(ctx), *cfg) if err != nil { return err } @@ -171,8 +171,8 @@ func buildDeployments(compiled *catalog.CompiledCatalog, persisted map[string]co } // mergeDeployment keeps only non-secret routing fields from disk while filling -// credential fields from the injected store. Legacy secret-bearing provider -// state is never accepted as a runtime credential source. +// credential fields from the injected store. Secret-bearing provider state is +// never accepted as a runtime credential source. func mergeDeployment(persisted, derived config.DeploymentConfig) config.DeploymentConfig { out := config.SanitizeDeploymentConfigForDisk(persisted) if derived.APIKey != "" { diff --git a/engine/state_security_test.go b/engine/state_security_test.go index d7ea921..ca38359 100644 --- a/engine/state_security_test.go +++ b/engine/state_security_test.go @@ -21,15 +21,15 @@ func TestMigrateProviderSecretsImportsBeforeSanitizing(t *testing.T) { t.Fatal(err) } cfg := &config.ProviderConfig{ - OpenAIAPIKey: "sk-legacy-top-level-1234567890", + OpenAIAPIKey: "sk-stored-top-level-1234567890", Deployments: map[string]config.DeploymentConfig{ "openai-direct": { - APIKey: "sk-legacy-deployment-1234567890", + APIKey: "sk-stored-deployment-1234567890", BaseURL: "https://gateway.example.test/v1", }, }, } - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) if err := eng.MigrateProviderSecretsContext(ctx); err != nil { t.Fatal(err) } @@ -37,7 +37,7 @@ func TestMigrateProviderSecretsImportsBeforeSanitizing(t *testing.T) { if err != nil { t.Fatal(err) } - if secret != "sk-legacy-deployment-1234567890" { + if secret != "sk-stored-deployment-1234567890" { t.Fatalf("imported credential = %q", secret) } saved := config.LoadProviderConfig(eng.providerConfigPath) @@ -49,15 +49,15 @@ func TestMigrateProviderSecretsImportsBeforeSanitizing(t *testing.T) { } } -func TestMigrateProviderStateCanonicalizesLegacyVersionAlias(t *testing.T) { +func TestMigrateProviderStateCanonicalizesVersionAlias(t *testing.T) { ctx := context.Background() store := &credentials.MapStore{} eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) if err != nil { t.Fatal(err) } - legacy := []byte(`{"version":"1","active_provider":"openai","openai_api_key":"sk-legacy-version-alias-1234567890"}`) - if err := os.WriteFile(eng.providerConfigPath, legacy, 0o600); err != nil { + oldState := []byte(`{"version":"1","active_provider":"openai","openai_api_key":"sk-stored-version-alias-1234567890"}`) + if err := os.WriteFile(eng.providerConfigPath, oldState, 0o600); err != nil { t.Fatal(err) } @@ -65,17 +65,17 @@ func TestMigrateProviderStateCanonicalizesLegacyVersionAlias(t *testing.T) { t.Fatal(err) } secret, err := store.Get(ctx, credentials.AccountForEnv("OPENAI_API_KEY")) - if err != nil || secret != "sk-legacy-version-alias-1234567890" { - t.Fatalf("legacy credential was not imported before rewrite: value=%q err=%v", secret, err) + if err != nil || secret != "sk-stored-version-alias-1234567890" { + t.Fatalf("credential was not imported before rewrite: value=%q err=%v", secret, err) } persisted, err := os.ReadFile(eng.providerConfigPath) if err != nil { t.Fatal(err) } if bytes.Contains(persisted, []byte(`"version"`)) { - t.Fatalf("migration persisted legacy version alias: %s", persisted) + t.Fatalf("migration persisted the version alias: %s", persisted) } - if !bytes.Contains(persisted, []byte(`"_version"`)) || bytes.Contains(persisted, []byte("sk-legacy-version-alias")) { + if !bytes.Contains(persisted, []byte(`"_version"`)) || bytes.Contains(persisted, []byte("sk-stored-version-alias")) { t.Fatalf("migration did not atomically canonicalize and sanitize provider state: %s", persisted) } cfg, err := config.LoadProviderConfigWithError(eng.providerConfigPath) @@ -92,10 +92,10 @@ func TestMigrateProviderSecretsRollsBackOnStoreFailure(t *testing.T) { t.Fatal(err) } cfg := &config.ProviderConfig{ - OpenAIAPIKey: "sk-openai-legacy-1234567890", - AnthropicAPIKey: "sk-ant-legacy-1234567890", + OpenAIAPIKey: "sk-openai-stored-1234567890", + AnthropicAPIKey: "sk-ant-stored-1234567890", } - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) original, err := os.ReadFile(eng.providerConfigPath) if err != nil { t.Fatal(err) @@ -130,7 +130,7 @@ func TestMigrateProviderSecretsRefusesUnmappedCredentialFields(t *testing.T) { cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ "future-provider": {APIKey: "future-secret-1234567890", BaseURL: "https://future.example.test/v1"}, }} - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) original, err := os.ReadFile(eng.providerConfigPath) if err != nil { t.Fatal(err) @@ -147,21 +147,21 @@ func TestMigrateProviderSecretsRefusesUnmappedCredentialFields(t *testing.T) { } } -func TestEngineProviderWritesImportAndSanitizeLegacySecrets(t *testing.T) { +func TestEngineProviderWritesImportAndSanitizeStoredSecrets(t *testing.T) { ctx := context.Background() store := &credentials.MapStore{} eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) if err != nil { t.Fatal(err) } - cfg := &config.ProviderConfig{AnthropicAPIKey: "sk-ant-legacy-1234567890"} - writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + cfg := &config.ProviderConfig{AnthropicAPIKey: "sk-ant-stored-1234567890"} + writeProviderConfigFixture(t, eng.providerConfigPath, cfg) if err := eng.SetActiveProvider(ctx, "anthropic"); err != nil { t.Fatal(err) } secret, err := store.Get(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY")) - if err != nil || secret != "sk-ant-legacy-1234567890" { - t.Fatalf("legacy credential was not safely imported: value=%q err=%v", secret, err) + if err != nil || secret != "sk-ant-stored-1234567890" { + t.Fatalf("credential was not safely imported: value=%q err=%v", secret, err) } saved := config.LoadProviderConfig(eng.providerConfigPath) if saved == nil || saved.ActiveProvider != "anthropic" || config.ProviderConfigContainsSecrets(*saved) { @@ -169,9 +169,9 @@ func TestEngineProviderWritesImportAndSanitizeLegacySecrets(t *testing.T) { } } -// writeLegacyProviderConfigFixture is deliberately test-only: production +// writeProviderConfigFixture is deliberately test-only: production // SaveProviderConfig refuses plaintext credential fields. -func writeLegacyProviderConfigFixture(t *testing.T, path string, cfg *config.ProviderConfig) { +func writeProviderConfigFixture(t *testing.T, path string, cfg *config.ProviderConfig) { t.Helper() data, err := json.MarshalIndent(cfg, "", " ") if err != nil { diff --git a/setup/deployment.go b/setup/deployment.go index 8400498..3ce0200 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/catalog/zai" "github.com/GrayCodeAI/eyrie/client" @@ -75,7 +76,7 @@ func DeploymentProvider(ctx context.Context, cfg *config.ProviderConfig) (client } // DeploymentProviderFromCatalog is the ambient compatibility constructor. It -// may consult the default store, process environment, and legacy detection. +// may consult the default store, process environment, and flat-config detection. // Host integrations must use DeploymentProviderFromState instead. func DeploymentProviderFromCatalog(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog) (client.Provider, error) { return deploymentProviderFromCatalog(cfg, compiled, true) @@ -83,7 +84,7 @@ func DeploymentProviderFromCatalog(cfg *config.ProviderConfig, compiled *catalog // DeploymentProviderFromState builds a router exclusively from the supplied // provider state. It never reads the default credential store, process -// environment, process-default provider path, or legacy provider detection. +// environment, process-default provider path, or flat-config detection. // Host-facing Engine code must use this strict constructor. func DeploymentProviderFromState(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog) (client.Provider, error) { return deploymentProviderFromCatalog(cfg, compiled, false) @@ -143,7 +144,7 @@ func explicitDeployments(cfg *config.ProviderConfig) map[string]config.Deploymen return out } -// ConfiguredDeployments merges explicit deployments with legacy single-provider config. +// ConfiguredDeployments merges explicit deployments with flat provider.json config. func ConfiguredDeployments(cfg *config.ProviderConfig) map[string]config.DeploymentConfig { out := map[string]config.DeploymentConfig{} if cfg != nil { @@ -161,7 +162,7 @@ func ConfiguredDeployments(cfg *config.ProviderConfig) map[string]config.Deploym } } if id := DefaultDeploymentForProvider(provider); id != "" { - out[id] = LegacyDeploymentConfig(cfg, provider) + out[id] = DeploymentConfigFromProviderState(cfg, provider) } return out } @@ -428,111 +429,20 @@ func resolveZAIAnthropicBaseForDeployment(plan zai.Plan, cfg *config.ProviderCon return zai.ResolveAnthropicBase(region) } -// DefaultDeploymentForProvider maps a logical provider name to a deployment ID. +// DefaultDeploymentForProvider resolves the default deployment for a logical +// provider name from the provider registry. New providers need no setup-side +// mapping. func DefaultDeploymentForProvider(provider string) string { - switch provider { - case config.ProviderAnthropic: - return "anthropic-direct" - case config.ProviderOpenAI: - return "openai-direct" - case config.ProviderAzure: - return "openai-azure" - case config.ProviderGrok: - return "grok-direct" - case config.ProviderGemini: - return "gemini-direct" - case config.ProviderVertex: - return "gemini-vertex" - case config.ProviderBedrock: - return "anthropic-bedrock" - case config.ProviderOpenRouter: - return "openrouter" - case config.ProviderCanopyWave: - return "canopywave" - case config.ProviderPoolside: - return "poolside" - case config.ProviderDeepSeek: - return "deepseek-direct" - case config.ProviderGroq: - return "groq-direct" - case config.ProviderZAIPayg: - return "zai_payg-direct" - case config.ProviderZAICoding: - return "zai_coding-direct" - case config.ProviderOllama: - return "ollama-local" - case config.ProviderOpenCodeGo: - return "opencodego" - case config.ProviderKimi: - return "kimi-direct" - case config.ProviderXiaomiMimoPayg: - return "xiaomi_mimo_payg-direct" - case config.ProviderXiaomiMimoTokenPlan: - return "xiaomi_mimo_token_plan-direct" - case config.ProviderMiniMaxTokenPlan: - return "minimax_token_plan-direct" - case config.ProviderMiniMaxPayg: - return "minimax_payg-direct" - case config.ProviderConcentrate: - return "concentrate-payg" - case config.ProviderOpenGateway: - return "opengateway-payg" - default: - return "" + if spec, ok := registry.SpecByProviderID(provider); ok { + return spec.DeploymentID } + return "" } -// LegacyDeploymentConfig reads API keys from flat provider.json fields. -func LegacyDeploymentConfig(cfg *config.ProviderConfig, provider string) config.DeploymentConfig { - if cfg == nil { - return config.DeploymentConfig{} - } - switch provider { - case config.ProviderAnthropic: - return config.DeploymentConfig{APIKey: cfg.AnthropicAPIKey, BaseURL: cfg.AnthropicBaseURL} - case config.ProviderOpenAI: - return config.DeploymentConfig{APIKey: cfg.OpenAIAPIKey, BaseURL: cfg.OpenAIBaseURL} - case config.ProviderGrok: - return config.DeploymentConfig{APIKey: FirstNonEmpty(cfg.GrokAPIKey, cfg.XAIAPIKey), BaseURL: FirstNonEmpty(cfg.GrokBaseURL, cfg.XAIBaseURL)} - case config.ProviderGemini: - return config.DeploymentConfig{APIKey: cfg.GeminiAPIKey, BaseURL: cfg.GeminiBaseURL} - case config.ProviderOpenRouter: - return config.DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} - case config.ProviderCanopyWave: - return config.DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} - case config.ProviderOpenGateway: - return config.DeploymentConfig{APIKey: cfg.OpenGatewayAPIKey, BaseURL: cfg.OpenGatewayBaseURL} - case config.ProviderPoolside: - return config.DeploymentConfig{APIKey: cfg.PoolsideAPIKey, BaseURL: cfg.PoolsideBaseURL} - case config.ProviderDeepSeek: - return config.DeploymentConfig{APIKey: cfg.DeepSeekAPIKey, BaseURL: cfg.DeepSeekBaseURL} - case config.ProviderGroq: - return config.DeploymentConfig{APIKey: cfg.GroqAPIKey, BaseURL: cfg.GroqBaseURL} - case config.ProviderZAIPayg: - return config.DeploymentConfig{APIKey: cfg.ZAIAPIKey, BaseURL: cfg.ZAIBaseURL} - case config.ProviderZAICoding: - return config.DeploymentConfig{APIKey: cfg.ZAICodingAPIKey, BaseURL: cfg.ZAICodingBaseURL} - case config.ProviderOllama: - return config.DeploymentConfig{BaseURL: cfg.OllamaBaseURL} - case config.ProviderOpenCodeGo: - return config.DeploymentConfig{APIKey: cfg.OpenCodeGoAPIKey, BaseURL: cfg.OpenCodeGoBaseURL} - case config.ProviderKimi: - return config.DeploymentConfig{APIKey: cfg.MoonshotAPIKey, BaseURL: cfg.MoonshotBaseURL} - case config.ProviderXiaomiMimoPayg: - return config.DeploymentConfig{ - APIKey: cfg.XiaomiMimoPaygAPIKey, - BaseURL: cfg.XiaomiMimoPaygBaseURL, - } - case config.ProviderXiaomiMimoTokenPlan: - base, _ := config.ResolveXiaomiOpenAIBase(config.ProviderXiaomiMimoTokenPlan, cfg) - return config.DeploymentConfig{APIKey: cfg.XiaomiMimoTokenPlanAPIKey, BaseURL: base} - case config.ProviderMiniMaxTokenPlan: - return config.DeploymentConfig{APIKey: cfg.MiniMaxTokenPlanAPIKey, BaseURL: cfg.MiniMaxTokenPlanBaseURL} - case config.ProviderMiniMaxPayg: - return config.DeploymentConfig{APIKey: cfg.MiniMaxPaygAPIKey, BaseURL: cfg.MiniMaxPaygBaseURL} - default: - return config.DeploymentConfig{} - } +// DeploymentConfigFromProviderState reads API keys and base URLs from flat +// provider.json fields via the provider registry. +func DeploymentConfigFromProviderState(cfg *config.ProviderConfig, provider string) config.DeploymentConfig { + return config.DeploymentConfigFromProviderState(cfg, provider) } // RouterRoutingPolicy converts config routing JSON into router policy. diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 60be72d..54e1a27 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -56,7 +56,7 @@ func TestProviderForDeploymentAnthropicBedrockRequiresCredentials(t *testing.T) } } -func TestDeploymentProviderFromStateRejectsAmbientCredentialsAndLegacyDetection(t *testing.T) { +func TestDeploymentProviderFromStateRejectsAmbientCredentialsAndFlatConfigDetection(t *testing.T) { store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) @@ -166,11 +166,11 @@ func TestUseDeploymentRouting_WithRouting(t *testing.T) { } } -func TestUseDeploymentRouting_LegacyConfig(t *testing.T) { +func TestUseDeploymentRouting_FlatConfig(t *testing.T) { t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "") cfg := &config.ProviderConfig{ConfigVersion: 0} if UseDeploymentRouting(cfg) { - t.Fatal("expected false for legacy config without deployments/routing") + t.Fatal("expected false for flat config without deployments/routing") } } @@ -340,6 +340,9 @@ func TestDefaultDeploymentForProvider(t *testing.T) { {config.ProviderOpenCodeGo, "opencodego"}, {config.ProviderKimi, "kimi-direct"}, {config.ProviderXiaomiMimoPayg, "xiaomi_mimo_payg-direct"}, + {config.ProviderAgnes, "agnes-direct"}, + {config.ProviderStepFun, "stepfun-direct"}, + {config.ProviderMiniMaxPayg, "minimax_payg-direct"}, {"unknown", ""}, {"", ""}, } @@ -353,21 +356,21 @@ func TestDefaultDeploymentForProvider(t *testing.T) { } } -// --- LegacyDeploymentConfig --- +// --- DeploymentConfigFromProviderState --- -func TestLegacyDeploymentConfig_NilConfig(t *testing.T) { - got := LegacyDeploymentConfig(nil, config.ProviderAnthropic) +func TestDeploymentConfigFromProviderState_NilConfig(t *testing.T) { + got := DeploymentConfigFromProviderState(nil, config.ProviderAnthropic) if got.APIKey != "" || got.BaseURL != "" { t.Fatalf("expected empty DeploymentConfig for nil config, got %+v", got) } } -func TestLegacyDeploymentConfig_Anthropic(t *testing.T) { +func TestDeploymentConfigFromProviderState_Anthropic(t *testing.T) { cfg := &config.ProviderConfig{ AnthropicAPIKey: "key123", AnthropicBaseURL: "https://custom.api.com", } - got := LegacyDeploymentConfig(cfg, config.ProviderAnthropic) + got := DeploymentConfigFromProviderState(cfg, config.ProviderAnthropic) if got.APIKey != "key123" { t.Fatalf("APIKey = %q, want key123", got.APIKey) } @@ -376,42 +379,42 @@ func TestLegacyDeploymentConfig_Anthropic(t *testing.T) { } } -func TestLegacyDeploymentConfig_OpenAI(t *testing.T) { +func TestDeploymentConfigFromProviderState_OpenAI(t *testing.T) { cfg := &config.ProviderConfig{ OpenAIAPIKey: "oai-key", OpenAIBaseURL: "https://api.openai.com/v1", } - got := LegacyDeploymentConfig(cfg, config.ProviderOpenAI) + got := DeploymentConfigFromProviderState(cfg, config.ProviderOpenAI) if got.APIKey != "oai-key" { t.Fatalf("APIKey = %q, want oai-key", got.APIKey) } } -func TestLegacyDeploymentConfig_Grok(t *testing.T) { +func TestDeploymentConfigFromProviderState_Grok(t *testing.T) { cfg := &config.ProviderConfig{ GrokAPIKey: "grok-key", } - got := LegacyDeploymentConfig(cfg, config.ProviderGrok) + got := DeploymentConfigFromProviderState(cfg, config.ProviderGrok) if got.APIKey != "grok-key" { t.Fatalf("APIKey = %q, want grok-key", got.APIKey) } } -func TestLegacyDeploymentConfig_GrokXAIFallback(t *testing.T) { +func TestDeploymentConfigFromProviderState_GrokXAIFallback(t *testing.T) { cfg := &config.ProviderConfig{ XAIAPIKey: "xai-key", } - got := LegacyDeploymentConfig(cfg, config.ProviderGrok) + got := DeploymentConfigFromProviderState(cfg, config.ProviderGrok) if got.APIKey != "xai-key" { t.Fatalf("APIKey = %q, want xai-key (XAI fallback)", got.APIKey) } } -func TestLegacyDeploymentConfig_Ollama(t *testing.T) { +func TestDeploymentConfigFromProviderState_Ollama(t *testing.T) { cfg := &config.ProviderConfig{ OllamaBaseURL: "http://localhost:11434", } - got := LegacyDeploymentConfig(cfg, config.ProviderOllama) + got := DeploymentConfigFromProviderState(cfg, config.ProviderOllama) if got.BaseURL != "http://localhost:11434" { t.Fatalf("BaseURL = %q, want http://localhost:11434", got.BaseURL) } @@ -420,9 +423,31 @@ func TestLegacyDeploymentConfig_Ollama(t *testing.T) { } } -func TestLegacyDeploymentConfig_Unknown(t *testing.T) { +func TestDeploymentConfigFromProviderState_AgnesStepFunConcentrate(t *testing.T) { + cfg := &config.ProviderConfig{ + AgnesAPIKey: "agnes-key", + AgnesBaseURL: "https://apihub.agnes-ai.com/v1", + StepFunAPIKey: "stepfun-key", + ConcentrateAPIKey: "concentrate-key", + } + for provider, wantKey := range map[string]string{ + config.ProviderAgnes: "agnes-key", + config.ProviderStepFun: "stepfun-key", + config.ProviderConcentrate: "concentrate-key", + } { + got := DeploymentConfigFromProviderState(cfg, provider) + if got.APIKey != wantKey { + t.Fatalf("%s APIKey = %q, want %q", provider, got.APIKey, wantKey) + } + } + if got := DeploymentConfigFromProviderState(cfg, config.ProviderAgnes); got.BaseURL != "https://apihub.agnes-ai.com/v1" { + t.Fatalf("agnes BaseURL = %q, want agnes base url", got.BaseURL) + } +} + +func TestDeploymentConfigFromProviderState_Unknown(t *testing.T) { cfg := &config.ProviderConfig{AnthropicAPIKey: "key"} - got := LegacyDeploymentConfig(cfg, "nonexistent") + got := DeploymentConfigFromProviderState(cfg, "nonexistent") if got.APIKey != "" { t.Fatalf("expected empty for unknown provider, got %+v", got) }