package config import ( "fmt" "strings" "github.com/mitchellh/mapstructure" "github.com/spf13/viper" ) func Load(cfgFile string) (*NodeConfig, error) { v := viper.New() v.SetConfigFile(cfgFile) setDefaults(v) if err := v.ReadInConfig(); err != nil { return nil, err } if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil { return nil, err } var cfg NodeConfig if err := v.Unmarshal(&cfg); err != nil { return nil, err } if err := validateReconnect(cfg.Reconnect); err != nil { return nil, err } if err := validateNodeCredentialPlane(&cfg); err != nil { return nil, err } return &cfg, nil } // validateReconnect enforces the reconnect policy contract (SDD S17). setDefaults // supplies the omitted defaults (interval_sec=10, max_attempts=10) before this // runs, so an omitted max_attempts is already 10 here while an explicit 0 stays // 0 (unlimited). Negative max_attempts or interval_sec are rejected, and an // unlimited policy requires a positive interval so it cannot hot-loop. func validateReconnect(rc ReconnectConf) error { if rc.MaxAttempts < 0 { return fmt.Errorf("reconnect.max_attempts must not be negative") } if rc.IntervalSec < 0 { return fmt.Errorf("reconnect.interval_sec must not be negative") } if rc.MaxAttempts == 0 && rc.IntervalSec <= 0 { return fmt.Errorf("reconnect.interval_sec must be positive when reconnect.max_attempts=0 (unlimited)") } return nil } func LoadEdge(cfgFile string) (*EdgeConfig, error) { v := viper.New() v.SetConfigFile(cfgFile) setEdgeDefaults(v) if err := v.ReadInConfig(); err != nil { return nil, err } if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil { return nil, err } var cfg EdgeConfig if err := v.Unmarshal(&cfg); err != nil { return nil, err } if v.InConfig("execution_presets") { raw := v.Get("execution_presets") var presets []ExecutionPreset var metadata mapstructure.Metadata decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ ErrorUnused: true, Result: &presets, Metadata: &metadata, TagName: "mapstructure", }) if err != nil { return nil, fmt.Errorf("execution_presets: %w", err) } if err := decoder.Decode(raw); err != nil { return nil, fmt.Errorf("execution_presets: %w", err) } if len(metadata.Unused) > 0 { return nil, fmt.Errorf("execution_presets: unknown fields %v", metadata.Unused) } cfg.ExecutionPresets = presets } if !v.InConfig("console.target") { if v.InConfig("console.model") { cfg.Console.Target = cfg.Console.Model } } if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil { return nil, err } if err := validateOpenAIPrincipalTokens(cfg.OpenAI.PrincipalTokens); err != nil { return nil, err } if err := normalizeOpenAIProviderAuth(v, &cfg.OpenAI.ProviderAuth); err != nil { return nil, err } if cfg.LongContextThresholdTokens <= 0 { return nil, fmt.Errorf("long_context_threshold_tokens must be positive") } if cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes == 0 { cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes = cfg.OpenAI.StreamEvidenceGate.EffectiveMaxIngressSnapshotBytes() } if err := cfg.OpenAI.StreamEvidenceGate.Validate(); err != nil { return nil, fmt.Errorf("openai.stream_evidence_gate: %w", err) } // Resolve the canonical Edge root provider_pool queue policy. // SDD S06: root provider_pool is the canonical owner of the effective // queue policy. Legacy per-provider max_queue/queue_timeout_ms are ignored // once the canonical owner is resolved. if err := resolveProviderPoolPolicy(v, &cfg); err != nil { return nil, err } // Collect all provider IDs from nodes[].providers[] for cross-referencing // and validate uniqueness within each node. providerIDs := make(map[string]struct{}) providerByID := make(map[string]NodeProviderConf) for i := range cfg.Nodes { if err := normalizeAdapters(&cfg.Nodes[i].Adapters); err != nil { name := cfg.Nodes[i].ID if name == "" { name = cfg.Nodes[i].Alias } return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err) } // Validate unique provider IDs within this node and across all nodes. seenProviderIDs := make(map[string]struct{}, len(cfg.Nodes[i].Providers)) for j, p := range cfg.Nodes[i].Providers { if err := p.Validate(); err != nil { return nil, fmt.Errorf("nodes[%d].providers[%d]: %w", i, j, err) } if _, dup := seenProviderIDs[p.ID]; dup { return nil, fmt.Errorf("nodes[%d].providers: duplicate provider id %q within node", i, p.ID) } seenProviderIDs[p.ID] = struct{}{} if _, globalDup := providerIDs[p.ID]; globalDup { return nil, fmt.Errorf("nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)", i, j, p.ID) } providerIDs[p.ID] = struct{}{} providerByID[p.ID] = p } } for i := range cfg.Nodes { if err := CheckProviderLegacyConflict(i, cfg.Nodes[i].Alias, &cfg.Nodes[i]); err != nil { return nil, err } } // Resolve protocol profiles into immutable concrete snapshots on each // provider. This must run before model catalog validation so that // profile-dependent checks (e.g. token counter mode) can reference the // resolved snapshot. if err := resolveProtocolProfiles(&cfg); err != nil { return nil, err } // Provider structs copied before normalization do not contain the resolved // profile snapshots. Rebuild the index so profile-aware model validation // observes exactly the immutable provider values retained by cfg. providerByID = make(map[string]NodeProviderConf, len(providerIDs)) for i := range cfg.Nodes { for _, provider := range cfg.Nodes[i].Providers { providerByID[provider.ID] = provider } } // Build the provider->servedModels index so Validate can check membership. serveModels := buildProviderServedModelsIndex(cfg.Nodes) // Validate models[].providers reference valid provider IDs. seenModelIDs := make(map[string]struct{}, len(cfg.Models)) for i, m := range cfg.Models { id := strings.TrimSpace(m.ID) if id == "" { return nil, fmt.Errorf("models[%d]: id must not be empty", i) } if _, dup := seenModelIDs[id]; dup { return nil, fmt.Errorf("models: duplicate model id %q", id) } seenModelIDs[id] = struct{}{} if err := m.Validate(providerIDs, serveModels); err != nil { return nil, fmt.Errorf("models[%d]: %w", i, err) } // Provider-only budget and token-counter checks apply to provider-backed // entries only. Virtual (preset-only) entries delegate execution to a // frozen preset shape and have no provider pool to budget against. if strings.TrimSpace(m.ExecutionPreset) == "" { if err := validateModelTokenCounter(m, providerByID); err != nil { return nil, fmt.Errorf("models[%d]: %w", i, err) } if err := validateProviderLongContextBudget(m, providerByID); err != nil { return nil, fmt.Errorf("models[%d]: %w", i, err) } } } // Validate and normalize execution presets before model admission. Preset // validation runs early so that invalid preset shapes fail closed before // any runtime dispatch path can observe them. if err := validatePresetCatalog(cfg.ExecutionPresets, seenModelIDs); err != nil { return nil, fmt.Errorf("execution_presets: %w", err) } // Resolve preset ids referenced by virtual (preset-only) model entries // against the validated preset catalog. Dangling references fail closed. // Whitespace-only execution_preset values are normalized to empty so the // field reflects the effective (unset) state downstream, and a resolved // non-empty id is persisted in its canonical (trimmed) form so exact // downstream lookups match the value that was admitted here. for i := range cfg.Models { m := &cfg.Models[i] presetID := strings.TrimSpace(m.ExecutionPreset) if presetID == "" { m.ExecutionPreset = "" continue } found := false for _, p := range cfg.ExecutionPresets { if p.ID == presetID { found = true break } } if !found { return nil, fmt.Errorf("models[%d] id=%q: execution_preset %q does not match any execution_presets[] entry", i, m.ID, presetID) } m.ExecutionPreset = presetID } // Attribution binding validation intentionally runs after the established // route, principal, stream-gate, provider, model, and budget checks so a // missing provider identity never masks their existing diagnostics. if err := validateOpenAIAttributionBindings(cfg.OpenAI); err != nil { return nil, err } if err := validateEdgeCredentialPlane(&cfg); err != nil { return nil, err } return &cfg, nil } // rejectLegacyProviderConfig fails closed before decode so removed fields are // never silently ignored by mapstructure. func rejectLegacyProviderConfig(settings map[string]any) error { var walk func(any, string) error walk = func(value any, path string) error { switch current := value.(type) { case map[string]any: for key, child := range current { childPath := key if path != "" { childPath = path + "." + key } if (path == "console" && key == "agent") || key == "agent_"+"kind" || key == "workspace_"+"required" || (key == "cli" && strings.Contains(path, "adapters")) || (strings.Contains(path, "providers[") && isLegacyProviderProcessField(key)) { return fmt.Errorf("legacy provider configuration field %q is not supported", childPath) } if strings.Contains(path, "providers[") && (key == "type" || key == "category") && strings.EqualFold(fmt.Sprint(child), "cli") { return fmt.Errorf("legacy provider configuration value %q is not supported", childPath) } if err := walk(child, childPath); err != nil { return err } } case []any: for i, child := range current { if err := walk(child, fmt.Sprintf("%s[%d]", path, i)); err != nil { return err } } } return nil } return walk(settings, "") } func isLegacyProviderProcessField(key string) bool { switch key { case "command", "args", "env", "mode", "resume_args", "output_format": return true default: return false } } func setDefaults(v *viper.Viper) { v.SetDefault("transport.edge_addr", "localhost:9090") v.SetDefault("reconnect.interval_sec", 10) v.SetDefault("reconnect.max_attempts", 10) v.SetDefault("logging.level", "info") v.SetDefault("metrics.port", 9091) v.SetDefault("credential_plane.replay_cache_size", 256) } func setEdgeDefaults(v *viper.Viper) { v.SetDefault("server.listen", "0.0.0.0:9090") v.SetDefault("bootstrap.listen", "0.0.0.0:18080") v.SetDefault("bootstrap.artifact_dir", "artifacts") v.SetDefault("openai.enabled", false) v.SetDefault("openai.listen", "0.0.0.0:18081") v.SetDefault("openai.bearer_token", "") v.SetDefault("openai.adapter", "ollama") v.SetDefault("openai.session_id", "openai") v.SetDefault("openai.timeout_sec", 120) v.SetDefault("openai.strict_output", true) v.SetDefault("openai.strict_stream_buffer", false) v.SetDefault("openai.stream_evidence_gate.max_request_fault_recovery", 3) v.SetDefault("openai.stream_evidence_gate.max_ingress_snapshot_bytes", 16*1024*1024) v.SetDefault("a2a.enabled", false) v.SetDefault("a2a.listen", "0.0.0.0:8081") v.SetDefault("a2a.path", "/a2a") v.SetDefault("a2a.adapter", "openai_compat") v.SetDefault("a2a.session_id", "a2a") v.SetDefault("a2a.timeout_sec", 120) v.SetDefault("logging.level", "info") v.SetDefault("metrics.port", 19092) v.SetDefault("tls.enabled", false) v.SetDefault("console.adapter", "ollama") v.SetDefault("console.target", "") v.SetDefault("console.session_id", "default") v.SetDefault("console.background", false) v.SetDefault("console.timeout_sec", 120) v.SetDefault("control_plane.enabled", false) v.SetDefault("control_plane.wire_addr", "") v.SetDefault("control_plane.reconnect_interval_sec", 5) v.SetDefault("credential_plane.lease_ttl_seconds", 30) v.SetDefault("credential_plane.lease_cache_size", 256) v.SetDefault("refresh.enabled", false) v.SetDefault("refresh.listen", "127.0.0.1:19093") v.SetDefault("long_context_threshold_tokens", 100000) } // resolveProviderPoolPolicy resolves the effective queue policy from the // canonical Edge root or, when absent, promotes the matching legacy per- // provider pair. SDD S06. // // Canonical path: if either provider_pool.max_queue or provider_pool.queue_timeout_ms // is present in the raw source config, treat the canonical owner as set. // Missing/max=0 values fall back to defaults, while explicit timeout 0 stays // as no-timeout. // // Legacy path: when no canonical key is present, collect every node provider // that contributes a usable legacy pair (max_queue>0 or queue_timeout_ms>0), // require all of them to agree, and promote the single shared pair. Conflicts // trigger a deterministic error naming the offending providers. func resolveProviderPoolPolicy(v *viper.Viper, cfg *EdgeConfig) error { canonicalHasKey := v.InConfig("provider_pool.max_queue") || v.InConfig("provider_pool.queue_timeout_ms") if canonicalHasKey { // Canonical present: validate boundaries before applying default semantics. // SDD S06: negative values are rejected; max_queue=0 normalizes to // default; explicit queue_timeout_ms=0 stays as no-timeout. if cfg.ProviderPool.MaxQueue < 0 { return fmt.Errorf("provider_pool.max_queue must be non-negative") } if cfg.ProviderPool.QueueTimeoutMS < 0 { return fmt.Errorf("provider_pool.queue_timeout_ms must be non-negative") } if cfg.ProviderPool.MaxQueue == 0 { cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue } if v.InConfig("provider_pool.queue_timeout_ms") { // Key present: preserve decoded value (0 = no-timeout, N>0 = explicit). } else { cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS } return nil } // Canonical absent: collect legacy effective pairs from all node providers. type legacyPair struct { id string maxQ int tMS int } var candidates []legacyPair seen := make(map[string]struct{}) for i := range cfg.Nodes { for _, p := range cfg.Nodes[i].Providers { // Skip if this provider already seen (cross-node dedupe). if _, dup := seen[p.ID]; dup { continue } seen[p.ID] = struct{}{} maxQ := p.MaxQueue tMS := p.QueueTimeoutMS // A legacy pair must have at least one usable field (>0). // Both unset/zero alone does not participate (same as runtime default). if maxQ <= 0 && tMS <= 0 { continue } // Normalize participating legacy pair to effective value before // comparison/promotion. max_queue==0 in a participating pair means the // default queue depth, so replace it with DefaultProviderPoolMaxQueue. // max_queue>0 with timeout==0 is a // max-only candidate; its no-timeout semantics are preserved as-is. if maxQ == 0 { maxQ = DefaultProviderPoolMaxQueue } candidates = append(candidates, legacyPair{id: p.ID, maxQ: maxQ, tMS: tMS}) } } if len(candidates) == 0 { cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS return nil } // All candidates must agree on the same pair. ref := candidates[0] for _, c := range candidates[1:] { if c.maxQ != ref.maxQ || c.tMS != ref.tMS { return fmt.Errorf("conflicting provider queue policy: provider %q has max_queue=%d queue_timeout_ms=%d; provider %q has max_queue=%d queue_timeout_ms=%d; canonical provider_pool is required to disambiguate", ref.id, ref.maxQ, ref.tMS, c.id, c.maxQ, c.tMS) } } // Promote the single agreed legacy pair as-is (explicit 0 stays no-timeout). cfg.ProviderPool.MaxQueue = ref.maxQ cfg.ProviderPool.QueueTimeoutMS = ref.tMS return nil }