package config import ( "bytes" "fmt" "reflect" "strings" "sync" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) // Config remains the effective runtime/wire representation. Layout translation is // confined to YAML boundaries so older SDK and Home consumers keep their contract. type legacyConfig Config type configPath struct{ old, current string } var v8FieldIndexes = make(map[string][]int) var v8StructPaths []configPath var v8Paths = buildV8Paths() // Historical spellings are YAML-boundary aliases, not provider runtime fields. // The canonical client path wins by presence (including explicit false/null). // If it is absent, prefer the previous v8 OAuth path, then providers, then codex. var v8ClientPaths = []configPath{ {"oauth.providers.codex.optimize-multi-agent-v2", "client.codex.optimize-multi-agent-v2"}, {"providers.codex.optimize-multi-agent-v2", "client.codex.optimize-multi-agent-v2"}, {"codex.optimize-multi-agent-v2", "client.codex.optimize-multi-agent-v2"}, } // Canonical upstream fields win by presence; historical OAuth fields precede globals. var v8SharedPaths = []configPath{ {"oauth.providers.codex.disable-codex-cloaking", "upstream.codex.disable-codex-cloaking"}, {"oauth.providers.codex.stream-bootstrap-buffering", "upstream.codex.stream-bootstrap-buffering"}, {"oauth.providers.codex.stream-bootstrap-timeout", "upstream.codex.stream-bootstrap-timeout"}, {"oauth.providers.codex.orphan-delegation-compatibility", "upstream.codex.orphan-delegation-compatibility"}, {"oauth.providers.codex.model-level-cooling", "upstream.codex.model-level-cooling"}, {"oauth.providers.codex.response-steering", "upstream.codex.response-steering"}, {"oauth.providers.codex.upstream-request-compression", "upstream.codex.upstream-request-compression"}, {"oauth.providers.codex.upstream-request-compression-min-bytes", "upstream.codex.upstream-request-compression-min-bytes"}, {"oauth.providers.codex.websocket-compression", "upstream.codex.websocket-compression"}, {"oauth.providers.claude.model-level-cooling", "upstream.claude.model-level-cooling"}, {"oauth.providers.claude.claude-code.disable-cloaking-model-list", "upstream.claude.disable-cloaking-model-list"}, {"oauth.providers.claude.disable-claude-cloak-mode", "upstream.claude.disable-claude-cloak-mode"}, {"oauth.providers.claude.header-defaults.user-agent", "upstream.claude.header-defaults.user-agent"}, {"oauth.providers.claude.header-defaults.package-version", "upstream.claude.header-defaults.package-version"}, {"oauth.providers.claude.header-defaults.runtime-version", "upstream.claude.header-defaults.runtime-version"}, {"oauth.providers.claude.header-defaults.os", "upstream.claude.header-defaults.os"}, {"oauth.providers.claude.header-defaults.arch", "upstream.claude.header-defaults.arch"}, {"oauth.providers.claude.header-defaults.timeout", "upstream.claude.header-defaults.timeout"}, {"oauth.providers.claude.header-defaults.timezone", "upstream.claude.header-defaults.timezone"}, {"oauth.providers.claude.header-defaults.stabilize-device-profile", "upstream.claude.header-defaults.stabilize-device-profile"}, {"oauth.providers.xai.inject-x-search", "upstream.xai.inject-x-search"}, } var v8Aliases = append(append([]configPath(nil), v8ClientPaths...), v8SharedPaths...) var v8SharedStructPaths = []configPath{ {"oauth.providers.claude.header-defaults", "upstream.claude.header-defaults"}, {"oauth.providers.claude.claude-code", "upstream.claude"}, {"oauth.providers.claude", "upstream.claude"}, {"oauth.providers.xai", "upstream.xai"}, } var v8KeyFamilies = []configPath{ {"gemini-api-key", "gemini"}, {"interactions-api-key", "interactions"}, {"vertex-api-key", "vertex"}, {"codex-api-key", "codex"}, {"claude-api-key", "claude"}, {"xai-api-key", "xai"}, {"meta-api-key", "meta"}, {"openai-compatibility", "openai-compatibility"}, } func buildV8Paths() []configPath { prefixes := []configPath{ {"host", "server.host"}, {"port", "server.port"}, {"max-request-body-bytes", "server.max-request-body-bytes"}, {"trusted-proxies", "server.trusted-proxies"}, {"github-token", "server.github-token"}, {"tls", "server.tls"}, {"commercial-mode", "server.commercial-mode"}, {"discovery", "server.discovery"}, {"kv-store", "server.kv-store"}, {"remote-management", "management"}, {"api-keys", "access.api-keys"}, {"credential-concurrency", "credentials.concurrency"}, {"credential-in-flight", "credentials.in-flight"}, {"force-model-prefix", "routing.force-model-prefix"}, {"request-retry", "routing.retry.request-retry"}, {"max-retry-credentials", "routing.retry.max-retry-credentials"}, {"max-retry-interval", "routing.retry.max-retry-interval"}, {"disable-cooling", "routing.cooldown.disable-cooling"}, {"save-cooldown-status", "routing.cooldown.save-cooldown-status"}, {"transient-error-cooldown-seconds", "routing.cooldown.transient-error-cooldown-seconds"}, {"proxy-url", "requests.proxy-url"}, {"tls-handshake-timeout-seconds", "requests.tls-handshake-timeout-seconds"}, {"passthrough-headers", "requests.passthrough-headers"}, {"nonstream-keepalive-interval", "requests.nonstream-keepalive-interval"}, {"streaming", "requests.streaming"}, {"payload", "requests.payload"}, {"response-compression", "server.response-compression"}, {"auth-dir", "oauth.auth-dir"}, {"auth-auto-refresh-workers", "oauth.auth-auto-refresh-workers"}, {"oauth-model-alias", "oauth.model-alias"}, {"oauth-excluded-models", "oauth.excluded-models"}, {"oauth-request-scoped-errors", "oauth.request-scoped-errors"}, {"oauth-settings", "oauth.settings"}, {"ws-auth", "oauth.providers.aistudio.ws-auth"}, {"codex.disable-codex-cloaking", "upstream.codex.disable-codex-cloaking"}, {"codex.stream-bootstrap-buffering", "upstream.codex.stream-bootstrap-buffering"}, {"codex.stream-bootstrap-timeout", "upstream.codex.stream-bootstrap-timeout"}, {"codex.orphan-delegation-compatibility", "upstream.codex.orphan-delegation-compatibility"}, {"codex.model-level-cooling", "upstream.codex.model-level-cooling"}, {"codex.response-steering", "upstream.codex.response-steering"}, {"codex.upstream-request-compression", "upstream.codex.upstream-request-compression"}, {"codex.upstream-request-compression-min-bytes", "upstream.codex.upstream-request-compression-min-bytes"}, {"codex.websocket-compression", "upstream.codex.websocket-compression"}, {"codex", "oauth.providers.codex"}, {"codex-header-defaults", "oauth.providers.codex.header-defaults"}, {"claude", "upstream.claude"}, {"claude-code", "upstream.claude"}, {"disable-claude-cloak-mode", "upstream.claude.disable-claude-cloak-mode"}, {"claude-header-defaults", "upstream.claude.header-defaults"}, {"antigravity", "oauth.providers.antigravity"}, {"antigravity-signature-cache-enabled", "oauth.providers.antigravity.signature-cache-enabled"}, {"antigravity-signature-bypass-strict", "oauth.providers.antigravity.signature-bypass-strict"}, {"quota-exceeded.antigravity-credits", "oauth.providers.antigravity.antigravity-credits"}, {"xai", "upstream.xai"}, {"devin", "oauth.providers.devin"}, {"disable-image-generation", "multimedia.disable-image-generation"}, {"gpt-image-2-base-model", "multimedia.gpt-image-2-base-model"}, {"video-result-auth-cache-ttl", "multimedia.video-result-auth-cache-ttl"}, {"debug", "observability.logs.debug"}, {"logging-to-file", "observability.logs.logging-to-file"}, {"logs-max-total-size-mb", "observability.logs.logs-max-total-size-mb"}, {"request-log", "observability.logs.request-log"}, {"error-logs-max-files", "observability.logs.error-logs-max-files"}, {"usage-statistics-enabled", "observability.usage.usage-statistics-enabled"}, {"redis-usage-queue-retention-seconds", "observability.usage.redis-usage-queue-retention-seconds"}, {"pprof", "observability.pprof"}, } var out []configPath var walk func(reflect.Type, string, []int) walk = func(t reflect.Type, path string, indexes []int) { if t.Kind() == reflect.Struct { for _, prefix := range prefixes { if path == prefix.old || strings.HasPrefix(path, prefix.old+".") { v8StructPaths = append(v8StructPaths, configPath{path, prefix.current + strings.TrimPrefix(path, prefix.old)}) break } } for i := 0; i < t.NumField(); i++ { field := t.Field(i) tag := strings.Split(field.Tag.Get("yaml"), ",")[0] if tag == "-" || field.PkgPath != "" { continue } fieldIndexes := append(append([]int(nil), indexes...), i) if field.Anonymous { walk(field.Type, path, fieldIndexes) continue } child := tag if path != "" { child = path + "." + tag } walk(field.Type, child, fieldIndexes) } return } for _, prefix := range prefixes { if path == prefix.old || strings.HasPrefix(path, prefix.old+".") { out = append(out, configPath{path, prefix.current + strings.TrimPrefix(path, prefix.old)}) v8FieldIndexes[path] = indexes return } } } walk(reflect.TypeOf(Config{}), "", nil) return out } func yamlPath(root *yaml.Node, path string) *yaml.Node { for _, key := range strings.Split(path, ".") { idx := findMapKeyIndex(root, key) if idx < 0 { return nil } root = root.Content[idx+1] } return root } func setYAMLPath(root *yaml.Node, path string, value *yaml.Node) { parts := strings.Split(path, ".") for _, key := range parts[:len(parts)-1] { root = getOrCreateMapValue(root, key) } dst := getOrCreateMapValue(root, parts[len(parts)-1]) *dst = *deepCopyNode(value) } // setYAMLPathWithComments attaches leading comments to the destination key; // yaml.v3 does not render a scalar value's HeadComment. func setYAMLPathWithComments(root *yaml.Node, path string, value *yaml.Node) { setYAMLPath(root, path, value) parts := strings.Split(path, ".") parent := root if len(parts) > 1 { parent = yamlPath(root, strings.Join(parts[:len(parts)-1], ".")) } parent.Content[findMapKeyIndex(parent, parts[len(parts)-1])].HeadComment = value.HeadComment parent.Content[findMapKeyIndex(parent, parts[len(parts)-1])].FootComment = value.FootComment yamlPath(root, path).HeadComment = "" yamlPath(root, path).FootComment = "" } // copyYAMLPathValue carries key comments and comments of pruned ancestors. func copyYAMLPathValue(root *yaml.Node, path string) *yaml.Node { copy := deepCopyNode(yamlPath(root, path)) parts := strings.Split(path, ".") parent := root parents := []*yaml.Node{root} if len(parts) > 1 { for _, part := range parts[:len(parts)-1] { parent = yamlPath(parent, part) parents = append(parents, parent) } } if idx := findMapKeyIndex(parent, parts[len(parts)-1]); idx >= 0 { key := parent.Content[idx] copy.HeadComment = strings.TrimSpace(key.HeadComment + "\n" + key.LineComment + "\n" + copy.HeadComment) copy.FootComment = strings.TrimSpace(copy.FootComment + "\n" + key.FootComment) } // A move also prunes single-child ancestors. Carry their comments with the // field; section line comments become leading comments at the destination. for i := len(parents) - 1; i > 0 && len(parents[i].Content) == 2; i-- { idx := findMapKeyIndex(parents[i-1], parts[i-1]) key := parents[i-1].Content[idx] copy.HeadComment = strings.TrimSpace(key.HeadComment + "\n" + key.LineComment + "\n" + parents[i].HeadComment + "\n" + parents[i].LineComment + "\n" + copy.HeadComment) copy.FootComment = strings.TrimSpace(copy.FootComment + "\n" + parents[i].FootComment + "\n" + key.FootComment) } return copy } func deleteYAMLPath(root *yaml.Node, path string) bool { parts := strings.SplitN(path, ".", 2) idx := findMapKeyIndex(root, parts[0]) if idx < 0 { return false } if len(parts) == 2 { child := root.Content[idx+1] if !deleteYAMLPath(child, parts[1]) { return false } if len(child.Content) != 0 { return true } } root.Content = append(root.Content[:idx], root.Content[idx+2:]...) return true } func legacyPath(root *yaml.Node, path string) *yaml.Node { node := yamlPath(root, path) if path == "api-keys" && node != nil && node.Kind == yaml.MappingNode { return nil } return node } // EffectiveResponseCompression returns the downstream response compression // settings from their canonical storage (SDKConfig.ResponseCompression, YAML // server.response-compression), falling back to the JSON/programmatic mirror // on the outer Config when the canonical value was never populated. func (cfg *Config) EffectiveResponseCompression() ResponseCompressionConfig { if cfg == nil { return ResponseCompressionConfig{} } if cfg.ResponseCompression.Enabled != nil || cfg.ResponseCompression.MinBytes != 0 { return cfg.ResponseCompression } return cfg.ServerResponseCompression } // SyncServerResponseCompression copies the canonical response compression // settings into the JSON-visible mirror on the outer Config. func (cfg *Config) SyncServerResponseCompression() { if cfg == nil { return } cfg.ServerResponseCompression = cfg.ResponseCompression } // UnmarshalYAML accepts both layouts, including partially migrated documents. // Presence, rather than Go zero values, determines which setting wins. func (cfg *Config) UnmarshalYAML(node *yaml.Node) error { root, err := flattenV8(node) if err != nil { return err } decoded := legacyConfig(*cfg) if err = root.Decode(&decoded); err != nil { return err } if errValidate := decoded.Models.Validate(); errValidate != nil { return errValidate } *cfg = Config(decoded) cfg.SyncServerResponseCompression() cfg.OAuthOnlyFields = nil source := expandConfigAliases(node) for _, path := range v8Paths { if strings.HasPrefix(path.current, "oauth.providers.") && yamlPath(source, path.current) != nil { if cfg.OAuthOnlyFields == nil { cfg.OAuthOnlyFields = make(map[string]bool) } cfg.OAuthOnlyFields[path.old] = true } } return nil } func flattenV8(node *yaml.Node) (*yaml.Node, error) { if node.Kind != yaml.MappingNode { return nil, fmt.Errorf("config must be a mapping") } // Decode once before transformation to reject duplicate keys even when a // winning v8 value would otherwise hide the malformed legacy subtree. var shape map[string]any if err := node.Decode(&shape); err != nil { return nil, err } node = expandConfigAliases(node) if _, err := normalizeV8PrivateIPAlias(node, true); err != nil { return nil, err } for _, path := range append(append([]configPath(nil), v8Paths...), v8Aliases...) { parts := strings.Split(path.current, ".") for i := 1; i < len(parts); i++ { parent := yamlPath(node, strings.Join(parts[:i], ".")) if parent == nil { break } // Routing is shared with the legacy layout, where null means defaults. if i == 1 && parts[0] == "routing" && parent.Tag == "!!null" { break } if parent.Kind != yaml.MappingNode { return nil, fmt.Errorf("%s must be a mapping", strings.Join(parts[:i], ".")) } } } root := deepCopyNode(node) for _, path := range v8Aliases { if yamlPath(root, path.old) != nil { if yamlPath(root, path.current) == nil { setYAMLPathWithComments(root, path.current, copyYAMLPathValue(root, path.old)) } deleteYAMLPath(root, path.old) } } for _, path := range v8SharedStructPaths { value := yamlPath(root, path.old) if value == nil { continue } if value.Tag != "!!null" && value.Kind != yaml.MappingNode { return nil, fmt.Errorf("%s must be a mapping", path.old) } if value.Tag != "!!null" && len(value.Content) != 0 { continue } if yamlPath(root, path.current) == nil { copy := copyYAMLPathValue(root, path.old) copy.Kind, copy.Tag, copy.Value = yaml.MappingNode, "!!map", "" setYAMLPathWithComments(root, path.current, copy) } deleteYAMLPath(root, path.old) } if version := yamlPath(root, "config-version"); version != nil && (version.Tag != "!!int" || version.Value != "8") { return nil, fmt.Errorf("unsupported config-version (expected 8)") } // The v8 upstream map reuses the legacy client-key field name. if keys := yamlPath(root, "api-keys"); keys != nil && keys.Kind == yaml.MappingNode { deleteYAMLPath(root, "api-keys") } for _, path := range v8Paths { if value := yamlPath(root, path.current); value != nil { copy := copyYAMLPathValue(root, path.current) deleteYAMLPath(root, path.current) setYAMLPathWithComments(root, path.old, copy) } } for _, family := range v8KeyFamilies { if groups := yamlPath(node, "api-keys."+family.current); groups != nil { keys, err := expandV8Groups(groups, family.current) if err != nil { return nil, err } setYAMLPath(root, family.old, keys) } } return root, nil } var sharedKeyFields = map[string]bool{ "priority": true, "prefix": true, "proxy-url": true, "headers": true, "models": true, "excluded-models": true, "disable-cooling": true, "request-retry": true, "request-scoped-errors": true, // connection-pool follows proxy-url: a group-level default that each key may // override, so an operator can pool one provider without touching the others. "connection-pool": true, } func expandV8Groups(groups *yaml.Node, provider string) (*yaml.Node, error) { if groups.Kind != yaml.SequenceNode { return nil, fmt.Errorf("api-keys.%s must be a list", provider) } out := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for index, group := range groups.Content { if group.Kind != yaml.MappingNode { return nil, fmt.Errorf("api-keys.%s[%d] must be a mapping", provider, index) } keys := yamlPath(group, "keys") if keys == nil || keys.Kind != yaml.SequenceNode { return nil, fmt.Errorf("api-keys.%s[%d].keys must be a list", provider, index) } if err := validateWeightSequenceNode(keys, "api-keys."+provider+".keys"); err != nil { return nil, err } if provider == "openai-compatibility" { item := deepCopyNode(group) deleteYAMLPath(item, "keys") deleteYAMLPath(item, "auth_index") deleteYAMLPath(item, "auth-index") cleanKeys := deepCopyNode(keys) for _, k := range cleanKeys.Content { deleteYAMLPath(k, "auth_index") deleteYAMLPath(k, "auth-index") } setYAMLPath(item, "api-key-entries", cleanKeys) out.Content = append(out.Content, item) continue } for i := 0; i < len(group.Content); i += 2 { field := group.Content[i].Value if field != "name" && field != "base-url" && field != "keys" && !sharedKeyFields[field] { return nil, fmt.Errorf("api-keys.%s: unsupported group field %s", provider, field) } } for _, key := range keys.Content { if key.Kind != yaml.MappingNode { return nil, fmt.Errorf("api-keys.%s key must be a mapping", provider) } if yamlPath(key, "base-url") != nil { return nil, fmt.Errorf("api-keys.%s: base-url belongs to the group", provider) } item := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} for i := 0; i < len(group.Content); i += 2 { field := group.Content[i].Value if field == "base-url" || sharedKeyFields[field] { setYAMLPath(item, field, group.Content[i+1]) } } for i := 0; i < len(key.Content); i += 2 { field := key.Content[i].Value if field == "auth_index" || field == "auth-index" { continue } if key.Content[i+1].Tag == "!!null" { continue } if field == "connection-pool" { // A per-key block merges onto the group block so a key that overrides // one field keeps inheriting the rest, matching the runtime resolution // where an omitted field inherits the outer value. Replacing the whole // block would silently drop the group's enabled: true. setYAMLPath(item, field, mergeConnectionPoolNode(yamlPath(group, field), key.Content[i+1])) continue } setYAMLPath(item, field, key.Content[i+1]) } out.Content = append(out.Content, item) } } return out, nil } // mergeConnectionPoolNode merges a per-key connection-pool block onto the group block. // Missing sub-keys fall back to the group value; the result is always a fresh node. func mergeConnectionPoolNode(base, override *yaml.Node) *yaml.Node { merged := deepCopyNode(base) if merged == nil || merged.Kind != yaml.MappingNode || override == nil || override.Kind != yaml.MappingNode { return deepCopyNode(override) } for i := 0; i < len(override.Content); i += 2 { if override.Content[i+1].Tag == "!!null" { continue } setYAMLPath(merged, override.Content[i].Value, override.Content[i+1]) } return merged } // groupLegacyKeys deliberately keeps one group per legacy entry: equal endpoints // do not imply equal routing, headers, models, or credential policies. func groupLegacyKeys(keys *yaml.Node, provider string) *yaml.Node { out := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for index, entry := range keys.Content { group := deepCopyNode(entry) if provider == "openai-compatibility" { entries := yamlPath(group, "api-key-entries") if entries == nil { entries = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} } setYAMLPath(group, "keys", entries) deleteYAMLPath(group, "api-key-entries") } else { group = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} setYAMLPath(group, "name", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprintf("%s-%d", provider, index+1)}) key := deepCopyNode(entry) for i := 0; i < len(entry.Content); i += 2 { field := entry.Content[i].Value if field == "base-url" || sharedKeyFields[field] { setYAMLPath(group, field, entry.Content[i+1]) deleteYAMLPath(key, field) } } setYAMLPath(group, "keys", &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{key}}) } out.Content = append(out.Content, group) } return out } // NormalizeConfigLayout removes conflicting legacy fields. migrate also moves // legacy-only fields; callers use it only for an explicit v8 configuration write. func NormalizeConfigLayout(data []byte, migrate bool) ([]byte, bool, error) { var doc yaml.Node if err := yaml.Unmarshal(data, &doc); err != nil { return nil, false, err } if len(doc.Content) == 0 { if !migrate { return data, false, nil } return nil, false, fmt.Errorf("empty config") } root := doc.Content[0] if _, err := flattenV8(root); err != nil { return nil, false, err } root = expandConfigAliases(root) doc.Content[0] = root changed, err := normalizeV8PrivateIPAlias(root, migrate) if err != nil { return nil, false, err } // Empty legacy structs have no leaf fields to move. Preserve them as empty v8 // mappings; null structs also mean defaults. User-owned maps are not included. paths := append(append([]configPath(nil), v8Aliases...), v8Paths...) for _, path := range append(append([]configPath(nil), v8StructPaths...), v8SharedStructPaths...) { old := yamlPath(root, path.old) if old == nil || (!migrate && yamlPath(root, path.current) == nil) { continue } if old.Tag == "!!null" { old.Kind, old.Tag, old.Value = yaml.MappingNode, "!!map", "" } else if old.Kind != yaml.MappingNode || len(old.Content) != 0 { continue } paths = append(paths, path) } for _, path := range paths { old := legacyPath(root, path.old) if old == nil { continue } current := yamlPath(root, path.current) if current == nil && !migrate { continue } copy := copyYAMLPathValue(root, path.old) deleteYAMLPath(root, path.old) if current == nil { setYAMLPathWithComments(root, path.current, copy) } changed = true } for _, family := range v8KeyFamilies { old := yamlPath(root, family.old) if old == nil { continue } path := "api-keys." + family.current if yamlPath(root, path) == nil { if !migrate { continue } setYAMLPath(root, path, groupLegacyKeys(old, family.current)) } deleteYAMLPath(root, family.old) changed = true } if migrate { // Existing unknown fields are ignored by the runtime. Retain their // contents as comments while keeping new v8 writes strictly validated. if err := commentUnknownV8Sections(root); err != nil { return nil, false, err } setYAMLPath(root, "config-version", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "8"}) changed = true } if !changed { return data, false, nil } out, err := yaml.Marshal(&doc) return out, true, err } // IsV8ConfigLayout identifies a validated document by its version or v8 paths. // Roots shared with the legacy runtime schema do not alone indicate v8. func IsV8ConfigLayout(root *yaml.Node) bool { if root == nil || root.Kind != yaml.MappingNode { return false } root = expandConfigAliases(root) sections := v8AllowedRoots() for _, shared := range []string{"api-keys", "plugins", "quota-exceeded", "routing", "client"} { delete(sections, shared) } for i := 0; i < len(root.Content); i += 2 { if sections[root.Content[i].Value] { return true } } if keys := yamlPath(root, "api-keys"); keys != nil && keys.Kind == yaml.MappingNode { return true } paths := append(append(append([]configPath(nil), v8Paths...), v8StructPaths...), v8Aliases...) for _, path := range paths { if path.old != path.current && yamlPath(root, path.current) != nil { return true } if strings.HasPrefix(path.old, "providers.") && yamlPath(root, path.old) != nil { return true } if path.old != path.current && strings.HasPrefix(path.current, "routing.") { child, _, _ := strings.Cut(strings.TrimPrefix(path.current, "routing."), ".") if yamlPath(root, "routing."+child) != nil { return true } } } return false } func v8AllowedRoots() map[string]bool { allowed := map[string]bool{"models": true, "config-version": true, "api-keys": true, "plugins": true, "quota-exceeded": true, "client": true} for _, path := range v8Paths { section, _, _ := strings.Cut(path.current, ".") allowed[section] = true } return allowed } var ( v8WarnMu sync.RWMutex v8WarnFunc func(section, msg string) ) // SetV8MigrationWarnFunc sets a custom warning handler (e.g. from logging package). func SetV8MigrationWarnFunc(fn func(section, msg string)) { v8WarnMu.Lock() defer v8WarnMu.Unlock() v8WarnFunc = fn } func warnUnrecognizedV8Section(section string) { msg := fmt.Sprintf("unrecognized configuration section %q commented out during v8 migration", section) v8WarnMu.RLock() fn := v8WarnFunc v8WarnMu.RUnlock() if fn != nil { fn(section, msg) return } log.Warn(msg) } func commentUnknownV8Sections(root *yaml.Node) error { if err := commentUnknownV8Fields(root, root, v8AllowedRoots(), ""); err != nil { return err } children := make(map[string]map[string]bool) for _, path := range append(append([]configPath(nil), v8Paths...), v8StructPaths...) { parts := strings.Split(path.current, ".") for i := 1; i < len(parts); i++ { parent := strings.Join(parts[:i], ".") if children[parent] == nil { children[parent] = make(map[string]bool) } children[parent][parts[i]] = true } } // Some structs already live at their v8 path and therefore have no remap entry. allowedRoots := v8AllowedRoots() var includeNative func(reflect.Type, string) includeNative = func(t reflect.Type, path string) { if t.Kind() != reflect.Struct { return } for i := 0; i < t.NumField(); i++ { field := t.Field(i) tag := strings.Split(field.Tag.Get("yaml"), ",")[0] if tag == "-" || field.PkgPath != "" { continue } if field.Anonymous { includeNative(field.Type, path) continue } if path == "" && !allowedRoots[tag] { continue } if children[path] == nil { children[path] = make(map[string]bool) } children[path][tag] = true child := tag if path != "" { child = path + "." + tag } includeNative(field.Type, child) } } includeNative(reflect.TypeOf(Config{}), "") var walk func(*yaml.Node, string) error walk = func(node *yaml.Node, path string) error { if node == nil || node.Kind != yaml.MappingNode || children[path] == nil { return nil } if err := commentUnknownV8Fields(node, root, children[path], path); err != nil { return err } for i := 0; i+1 < len(node.Content); i += 2 { child := path + "." + node.Content[i].Value if err := walk(node.Content[i+1], child); err != nil { return err } } return nil } for i := 0; i+1 < len(root.Content); i += 2 { if err := walk(root.Content[i+1], root.Content[i].Value); err != nil { return err } } return nil } func commentUnknownV8Fields(node *yaml.Node, archive *yaml.Node, allowed map[string]bool, path string) error { var comments []string for i := 0; i+1 < len(node.Content); { key := node.Content[i].Value if allowed[key] { i += 2 continue } section := key if path != "" { section = path + "." + key } warnUnrecognizedV8Section(section) keyNode := *node.Content[i] keyNode.Value = section entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{&keyNode, node.Content[i+1]}} data, errMarshal := yaml.Marshal(entry) if errMarshal != nil { return errMarshal } text := strings.TrimSuffix(string(data), "\n") comments = append(comments, "# "+strings.ReplaceAll(text, "\n", "\n# ")) node.Content = append(node.Content[:i], node.Content[i+2:]...) } // Archive at the root so deleting a neighboring nested field cannot drop it. if len(comments) > 0 { archive.FootComment = strings.TrimSpace(archive.FootComment + "\n" + strings.Join(comments, "\n")) } return nil } // The deprecated allow flag is the inverse of disable-private-remote-ips. // Resolve it before applying v8 precedence so both names cannot reach the runtime // decoder together. Legacy-only files are left untouched until a migration/save. func normalizeV8PrivateIPAlias(root *yaml.Node, migrate bool) (bool, error) { const old = "codex.live-media-relay.allow-private-remote-ips" const canonical = "codex.live-media-relay.disable-private-remote-ips" value := yamlPath(root, old) if value == nil { return false, nil } if yamlPath(root, "oauth.providers."+canonical) != nil { return deleteYAMLPath(root, old), nil } if !migrate || yamlPath(root, canonical) != nil { return false, nil } var allow bool if err := value.Decode(&allow); err != nil { return false, fmt.Errorf("decode %s: %w", old, err) } replacement := deepCopyNode(value) if err := replacement.Encode(!allow); err != nil { return false, err } setYAMLPath(root, canonical, replacement) deleteYAMLPath(root, old) return true, nil } // restoreV8Layout lets v0 handlers update the effective runtime fields without // reintroducing their legacy spellings into an existing v8 document. func restoreV8Layout(root, layout *yaml.Node, original []byte, generated *yaml.Node) error { for _, path := range v8Paths { upstreams := yamlPath(layout, "api-keys") clientKeyCollision := path.old == "api-keys" && upstreams != nil && upstreams.Kind == yaml.MappingNode if yamlPath(layout, path.current) == nil && !clientKeyCollision { continue } value := legacyPath(root, path.old) if value == nil { continue } copy := copyYAMLPathValue(root, path.old) deleteYAMLPath(root, path.old) setYAMLPathWithComments(root, path.current, copy) } var baseline *yaml.Node for _, family := range v8KeyFamilies { groups := yamlPath(layout, "api-keys."+family.current) if groups == nil { continue } if baseline == nil { cfg, err := ParseConfigBytes(original) if err != nil { return err } baseline = new(yaml.Node) if err = baseline.Encode((*legacyConfig)(cfg)); err != nil { return err } } var before, after any if value := yamlPath(baseline, family.old); value != nil { if err := value.Decode(&before); err != nil { return err } } if value := yamlPath(generated, family.old); value != nil { if err := value.Decode(&after); err != nil { return err } } if !reflect.DeepEqual(before, after) { keys := yamlPath(root, family.old) if keys == nil { keys = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} } groups = groupLegacyKeys(keys, family.current) } deleteYAMLPath(root, family.old) setYAMLPath(root, "api-keys."+family.current, groups) } preserveV8Comments(root, layout) return nil } func preserveV8Comments(dst, src *yaml.Node) { if dst == nil || src == nil { return } dst.HeadComment, dst.LineComment, dst.FootComment = src.HeadComment, src.LineComment, src.FootComment if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode { return } for i := 0; i < len(src.Content); i += 2 { if index := findMapKeyIndex(dst, src.Content[i].Value); index >= 0 { preserveV8Comments(dst.Content[index], src.Content[i]) preserveV8Comments(dst.Content[index+1], src.Content[i+1]) } } } // Expand aliases and YAML merge keys before moving paths. Keeping an alias to a // removed legacy anchor would produce an unreadable file, and updating a shared // anchor in place could unintentionally change an unrelated setting. // Callers first decode the document so yaml.v3 rejects cyclic/invalid aliases. func expandConfigAliases(node *yaml.Node) *yaml.Node { if node.Kind == yaml.AliasNode { return expandConfigAliases(node.Alias) } copy := *node copy.Anchor = "" copy.Content = make([]*yaml.Node, len(node.Content)) for i, child := range node.Content { copy.Content[i] = expandConfigAliases(child) } if copy.Kind != yaml.MappingNode { return © } for i := 0; i < len(copy.Content); i += 2 { if copy.Content[i].Tag != "!!merge" { continue } merge := copy.Content[i+1] copy.Content = append(copy.Content[:i], copy.Content[i+2:]...) mappings := []*yaml.Node{merge} if merge.Kind == yaml.SequenceNode { mappings = merge.Content } for _, mapping := range mappings { for j := 0; j < len(mapping.Content); j += 2 { if findMapKeyIndex(©, mapping.Content[j].Value) < 0 { copy.Content = append(copy.Content, mapping.Content[j], mapping.Content[j+1]) } } } i -= 2 } return © } // ValidateV8Config accepts only the v8 layout in management writes. // Plugin-owned configuration remains extensible through its existing decoder. func ValidateV8Config(data []byte) error { var doc yaml.Node if err := yaml.Unmarshal(data, &doc); err != nil { return err } if len(doc.Content) == 0 { return fmt.Errorf("empty config") } root := doc.Content[0] flat, err := flattenV8(root) if err != nil { return err } root = expandConfigAliases(root) allowedRoots := v8AllowedRoots() for _, path := range append(append(append([]configPath(nil), v8Paths...), v8Aliases...), v8SharedStructPaths...) { if legacyPath(root, path.old) != nil { return fmt.Errorf("legacy field %s is not accepted by v8; use %s", path.old, path.current) } } for i := 0; i < len(root.Content); i += 2 { if key := root.Content[i].Value; !allowedRoots[key] { return fmt.Errorf("unknown v8 configuration section %s", key) } } if groups := yamlPath(root, "api-keys"); groups != nil && groups.Kind == yaml.MappingNode { for i := 0; i < len(groups.Content); i += 2 { found := false for _, family := range v8KeyFamilies { if groups.Content[i].Value == family.current { found = true break } } if !found { return fmt.Errorf("unknown API-key provider %s", groups.Content[i].Value) } } } deleteYAMLPath(flat, "config-version") // Empty struct containers are valid replacements. Only strip known structural // paths; empty user maps (headers, aliases, plugin options) carry real values. for _, path := range v8Paths { parts := strings.Split(path.current, ".") for end := len(parts) - 1; end > 0; end-- { container := strings.Join(parts[:end], ".") if value := yamlPath(flat, container); value != nil && value.Kind == yaml.MappingNode && len(value.Content) == 0 { deleteYAMLPath(flat, container) } } } encoded, err := yaml.Marshal(flat) if err != nil { return err } decoder := yaml.NewDecoder(bytes.NewReader(encoded)) decoder.KnownFields(true) var cfg legacyConfig if errDecode := decoder.Decode(&cfg); errDecode != nil { return errDecode } return cfg.Models.Validate() }