internal/config/config_v8.go
Rama main · réplica de lectura del repositorio original.
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()
}