Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,76 @@
package config
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestAPIKeyModelIsCompatConfigDecoding(t *testing.T) {
const yamlConfig = `gemini-api-key:
- models:
- name: gemini-upstream
alias: gemini-alias
is-compat: true
- name: gemini-native
alias: gemini-native
interactions-api-key:
- models:
- name: interactions-upstream
alias: interactions-alias
is-compat: true
xai-api-key:
- models:
- name: xai-upstream
alias: xai-alias
is-compat: true
claude-api-key:
- models:
- name: claude-upstream
alias: claude-alias
is-compat: true
codex-api-key:
- models:
- name: codex-upstream
alias: codex-alias
is-compat: true
openai-compatibility:
- name: deepseek
models:
- name: deepseek-upstream
alias: deepseek-alias
is-compat: true
- name: openai-native
alias: openai-native
`
var cfg Config
if errDecode := yaml.Unmarshal([]byte(yamlConfig), &cfg); errDecode != nil {
t.Fatalf("decode error: %v", errDecode)
}
if len(cfg.GeminiKey) != 1 || !cfg.GeminiKey[0].Models[0].IsCompat {
t.Fatalf("gemini-api-key IsCompat = %+v, want true", cfg.GeminiKey)
}
if cfg.GeminiKey[0].Models[1].IsCompat {
t.Fatal("gemini-api-key omitted IsCompat = true, want default false")
}
if len(cfg.InteractionsKey) != 1 || !cfg.InteractionsKey[0].Models[0].IsCompat {
t.Fatalf("interactions-api-key IsCompat = %+v, want true", cfg.InteractionsKey)
}
if len(cfg.XAIKey) != 1 || !cfg.XAIKey[0].Models[0].IsCompat {
t.Fatalf("xai-api-key IsCompat = %+v, want true", cfg.XAIKey)
}
if len(cfg.ClaudeKey) != 1 || !cfg.ClaudeKey[0].Models[0].IsCompat {
t.Fatalf("claude-api-key IsCompat = %+v, want true", cfg.ClaudeKey)
}
if len(cfg.CodexKey) != 1 || !cfg.CodexKey[0].Models[0].IsCompat {
t.Fatalf("codex-api-key IsCompat = %+v, want true", cfg.CodexKey)
}
if len(cfg.OpenAICompatibility) != 1 || !cfg.OpenAICompatibility[0].Models[0].IsCompat {
t.Fatalf("openai-compatibility IsCompat = %+v, want true", cfg.OpenAICompatibility)
}
if cfg.OpenAICompatibility[0].Models[1].IsCompat {
t.Fatal("openai-compatibility omitted IsCompat = true, want default false")
}
}

View file

@ -0,0 +1,34 @@
package config
import "testing"
func TestParseConfigBytesClaudeCodeModelListCloaking(t *testing.T) {
tests := []struct {
name string
yaml string
want bool
}{
{
name: "defaults to enabled cloaking",
yaml: "port: 8317\n",
want: false,
},
{
name: "disables model list cloaking",
yaml: "claude-code:\n disable-cloaking-model-list: true\n",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(tt.yaml))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if got := cfg.ClaudeCode.DisableCloakingModelList; got != tt.want {
t.Fatalf("DisableCloakingModelList = %t, want %t", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,44 @@
package config
import (
"fmt"
"strings"
)
// Claude fingerprint profile values for ClaudeKey.FingerprintProfile and for the
// matching auth-file / auth-attribute field. This is the single source of truth:
// the runtime, the config sanitizer and the Management API all resolve a raw
// value through NormalizeClaudeFingerprintProfile so an operator cannot end up
// with a value that one layer accepts and another silently ignores.
const (
// ClaudeFingerprintProfileDefault keeps the caller-owned request fingerprint.
ClaudeFingerprintProfileDefault = ""
// ClaudeFingerprintProfileClaudeCodeCLI opts into the Claude Code CLI Messages fingerprint.
ClaudeFingerprintProfileClaudeCodeCLI = "claude-code-cli"
// claudeFingerprintProfileOAuthCLIAlias is the legacy spelling of claude-code-cli.
claudeFingerprintProfileOAuthCLIAlias = "oauth-cli"
)
// NormalizeClaudeFingerprintProfile maps a raw configured value to its canonical
// form. The second result reports whether the value is recognized; an
// unrecognized value normalizes to the default (caller-owned) profile.
func NormalizeClaudeFingerprintProfile(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case ClaudeFingerprintProfileClaudeCodeCLI, claudeFingerprintProfileOAuthCLIAlias:
return ClaudeFingerprintProfileClaudeCodeCLI, true
case ClaudeFingerprintProfileDefault:
return ClaudeFingerprintProfileDefault, true
default:
return ClaudeFingerprintProfileDefault, false
}
}
// ValidateClaudeFingerprintProfile reports an error for values that would be
// silently ignored at request time. Write paths (Management API) use it to
// reject a typo instead of letting it reach the request path.
func ValidateClaudeFingerprintProfile(raw string) error {
if _, ok := NormalizeClaudeFingerprintProfile(raw); !ok {
return fmt.Errorf("unsupported fingerprint-profile %q (supported: %q or empty)", strings.TrimSpace(raw), ClaudeFingerprintProfileClaudeCodeCLI)
}
return nil
}

View file

@ -0,0 +1,58 @@
package config
import "testing"
func TestNormalizeClaudeFingerprintProfile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
want string
wantK bool
}{
{name: "empty", raw: "", want: ClaudeFingerprintProfileDefault, wantK: true},
{name: "blank", raw: " ", want: ClaudeFingerprintProfileDefault, wantK: true},
{name: "canonical", raw: "claude-code-cli", want: ClaudeFingerprintProfileClaudeCodeCLI, wantK: true},
{name: "mixed case and padding", raw: " Claude-Code-CLI ", want: ClaudeFingerprintProfileClaudeCodeCLI, wantK: true},
{name: "legacy alias", raw: "oauth-cli", want: ClaudeFingerprintProfileClaudeCodeCLI, wantK: true},
{name: "typo", raw: "claude-code", want: ClaudeFingerprintProfileDefault, wantK: false},
{name: "unrelated", raw: "chrome", want: ClaudeFingerprintProfileDefault, wantK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, ok := NormalizeClaudeFingerprintProfile(tt.raw)
if got != tt.want || ok != tt.wantK {
t.Fatalf("NormalizeClaudeFingerprintProfile(%q) = (%q, %t), want (%q, %t)", tt.raw, got, ok, tt.want, tt.wantK)
}
errValidate := ValidateClaudeFingerprintProfile(tt.raw)
if (errValidate == nil) != tt.wantK {
t.Fatalf("ValidateClaudeFingerprintProfile(%q) error = %v, want error = %t", tt.raw, errValidate, !tt.wantK)
}
})
}
}
// An unrecognized value must survive sanitization: rewriting a config file is not
// the place to discard operator input, and the request path already falls back to
// the default profile.
func TestSanitizeClaudeKeysFingerprintProfile(t *testing.T) {
cfg := &Config{ClaudeKey: []ClaudeKey{
{APIKey: "a", FingerprintProfile: " OAuth-CLI "},
{APIKey: "b", FingerprintProfile: " claude-code "},
{APIKey: "c"},
}}
cfg.SanitizeClaudeKeys()
if got := cfg.ClaudeKey[0].FingerprintProfile; got != ClaudeFingerprintProfileClaudeCodeCLI {
t.Fatalf("recognized alias = %q, want %q", got, ClaudeFingerprintProfileClaudeCodeCLI)
}
if got := cfg.ClaudeKey[1].FingerprintProfile; got != "claude-code" {
t.Fatalf("unrecognized value = %q, want it preserved as written", got)
}
if got := cfg.ClaudeKey[2].FingerprintProfile; got != "" {
t.Fatalf("absent value = %q, want empty", got)
}
}

View file

@ -0,0 +1,59 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigOptional_ClaudeHeaderDefaults(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
configYAML := []byte(`
claude-header-defaults:
user-agent: " claude-cli/2.1.70 (external, cli) "
package-version: " 0.80.0 "
runtime-version: " v24.5.0 "
os: " MacOS "
arch: " arm64 "
timeout: " 900 "
timezone: " Pacific/Honolulu "
stabilize-device-profile: false
`)
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := LoadConfigOptional(configPath, false)
if err != nil {
t.Fatalf("LoadConfigOptional() error = %v", err)
}
if got := cfg.ClaudeHeaderDefaults.UserAgent; got != "claude-cli/2.1.70 (external, cli)" {
t.Fatalf("UserAgent = %q, want %q", got, "claude-cli/2.1.70 (external, cli)")
}
if got := cfg.ClaudeHeaderDefaults.PackageVersion; got != "0.80.0" {
t.Fatalf("PackageVersion = %q, want %q", got, "0.80.0")
}
if got := cfg.ClaudeHeaderDefaults.RuntimeVersion; got != "v24.5.0" {
t.Fatalf("RuntimeVersion = %q, want %q", got, "v24.5.0")
}
if got := cfg.ClaudeHeaderDefaults.OS; got != "MacOS" {
t.Fatalf("OS = %q, want %q", got, "MacOS")
}
if got := cfg.ClaudeHeaderDefaults.Arch; got != "arm64" {
t.Fatalf("Arch = %q, want %q", got, "arm64")
}
if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" {
t.Fatalf("Timeout = %q, want %q", got, "900")
}
if got := cfg.ClaudeHeaderDefaults.Timezone; got != "Pacific/Honolulu" {
t.Fatalf("Timezone = %q, want %q", got, "Pacific/Honolulu")
}
if cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil {
t.Fatal("StabilizeDeviceProfile = nil, want non-nil")
}
if got := *cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile; got {
t.Fatalf("StabilizeDeviceProfile = %v, want false", got)
}
}

View file

@ -0,0 +1,81 @@
package config
import (
"reflect"
"gopkg.in/yaml.v3"
)
var yamlNodeType = reflect.TypeOf(yaml.Node{})
// CloneForRuntime returns an independent in-memory snapshot of the full config.
func (cfg *Config) CloneForRuntime() *Config {
if cfg == nil {
return nil
}
cloned := cloneRuntimeValue(reflect.ValueOf(cfg))
return cloned.Interface().(*Config)
}
func cloneRuntimeValue(v reflect.Value) reflect.Value {
if !v.IsValid() {
return v
}
if v.Type() == yamlNodeType {
node := v.Interface().(yaml.Node)
return reflect.ValueOf(*deepCopyNode(&node))
}
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneRuntimeValue(v.Elem()))
return out
case reflect.Interface:
if v.IsNil() {
return reflect.Zero(v.Type())
}
return cloneRuntimeValue(v.Elem())
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.NumField(); i++ {
dst := out.Field(i)
if !dst.CanSet() {
return v
}
dst.Set(cloneRuntimeValue(v.Field(i)))
}
return out
case reflect.Slice:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneRuntimeValue(v.Index(i)))
}
return out
case reflect.Array:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneRuntimeValue(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(cloneRuntimeValue(iter.Key()), cloneRuntimeValue(iter.Value()))
}
return out
default:
return v
}
}

View file

@ -0,0 +1,324 @@
package config
import (
"reflect"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"gopkg.in/yaml.v3"
)
func TestCloneForRuntimeNil(t *testing.T) {
var cfg *Config
if got := cfg.CloneForRuntime(); got != nil {
t.Fatalf("CloneForRuntime() = %#v, want nil", got)
}
}
func TestParseConfigBytes_AntigravitySensitiveWords(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`antigravity:
sensitive-words:
- "API"
- "proxy"
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
want := []string{"API", "proxy"}
if !reflect.DeepEqual(cfg.Antigravity.SensitiveWords, want) {
t.Fatalf("Antigravity.SensitiveWords = %#v, want %#v", cfg.Antigravity.SensitiveWords, want)
}
}
func TestCloneForRuntimeDeepCopiesConfig(t *testing.T) {
cfg := sampleCloneRuntimeConfig()
clone := cfg.CloneForRuntime()
if clone == nil {
t.Fatal("CloneForRuntime() = nil")
}
if clone == cfg {
t.Fatal("CloneForRuntime() returned original pointer")
}
mutateOriginalConfig(cfg)
if clone.Home.Host != "home.local" {
t.Fatalf("clone.Home.Host = %q, want home.local", clone.Home.Host)
}
if clone.APIKeys[0] != "client-key" {
t.Fatalf("clone.APIKeys[0] = %q, want client-key", clone.APIKeys[0])
}
if clone.OAuthExcludedModels["codex"][0] != "hidden-model" {
t.Fatalf("clone.OAuthExcludedModels[codex][0] = %q, want hidden-model", clone.OAuthExcludedModels["codex"][0])
}
if clone.OAuthModelAlias["codex"][0].Alias != "client-model" {
t.Fatalf("clone.OAuthModelAlias[codex][0].Alias = %q, want client-model", clone.OAuthModelAlias["codex"][0].Alias)
}
if got := pluginRawScalar(t, clone.Plugins.Configs["sample"].Raw, "mode"); got != "first" {
t.Fatalf("clone plugin raw mode = %q, want first", got)
}
if clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "low" {
t.Fatalf("clone thinking level = %q, want low", clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0])
}
if got := clone.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "value" {
t.Fatalf("clone payload object key = %#v, want value", got)
}
clone.APIKeys[0] = "clone-client-key"
clone.OAuthExcludedModels["codex"][0] = "clone-hidden-model"
clone.OAuthModelAlias["codex"][0].Alias = "clone-client-model"
clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "clone-low"
clone.Payload.Default[0].Params["object"].(map[string]any)["key"] = "clone-value"
plugin := clone.Plugins.Configs["sample"]
setPluginRawScalar(t, &plugin.Raw, "mode", "third")
clone.Plugins.Configs["sample"] = plugin
if cfg.APIKeys[0] != "mutated-client-key" {
t.Fatalf("cfg.APIKeys[0] = %q, want mutated-client-key", cfg.APIKeys[0])
}
if cfg.OAuthExcludedModels["codex"][0] != "mutated-hidden-model" {
t.Fatalf("cfg.OAuthExcludedModels[codex][0] = %q, want mutated-hidden-model", cfg.OAuthExcludedModels["codex"][0])
}
if cfg.OAuthModelAlias["codex"][0].Alias != "mutated-client-model" {
t.Fatalf("cfg.OAuthModelAlias[codex][0].Alias = %q, want mutated-client-model", cfg.OAuthModelAlias["codex"][0].Alias)
}
if got := pluginRawScalar(t, cfg.Plugins.Configs["sample"].Raw, "mode"); got != "second" {
t.Fatalf("cfg plugin raw mode = %q, want second", got)
}
if cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "mutated-low" {
t.Fatalf("cfg thinking level = %q, want mutated-low", cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0])
}
if got := cfg.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "mutated-value" {
t.Fatalf("cfg payload object key = %#v, want mutated-value", got)
}
}
func TestCloneForRuntimeDoesNotShareReferenceFields(t *testing.T) {
cfg := sampleCloneRuntimeConfig()
clone := cfg.CloneForRuntime()
assertNoSharedRuntimeReferences(t, reflect.ValueOf(cfg), reflect.ValueOf(clone), "Config")
}
func sampleCloneRuntimeConfig() *Config {
cacheStrict := true
bypassStrict := false
pluginEnabled := false
cacheUserID := true
return &Config{
SDKConfig: SDKConfig{
APIKeys: []string{"client-key"},
Streaming: StreamingConfig{
KeepAliveSeconds: 3,
BootstrapRetries: 2,
},
},
Home: HomeConfig{
Enabled: true,
Host: "home.local",
Port: 8081,
TLS: HomeTLSConfig{
Enable: true,
ServerName: "home.local",
CACert: "ca",
ClientCert: "cert",
ClientKey: "key",
UseTargetServerName: true,
},
},
Plugins: PluginsConfig{
Enabled: true,
Dir: "plugins",
StoreSources: []string{"https://plugins.example/store.json"},
Configs: map[string]PluginInstanceConfig{
"sample": {
Enabled: &pluginEnabled,
Priority: 10,
Raw: samplePluginRawNode("first"),
},
},
},
AntigravitySignatureCacheEnabled: &cacheStrict,
AntigravitySignatureBypassStrict: &bypassStrict,
GeminiKey: []GeminiKey{{
APIKey: "gemini-key",
Models: []GeminiModel{{Name: "gemini-upstream", Alias: "gemini-upstream-alias"}},
Headers: map[string]string{"X-Gemini": "one"},
ExcludedModels: []string{"gemini-hidden"},
}},
CodexKey: []CodexKey{{
APIKey: "codex-key",
Models: []CodexModel{{Name: "codex-upstream", Alias: "codex-client"}},
Headers: map[string]string{"X-Codex": "one"},
ExcludedModels: []string{"codex-hidden-key"},
}},
ClaudeKey: []ClaudeKey{{
APIKey: "claude-key",
Models: []ClaudeModel{{Name: "claude-upstream", Alias: "claude-client"}},
Headers: map[string]string{"X-Claude": "one"},
ExcludedModels: []string{"claude-hidden"},
Cloak: &CloakConfig{
SensitiveWords: []string{"secret"},
CacheUserID: &cacheUserID,
},
}},
OpenAICompatibility: []OpenAICompatibility{{
Name: "compat",
APIKeyEntries: []OpenAICompatibilityAPIKey{{APIKey: "compat-key", ProxyURL: "http://proxy.local"}},
Models: []OpenAICompatibilityModel{{
Name: "compat-upstream",
Alias: "compat-client",
Thinking: &registry.ThinkingSupport{Levels: []string{"low", "high"}},
}},
Headers: map[string]string{"X-Compat": "one"},
}},
VertexCompatAPIKey: []VertexCompatKey{{
APIKey: "vertex-key",
Headers: map[string]string{"X-Vertex": "one"},
Models: []VertexCompatModel{{Name: "vertex-upstream", Alias: "vertex-client"}},
ExcludedModels: []string{"vertex-hidden"},
}},
OAuthExcludedModels: map[string][]string{
"codex": {"hidden-model"},
},
OAuthModelAlias: map[string][]OAuthModelAlias{
"codex": {{Name: "upstream-model", Alias: "client-model", Fork: true}},
},
Payload: PayloadConfig{
Default: []PayloadRule{{
Models: []PayloadModelRule{{
Name: "model-*",
Headers: map[string]string{"X-Tier": "gold"},
Match: []map[string]any{{"tier": "gold"}},
Exist: []string{"$.messages"},
}},
Params: map[string]any{
"object": map[string]any{"key": "value"},
"array": []any{"first", map[string]any{"nested": "value"}},
},
}},
Filter: []PayloadFilterRule{{
Models: []PayloadModelRule{{Name: "model-*"}},
Params: []string{"$.secret"},
}},
},
}
}
func mutateOriginalConfig(cfg *Config) {
cfg.Home.Host = "mutated-home.local"
cfg.APIKeys[0] = "mutated-client-key"
cfg.OAuthExcludedModels["codex"][0] = "mutated-hidden-model"
cfg.OAuthModelAlias["codex"][0].Alias = "mutated-client-model"
cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "mutated-low"
cfg.Payload.Default[0].Params["object"].(map[string]any)["key"] = "mutated-value"
plugin := cfg.Plugins.Configs["sample"]
setPluginRawScalar(nil, &plugin.Raw, "mode", "second")
cfg.Plugins.Configs["sample"] = plugin
}
func samplePluginRawNode(mode string) yaml.Node {
modeValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: mode, Anchor: "modeAnchor"}
return yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"},
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode"},
modeValue,
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode-alias"},
{Kind: yaml.AliasNode, Alias: modeValue},
},
}
}
func pluginRawScalar(t *testing.T, node yaml.Node, key string) string {
t.Helper()
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil {
return node.Content[i+1].Value
}
}
t.Fatalf("raw plugin node missing key %q", key)
return ""
}
func setPluginRawScalar(t *testing.T, node *yaml.Node, key, value string) {
if t != nil {
t.Helper()
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil {
node.Content[i+1].Value = value
return
}
}
if t != nil {
t.Fatalf("raw plugin node missing key %q", key)
}
}
func assertNoSharedRuntimeReferences(t *testing.T, original, clone reflect.Value, path string) {
t.Helper()
if !original.IsValid() || !clone.IsValid() {
return
}
if original.Kind() == reflect.Interface {
if original.IsNil() || clone.IsNil() {
return
}
assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path)
return
}
if original.Kind() != clone.Kind() {
t.Fatalf("%s kind mismatch: %s != %s", path, original.Kind(), clone.Kind())
}
switch original.Kind() {
case reflect.Pointer:
if original.IsNil() || clone.IsNil() {
return
}
if original.Pointer() == clone.Pointer() {
t.Fatalf("%s shares pointer %x", path, original.Pointer())
}
assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path+"->"+original.Type().Elem().String())
case reflect.Map:
if original.IsNil() || clone.IsNil() {
return
}
if original.Pointer() == clone.Pointer() {
t.Fatalf("%s shares map pointer %x", path, original.Pointer())
}
iter := original.MapRange()
for iter.Next() {
key := iter.Key()
assertNoSharedRuntimeReferences(t, iter.Value(), clone.MapIndex(key), path+"["+keyForPath(key)+"]")
}
case reflect.Slice:
if original.IsNil() || clone.IsNil() {
return
}
if original.Pointer() == clone.Pointer() {
t.Fatalf("%s shares slice pointer %x", path, original.Pointer())
}
for i := 0; i < original.Len(); i++ {
assertNoSharedRuntimeReferences(t, original.Index(i), clone.Index(i), path+"[]")
}
case reflect.Struct:
for i := 0; i < original.NumField(); i++ {
field := original.Type().Field(i)
assertNoSharedRuntimeReferences(t, original.Field(i), clone.Field(i), path+"."+field.Name)
}
}
}
func keyForPath(key reflect.Value) string {
if key.Kind() == reflect.String {
return key.String()
}
return key.Type().String()
}

View file

@ -0,0 +1,106 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// DefaultCodexLiveMediaMaxSessions is the default in-process media session limit.
const DefaultCodexLiveMediaMaxSessions = 32
// UnmarshalYAML supports the deprecated allow-private-remote-ips setting while
// preserving the default behavior of allowing private downstream candidates.
func (c *CodexLiveMediaRelayConfig) UnmarshalYAML(value *yaml.Node) error {
type plain CodexLiveMediaRelayConfig
var decoded plain
if errDecode := value.Decode(&decoded); errDecode != nil {
return errDecode
}
var allowPrivate *bool
var disablePrivate *bool
if value.Kind == yaml.MappingNode {
for index := 0; index+1 < len(value.Content); index += 2 {
key := value.Content[index].Value
switch key {
case "allow-private-remote-ips":
var setting bool
if errDecode := value.Content[index+1].Decode(&setting); errDecode != nil {
return fmt.Errorf("decode codex.live-media-relay.allow-private-remote-ips: %w", errDecode)
}
allowPrivate = &setting
case "disable-private-remote-ips":
var setting bool
if errDecode := value.Content[index+1].Decode(&setting); errDecode != nil {
return fmt.Errorf("decode codex.live-media-relay.disable-private-remote-ips: %w", errDecode)
}
disablePrivate = &setting
}
}
}
if allowPrivate != nil && disablePrivate != nil {
return errors.New("codex.live-media-relay cannot set both allow-private-remote-ips and disable-private-remote-ips")
}
if allowPrivate != nil {
decoded.DisablePrivateRemoteIPs = !*allowPrivate
log.Warn("codex.live-media-relay.allow-private-remote-ips is deprecated; use disable-private-remote-ips with the inverse value")
}
*c = CodexLiveMediaRelayConfig(decoded)
return nil
}
// EffectiveMaxSessions returns the configured media session limit.
func (c CodexLiveMediaRelayConfig) EffectiveMaxSessions() int {
if c.MaxSessions > 0 {
return c.MaxSessions
}
return DefaultCodexLiveMediaMaxSessions
}
// Validate verifies the Codex Live media relay configuration.
func (c CodexLiveMediaRelayConfig) Validate() error {
if !c.Enabled {
return nil
}
if c.MaxSessions < 0 {
return errors.New("codex.live-media-relay.max-sessions must not be negative")
}
if publicIP := strings.TrimSpace(c.PublicIP); publicIP != "" && net.ParseIP(publicIP) == nil {
return fmt.Errorf("codex.live-media-relay.public-ip is invalid: %q", publicIP)
}
if (c.UDPPortMin == 0) != (c.UDPPortMax == 0) {
return errors.New("codex.live-media-relay UDP port minimum and maximum must both be set")
}
if c.UDPPortMin > c.UDPPortMax {
return errors.New("codex.live-media-relay.udp-port-min must not exceed udp-port-max")
}
if c.UDPPortMin != 0 {
availablePorts := int(c.UDPPortMax) - int(c.UDPPortMin) + 1
requiredPorts := c.EffectiveMaxSessions() * 2
if availablePorts < requiredPorts {
return fmt.Errorf("codex.live-media-relay UDP range requires at least %d ports for %d sessions", requiredPorts, c.EffectiveMaxSessions())
}
}
for serverIndex, server := range c.ICEServers {
if len(server.URLs) == 0 {
return fmt.Errorf("codex.live-media-relay.ice-servers[%d].urls is required", serverIndex)
}
for _, rawURL := range server.URLs {
parsed, errParse := url.Parse(strings.TrimSpace(rawURL))
if errParse != nil || parsed.Scheme == "" {
return fmt.Errorf("codex.live-media-relay.ice-servers[%d] contains an invalid URL", serverIndex)
}
switch strings.ToLower(parsed.Scheme) {
case "stun", "stuns", "turn", "turns":
default:
return fmt.Errorf("codex.live-media-relay.ice-servers[%d] uses unsupported scheme %q", serverIndex, parsed.Scheme)
}
}
}
return nil
}

View file

@ -0,0 +1,119 @@
package config
import (
"encoding/json"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestCodexLiveMediaRelayConfigParsesAndValidates(t *testing.T) {
var cfg Config
raw := []byte(`codex:
live-media-relay:
enabled: true
max-sessions: 64
disable-private-remote-ips: true
public-ip: "203.0.113.10"
udp-port-min: 40000
udp-port-max: 40150
ice-servers:
- urls: ["stun:stun.example.com:3478"]
- urls: ["turn:turn.example.com:3478?transport=udp"]
username: "relay-user"
credential: "relay-secret"
`)
if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil {
t.Fatalf("unmarshal Codex Live media relay config: %v", errUnmarshal)
}
relay := cfg.Codex.LiveMediaRelay
if !relay.Enabled || relay.MaxSessions != 64 || !relay.DisablePrivateRemoteIPs || relay.PublicIP != "203.0.113.10" {
t.Fatalf("parsed media relay = %#v", relay)
}
if relay.UDPPortMin != 40000 || relay.UDPPortMax != 40150 {
t.Fatalf("parsed UDP range = %d-%d", relay.UDPPortMin, relay.UDPPortMax)
}
if len(relay.ICEServers) != 2 || relay.ICEServers[1].Credential != "relay-secret" {
t.Fatalf("parsed ICE servers = %#v", relay.ICEServers)
}
if errValidate := relay.Validate(); errValidate != nil {
t.Fatalf("Validate() error = %v", errValidate)
}
encoded, errMarshal := json.Marshal(relay)
if errMarshal != nil {
t.Fatalf("marshal media relay config: %v", errMarshal)
}
for _, sensitive := range []string{"relay-secret", "credential", "relay-user", "username"} {
if strings.Contains(string(encoded), sensitive) {
t.Fatalf("JSON media relay config leaked TURN field %q: %s", sensitive, encoded)
}
}
}
func TestCodexLiveMediaRelayConfigMigratesLegacyPrivateIPSetting(t *testing.T) {
for name, raw := range map[string]string{
"legacy allow true": "allow-private-remote-ips: true\n",
"legacy allow false": "allow-private-remote-ips: false\n",
"new default": "enabled: true\n",
} {
t.Run(name, func(t *testing.T) {
var relay CodexLiveMediaRelayConfig
if errUnmarshal := yaml.Unmarshal([]byte(raw), &relay); errUnmarshal != nil {
t.Fatalf("unmarshal media relay config: %v", errUnmarshal)
}
wantDisabled := name == "legacy allow false"
if relay.DisablePrivateRemoteIPs != wantDisabled {
t.Fatalf("disable-private-remote-ips = %t, want %t", relay.DisablePrivateRemoteIPs, wantDisabled)
}
})
}
var relay CodexLiveMediaRelayConfig
errUnmarshal := yaml.Unmarshal([]byte("allow-private-remote-ips: true\ndisable-private-remote-ips: false\n"), &relay)
if errUnmarshal == nil {
t.Fatal("accepted conflicting private IP settings")
}
}
func TestCodexLiveMediaRelayConfigRejectsInvalidValues(t *testing.T) {
for name, relay := range map[string]CodexLiveMediaRelayConfig{
"negative session limit": {
Enabled: true,
MaxSessions: -1,
},
"invalid public IP": {
Enabled: true,
PublicIP: "not-an-ip",
},
"partial UDP range": {
Enabled: true,
UDPPortMin: 40000,
},
"reversed UDP range": {
Enabled: true,
UDPPortMin: 40100,
UDPPortMax: 40000,
},
"undersized UDP range": {
Enabled: true,
MaxSessions: 2,
UDPPortMin: 40000,
UDPPortMax: 40002,
},
"missing ICE URLs": {
Enabled: true,
ICEServers: []CodexLiveICEServer{{Username: "user"}},
},
"unsupported ICE URL": {
Enabled: true,
ICEServers: []CodexLiveICEServer{{URLs: []string{"https://example.com"}}},
},
} {
t.Run(name, func(t *testing.T) {
if errValidate := relay.Validate(); errValidate == nil {
t.Fatal("Validate() accepted invalid media relay config")
}
})
}
}

View file

@ -0,0 +1,64 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
configYAML := []byte(`
codex-header-defaults:
user-agent: " my-codex-client/1.0 "
beta-features: " feature-a,feature-b "
`)
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := LoadConfigOptional(configPath, false)
if err != nil {
t.Fatalf("LoadConfigOptional() error = %v", err)
}
if got := cfg.CodexHeaderDefaults.UserAgent; got != "my-codex-client/1.0" {
t.Fatalf("UserAgent = %q, want %q", got, "my-codex-client/1.0")
}
if got := cfg.CodexHeaderDefaults.BetaFeatures; got != "feature-a,feature-b" {
t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b")
}
if cfg.Codex.DisableCodexCloaking {
t.Fatal("DisableCodexCloaking = true, want default false")
}
}
func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
configYAML := []byte(`
codex:
identity-confuse: true
disable-codex-cloaking: true
optimize-multi-agent-v2: true
`)
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := LoadConfigOptional(configPath, false)
if err != nil {
t.Fatalf("LoadConfigOptional() error = %v", err)
}
if !cfg.Codex.IdentityConfuse {
t.Fatalf("IdentityConfuse = false, want true")
}
if !cfg.Codex.DisableCodexCloaking {
t.Fatal("DisableCodexCloaking = false, want true")
}
if !cfg.Codex.OptimizeMultiAgentV2 {
t.Fatalf("OptimizeMultiAgentV2 = false, want true")
}
}

View file

@ -0,0 +1,174 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
package config
// Config represents the application's configuration, loaded from a YAML file.
type Config struct {
SDKConfig `yaml:",inline"`
// Host is the network host/interface on which the API server will bind.
// Default is empty ("") to bind all interfaces (IPv4 + IPv6). Use "127.0.0.1" or "localhost" for local-only access.
Host string `yaml:"host" json:"-"`
// Port is the network port on which the API server will listen.
Port int `yaml:"port" json:"-"`
// TLS config controls HTTPS server settings.
TLS TLSConfig `yaml:"tls" json:"tls"`
// Home config is runtime-only and is populated from -home-jwt.
Home HomeConfig `yaml:"-" json:"-"`
// CredentialConcurrency contains Home-authoritative credential lifecycle settings.
CredentialConcurrency CredentialConcurrencyConfig `yaml:"credential-concurrency" json:"credential-concurrency"`
// CredentialInFlight configures credential observation snapshots.
CredentialInFlight CredentialInFlightConfig `yaml:"credential-in-flight" json:"credential-in-flight"`
// RemoteManagement nests management-related options under 'remote-management'.
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
// Plugins configures dynamic plugin discovery and per-plugin settings.
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
// AuthDir is the directory where authentication token files are stored.
AuthDir string `yaml:"auth-dir" json:"-"`
// Debug enables or disables debug-level logging and other debug features.
Debug bool `yaml:"debug" json:"debug"`
// Pprof config controls the optional pprof HTTP debug server.
Pprof PprofConfig `yaml:"pprof" json:"pprof"`
// CommercialMode disables high-overhead request logging and HTTP middleware features to minimize per-request memory usage.
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
// LoggingToFile controls whether application logs are written to rotating files or stdout.
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
// LogsMaxTotalSizeMB limits the total size (in MB) of log files under the logs directory.
// When exceeded, the oldest log files are deleted until within the limit. Set to 0 to disable.
LogsMaxTotalSizeMB int `yaml:"logs-max-total-size-mb" json:"logs-max-total-size-mb"`
// ErrorLogsMaxFiles limits the number of error log files retained when request logging is disabled.
// When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup.
ErrorLogsMaxFiles int `yaml:"error-logs-max-files" json:"error-logs-max-files"`
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
// RedisUsageQueueRetentionSeconds controls how long usage queue items are retained
// in memory for Management API consumers.
// Default: 60. Max: 3600.
RedisUsageQueueRetentionSeconds int `yaml:"redis-usage-queue-retention-seconds" json:"redis-usage-queue-retention-seconds"`
// DisableCooling disables auth/model cooldown scheduling when true unless a credential or provider overrides it.
DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"`
// SaveCooldownStatus persists runtime cooldown status next to auth files when true.
SaveCooldownStatus bool `yaml:"save-cooldown-status" json:"save-cooldown-status"`
// TransientErrorCooldownSeconds controls cooldowns for transient upstream errors.
// 0 keeps the legacy default cooldown. Negative values disable these cooldowns.
TransientErrorCooldownSeconds int `yaml:"transient-error-cooldown-seconds" json:"transient-error-cooldown-seconds"`
// AuthAutoRefreshWorkers overrides the size of the core auth auto-refresh worker pool.
// When <= 0, the default worker count is used.
AuthAutoRefreshWorkers int `yaml:"auth-auto-refresh-workers" json:"auth-auto-refresh-workers"`
// RequestRetry defines the number of additional credential retry rounds after
// the first round has exhausted its eligible credentials.
RequestRetry int `yaml:"request-retry" json:"request-retry"`
// MaxRetryCredentials defines the maximum number of different credentials to
// try in each credential retry round.
// Set to 0 or a negative value to keep trying all available credentials (legacy behavior).
MaxRetryCredentials int `yaml:"max-retry-credentials" json:"max-retry-credentials"`
// MaxRetryInterval defines the maximum positive cooldown wait, in seconds,
// allowed before starting another credential retry round. A non-positive value
// forbids positive cooldown waits; it does not disable same-round credential
// failover or immediate additional rounds allowed by RequestRetry.
MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"`
// QuotaExceeded defines the behavior when a quota is exceeded.
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
// Routing controls credential selection behavior.
Routing RoutingConfig `yaml:"routing" json:"routing"`
// WebsocketAuth enables or disables authentication for the WebSocket API.
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
// AntigravitySignatureCacheEnabled controls whether signature cache validation is enabled for thinking blocks.
// When true (default), cached signatures are preferred and validated.
// When false, client signatures are used directly after normalization (bypass mode).
AntigravitySignatureCacheEnabled *bool `yaml:"antigravity-signature-cache-enabled,omitempty" json:"antigravity-signature-cache-enabled,omitempty"`
AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"`
// Antigravity configures provider-wide Antigravity request behavior.
Antigravity AntigravityConfig `yaml:"antigravity" json:"antigravity"`
// GeminiKey defines Gemini API key configurations with optional routing overrides.
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
// InteractionsKey defines native Google Interactions API key configurations.
InteractionsKey []GeminiKey `yaml:"interactions-api-key" json:"interactions-api-key"`
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
// XAIKey defines xAI API key configurations using the same structure as Codex API keys.
XAIKey []XAIKey `yaml:"xai-api-key" json:"xai-api-key"`
// XAI configures provider-wide xAI request behavior.
XAI XAIConfig `yaml:"xai" json:"xai"`
// Codex configures provider-wide Codex request behavior.
Codex CodexConfig `yaml:"codex" json:"codex"`
// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.
// These are used only when the client does not send its own headers.
CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"`
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
// ClaudeHeaderDefaults configures default header values for Claude API requests.
// These are used as fallbacks when the client does not send its own headers.
ClaudeHeaderDefaults ClaudeHeaderDefaults `yaml:"claude-header-defaults" json:"claude-header-defaults"`
// DisableClaudeCloakMode globally disables Claude request cloaking when true.
// Cloaking disguises requests as the official Claude Code CLI and replaces the
// system prompt. When true, every Claude credential defaults to no cloaking
// ("never"); a specific credential can still re-enable or override it via its own
// cloak settings (the per claude-api-key "cloak" block, or a "cloak_mode" value in
// the auth/OAuth token file). Default false preserves the per-client "auto" behavior.
DisableClaudeCloakMode bool `yaml:"disable-claude-cloak-mode" json:"disable-claude-cloak-mode"`
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
// VertexCompatAPIKey defines Vertex AI-compatible API key configurations for third-party providers.
// Used for services that use Vertex AI-style paths but with simple API key authentication.
VertexCompatAPIKey []VertexCompatKey `yaml:"vertex-api-key" json:"vertex-api-key"`
// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
// These aliases affect both model listing and model routing for supported channels:
// vertex, aistudio, antigravity, claude, codex, kimi, xai.
//
// NOTE: This does not apply to existing per-credential model alias features under:
// gemini-api-key, interactions-api-key, codex-api-key, xai-api-key, claude-api-key, openai-compatibility, and vertex-api-key.
OAuthModelAlias map[string][]OAuthModelAlias `yaml:"oauth-model-alias,omitempty" json:"oauth-model-alias,omitempty"`
// OAuthRequestScopedErrors defines per-provider request-scoped error rules applied to OAuth/file-backed auth entries.
// Supported channels include: vertex, aistudio, antigravity, claude, codex, kimi, xai, and OAuth plugin provider keys.
//
// NOTE: This applies only to OAuth credentials and does not affect per-credential request-scoped-errors under *-api-key.
OAuthRequestScopedErrors map[string][]RequestScopedErrorRule `yaml:"oauth-request-scoped-errors,omitempty" json:"oauth-request-scoped-errors,omitempty"`
// Payload defines default and override rules for provider payload parameters.
Payload PayloadConfig `yaml:"payload" json:"payload"`
}

View file

@ -0,0 +1,6 @@
package config
const (
DefaultPprofAddr = "127.0.0.1:8316"
DefaultAuthDir = "~/.cli-proxy-api"
)

View file

@ -0,0 +1,185 @@
package config
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// LoadConfig reads a YAML configuration file from the given path,
// unmarshals it into a Config struct, applies environment variable overrides,
// and returns it.
//
// Parameters:
// - configFile: The path to the YAML configuration file
//
// Returns:
// - *Config: The loaded configuration
// - error: An error if the configuration could not be loaded
func LoadConfig(configFile string) (*Config, error) {
return LoadConfigOptional(configFile, false)
}
// LoadConfigOptional reads YAML from configFile.
// If optional is true and the file is missing, it returns an empty Config.
// If optional is true and the file is empty or invalid, it returns an empty Config.
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Read the entire configuration file into memory.
data, err := os.ReadFile(configFile)
if err != nil {
if optional {
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
// Missing and optional: return empty config (cloud deploy standby).
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
return cfg, nil
}
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
if optional && len(bytes.TrimSpace(data)) == 0 {
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
return cfg, nil
}
if errValidate := validateCredentialWeightYAML(data); errValidate != nil {
if optional {
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
return cfgOptional, nil
}
return nil, errValidate
}
// Unmarshal the YAML data into the Config struct.
var cfg Config
// Set defaults before unmarshal so that absent keys keep defaults.
cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
cfg.LoggingToFile = false
cfg.LogsMaxTotalSizeMB = 0
cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false
cfg.RedisUsageQueueRetentionSeconds = 60
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.CredentialInFlight = DefaultCredentialInFlightConfig()
if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
return cfgOptional, nil
}
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
cfg.CredentialConcurrency = cfg.CredentialConcurrency.WithDefaults()
if errValidate := cfg.CredentialInFlight.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.Codex.LiveMediaRelay.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateCredentialWeights(); errValidate != nil {
return nil, errValidate
}
// Hash remote management key if plaintext is detected (nested)
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
hashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)
if errHash != nil {
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
}
cfg.RemoteManagement.SecretKey = hashed
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
// Preserve YAML comments and ordering; update only the nested key.
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed)
}
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
if cfg.Pprof.Addr == "" {
cfg.Pprof.Addr = DefaultPprofAddr
}
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
if cfg.ErrorLogsMaxFiles < 0 {
cfg.ErrorLogsMaxFiles = 10
}
if cfg.RedisUsageQueueRetentionSeconds <= 0 {
cfg.RedisUsageQueueRetentionSeconds = 60
} else if cfg.RedisUsageQueueRetentionSeconds > 3600 {
log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600")
cfg.RedisUsageQueueRetentionSeconds = 3600
}
if cfg.MaxRetryCredentials < 0 {
cfg.MaxRetryCredentials = 0
}
cfg.NormalizePluginsConfig()
if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled {
return nil, errResolvePluginsDir
}
// Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys()
// Sanitize native Interactions API key configuration.
cfg.SanitizeInteractionsKeys()
// Sanitize Vertex-compatible API keys.
cfg.SanitizeVertexCompatKeys()
// Sanitize Codex keys: drop entries without base-url
cfg.SanitizeCodexKeys()
// Sanitize xAI keys: drop entries without base-url
cfg.SanitizeXAIKeys()
// Sanitize Codex header defaults.
cfg.SanitizeCodexHeaderDefaults()
// Sanitize Claude header defaults.
cfg.SanitizeClaudeHeaderDefaults()
// Sanitize Claude key headers
cfg.SanitizeClaudeKeys()
// Sanitize OpenAI compatibility providers: drop entries without base-url
cfg.SanitizeOpenAICompatibility()
// Normalize OAuth provider model exclusion map.
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
// Normalize global OAuth model name aliases.
cfg.SanitizeOAuthModelAlias()
// Normalize global OAuth request-scoped error rules.
cfg.SanitizeOAuthRequestScopedErrors()
// Validate raw payload rules and drop invalid entries.
cfg.SanitizePayloadRules()
// Return the populated configuration struct.
return &cfg, nil
}

View file

@ -0,0 +1,392 @@
package config
import (
"sort"
"strings"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
)
// NormalizePluginsConfig applies default plugin configuration values.
func (cfg *Config) NormalizePluginsConfig() {
if cfg == nil {
return
}
cfg.Plugins.Dir = strings.TrimSpace(cfg.Plugins.Dir)
if cfg.Plugins.Dir == "" {
cfg.Plugins.Dir = defaultPluginsDir
}
if len(cfg.Plugins.StoreSources) > 0 {
sources := make([]string, 0, len(cfg.Plugins.StoreSources))
for _, source := range cfg.Plugins.StoreSources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
sources = append(sources, source)
}
cfg.Plugins.StoreSources = sources
}
cfg.Plugins.StoreAuth = sdkpluginstore.NormalizeAuthConfigs(cfg.Plugins.StoreAuth)
if cfg.Plugins.Configs == nil {
cfg.Plugins.Configs = map[string]PluginInstanceConfig{}
}
}
// SanitizeCodexHeaderDefaults trims surrounding whitespace from the
// configured Codex header fallback values.
func (cfg *Config) SanitizeCodexHeaderDefaults() {
if cfg == nil {
return
}
cfg.CodexHeaderDefaults.UserAgent = strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent)
cfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures)
}
// SanitizeClaudeHeaderDefaults trims surrounding whitespace from the
// configured Claude fingerprint baseline values.
func (cfg *Config) SanitizeClaudeHeaderDefaults() {
if cfg == nil {
return
}
cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent)
cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion)
cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion)
cfg.ClaudeHeaderDefaults.OS = strings.TrimSpace(cfg.ClaudeHeaderDefaults.OS)
cfg.ClaudeHeaderDefaults.Arch = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Arch)
cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout)
cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone)
}
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
func (cfg *Config) SanitizeOAuthModelAlias() {
if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
return
}
out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))
for rawChannel, aliases := range cfg.OAuthModelAlias {
channel := strings.ToLower(strings.TrimSpace(rawChannel))
if channel == "" || len(aliases) == 0 {
continue
}
seenAlias := make(map[string]struct{}, len(aliases))
clean := make([]OAuthModelAlias, 0, len(aliases))
for _, entry := range aliases {
name := strings.TrimSpace(entry.Name)
alias := strings.TrimSpace(entry.Alias)
if name == "" || alias == "" {
continue
}
if strings.EqualFold(name, alias) {
continue
}
aliasKey := strings.ToLower(alias)
if _, ok := seenAlias[aliasKey]; ok {
continue
}
seenAlias[aliasKey] = struct{}{}
clean = append(clean, OAuthModelAlias{
Name: name,
Alias: alias,
Fork: entry.Fork,
DisplayName: strings.TrimSpace(entry.DisplayName),
ForceMapping: entry.ForceMapping,
})
}
if len(clean) > 0 {
out[channel] = clean
}
}
cfg.OAuthModelAlias = out
}
// SanitizeOAuthRequestScopedErrors normalizes and validates global OAuth request-scoped error rules.
// It trims whitespace, normalizes channel keys to lower-case, validates status/action, and drops invalid rules.
func (cfg *Config) SanitizeOAuthRequestScopedErrors() {
if cfg == nil || len(cfg.OAuthRequestScopedErrors) == 0 {
return
}
out := make(map[string][]RequestScopedErrorRule, len(cfg.OAuthRequestScopedErrors))
for rawChannel, rules := range cfg.OAuthRequestScopedErrors {
channel := strings.ToLower(strings.TrimSpace(rawChannel))
if channel == "" || len(rules) == 0 {
continue
}
clean := make([]RequestScopedErrorRule, 0, len(rules))
for _, r := range rules {
action := strings.ToLower(strings.TrimSpace(r.Action))
match := make([]string, 0, len(r.Match))
for _, m := range r.Match {
if tm := strings.TrimSpace(m); tm != "" {
match = append(match, tm)
}
}
matchRegexr := make([]string, 0, len(r.MatchRegexr))
for _, re := range r.MatchRegexr {
if tre := strings.TrimSpace(re); tre != "" {
matchRegexr = append(matchRegexr, tre)
}
}
if r.Status <= 0 || (len(match) == 0 && len(matchRegexr) == 0) || action == "" {
continue
}
clean = append(clean, RequestScopedErrorRule{
Status: r.Status,
Match: match,
MatchRegexr: matchRegexr,
Action: action,
})
}
if len(clean) > 0 {
out[channel] = clean
}
}
if len(out) == 0 {
cfg.OAuthRequestScopedErrors = nil
return
}
cfg.OAuthRequestScopedErrors = out
}
// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
// not actionable, specifically those missing a BaseURL. It trims whitespace before
// evaluation and preserves the relative order of remaining entries.
func (cfg *Config) SanitizeOpenAICompatibility() {
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
return
}
out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
for i := range cfg.OpenAICompatibility {
e := cfg.OpenAICompatibility[i]
e.Name = strings.TrimSpace(e.Name)
e.Prefix = normalizeModelPrefix(e.Prefix)
e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
if e.BaseURL == "" {
// Skip providers with no base-url; treated as removed
continue
}
out = append(out, e)
}
cfg.OpenAICompatibility = out
}
// SanitizeCodexKeys removes Codex API key entries missing a BaseURL.
// It trims whitespace and preserves order for remaining entries.
func (cfg *Config) SanitizeCodexKeys() {
if cfg == nil {
return
}
cfg.CodexKey = sanitizeCodexKeyEntries(cfg.CodexKey)
}
// SanitizeXAIKeys removes xAI API key entries missing a BaseURL.
// It applies the same normalization rules as codex-api-key.
func (cfg *Config) SanitizeXAIKeys() {
if cfg == nil {
return
}
cfg.XAIKey = sanitizeCodexKeyEntries(cfg.XAIKey)
for i := range cfg.XAIKey {
cfg.XAIKey[i].AlphaSearch = false
}
}
func sanitizeCodexKeyEntries(entries []CodexKey) []CodexKey {
if len(entries) == 0 {
return entries
}
out := make([]CodexKey, 0, len(entries))
for i := range entries {
e := entries[i]
e.Prefix = normalizeModelPrefix(e.Prefix)
e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
if e.BaseURL == "" {
continue
}
out = append(out, e)
}
return out
}
// SanitizeClaudeKeys normalizes headers for Claude credentials.
func (cfg *Config) SanitizeClaudeKeys() {
if cfg == nil || len(cfg.ClaudeKey) == 0 {
return
}
for i := range cfg.ClaudeKey {
entry := &cfg.ClaudeKey[i]
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
// Only a recognized value is rewritten. An unrecognized one is preserved as
// written so sanitizing a config file never destroys operator input; the
// request path falls back to the default profile and reports it once.
if normalized, ok := NormalizeClaudeFingerprintProfile(entry.FingerprintProfile); ok {
entry.FingerprintProfile = normalized
} else {
entry.FingerprintProfile = strings.TrimSpace(entry.FingerprintProfile)
}
}
}
func sanitizeGeminiKeyEntries(entries []GeminiKey) []GeminiKey {
seen := make(map[string]struct{}, len(entries))
out := entries[:0]
for i := range entries {
entry := entries[i]
entry.APIKey = strings.TrimSpace(entry.APIKey)
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
if entry.APIKey == "" && entry.BaseURL == "" {
continue
}
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
uniqueKey := formatGeminiKeyDedupID(entry)
if _, exists := seen[uniqueKey]; exists {
continue
}
seen[uniqueKey] = struct{}{}
out = append(out, entry)
}
return out
}
func formatGeminiKeyDedupID(entry GeminiKey) string {
var b strings.Builder
b.WriteString(entry.APIKey)
b.WriteByte(0)
b.WriteString(entry.BaseURL)
b.WriteByte(0)
b.WriteString(entry.ProxyURL)
b.WriteByte(0)
b.WriteString(entry.Prefix)
b.WriteByte(0)
b.WriteString(FormatSortedHeaders(entry.Headers))
return b.String()
}
// FormatSortedHeaders serializes headers deterministically with null byte separators.
func FormatSortedHeaders(headers map[string]string) string {
if len(headers) == 0 {
return ""
}
keys := make([]string, 0, len(headers))
for k := range headers {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte(0)
b.WriteString(headers[k])
b.WriteByte(0)
}
return b.String()
}
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
// It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key.
func (cfg *Config) SanitizeGeminiKeys() {
if cfg == nil {
return
}
cfg.GeminiKey = sanitizeGeminiKeyEntries(cfg.GeminiKey)
}
// SanitizeInteractionsKeys deduplicates and normalizes native Interactions credentials.
// It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key.
func (cfg *Config) SanitizeInteractionsKeys() {
if cfg == nil {
return
}
cfg.InteractionsKey = sanitizeGeminiKeyEntries(cfg.InteractionsKey)
}
func normalizeModelPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
trimmed = strings.Trim(trimmed, "/")
if trimmed == "" {
return ""
}
if strings.Contains(trimmed, "/") {
return ""
}
return trimmed
}
// NormalizeHeaders trims header keys and values and removes empty pairs.
func NormalizeHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
clean := make(map[string]string, len(headers))
for k, v := range headers {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
clean[key] = val
}
if len(clean) == 0 {
return nil
}
return clean
}
// NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns.
// It preserves the order of first occurrences and drops empty entries.
func NormalizeExcludedModels(models []string) []string {
if len(models) == 0 {
return nil
}
seen := make(map[string]struct{}, len(models))
out := make([]string, 0, len(models))
for _, raw := range models {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}
// NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys
// and applying model exclusion normalization to each entry.
func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string {
if len(entries) == 0 {
return nil
}
out := make(map[string][]string, len(entries))
for provider, models := range entries {
key := strings.ToLower(strings.TrimSpace(provider))
if key == "" {
continue
}
normalized := NormalizeExcludedModels(models)
if len(normalized) == 0 {
continue
}
out[key] = normalized
}
if len(out) == 0 {
return nil
}
return out
}

View file

@ -0,0 +1,743 @@
package config
import (
"fmt"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
"gopkg.in/yaml.v3"
)
// RequestScopedErrorRule configures custom classification and handling for upstream errors.
type RequestScopedErrorRule struct {
// Status matches the HTTP status code of the upstream response (e.g. 400).
Status int `yaml:"status,omitempty" json:"status,omitempty"`
// Match matches substrings in the upstream error body.
Match []string `yaml:"match,omitempty" json:"match,omitempty"`
// MatchRegexr matches regular expressions in the upstream error body.
MatchRegexr []string `yaml:"match-regexr,omitempty" json:"match-regexr,omitempty"`
// Action specifies the handling behavior: "stop", "stop-and-cooldown", "continue", "continue-and-cooldown".
Action string `yaml:"action,omitempty" json:"action,omitempty"`
}
// PluginsConfig holds dynamic plugin system settings.
type PluginsConfig struct {
// Enabled toggles dynamic plugin loading.
Enabled bool `yaml:"enabled" json:"enabled"`
// Dir is the plugin discovery directory.
Dir string `yaml:"dir" json:"dir"`
// StoreSources appends third-party plugin store registries to the built-in official source.
StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"`
// StoreAuth defines optional auth rules for plugin store registry, metadata, and artifact requests.
StoreAuth []sdkpluginstore.AuthConfig `yaml:"store-auth,omitempty" json:"store-auth,omitempty"`
// AuthRevision changes when Home-managed plugin credentials change.
AuthRevision int64 `yaml:"auth-revision,omitempty" json:"auth-revision,omitempty"`
// Configs stores per-plugin instance configuration by plugin ID.
Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"`
}
// PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree.
type PluginInstanceConfig struct {
// Enabled toggles this plugin instance. Nil is normalized to false during YAML parsing.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Priority controls plugin startup and routing order.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Raw preserves the full original plugin configuration YAML subtree.
Raw yaml.Node `yaml:"-" json:"-"`
}
// UnmarshalYAML extracts host-owned fields while preserving the full original YAML node.
func (c *PluginInstanceConfig) UnmarshalYAML(value *yaml.Node) error {
if c == nil {
return nil
}
c.Priority = 0
defaultEnabled := false
c.Enabled = &defaultEnabled
if value == nil || value.Kind == 0 {
c.Raw = *defaultPluginInstanceConfigNode()
return nil
}
c.Raw = *deepCopyNode(value)
if value.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(value.Content); i += 2 {
key := value.Content[i]
node := value.Content[i+1]
if key == nil {
continue
}
switch key.Value {
case "enabled":
var enabled bool
if errDecodeEnabled := node.Decode(&enabled); errDecodeEnabled != nil {
return fmt.Errorf("parse plugin enabled: %w", errDecodeEnabled)
}
c.Enabled = &enabled
case "priority":
var priority int
if errDecodePriority := node.Decode(&priority); errDecodePriority != nil {
return fmt.Errorf("parse plugin priority: %w", errDecodePriority)
}
c.Priority = priority
}
}
return nil
}
// MarshalYAML returns the preserved raw plugin YAML subtree for lossless config output.
func (c PluginInstanceConfig) MarshalYAML() (any, error) {
if c.Raw.Kind == 0 {
return defaultPluginInstanceConfigNode(), nil
}
return deepCopyNode(&c.Raw), nil
}
func defaultPluginInstanceConfigNode() *yaml.Node {
return &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{},
}
}
// ClaudeHeaderDefaults configures the measured Claude Code software baseline.
// Verified native requests preserve their entrypoint and software shape only when their
// Claude Code, package, and runtime versions exactly match this baseline; unmeasured
// versions use the configured values. Timeout remains a fallback. Stabilized profiles
// also pin OS and Arch and never learn newer software versions automatically.
type ClaudeHeaderDefaults struct {
UserAgent string `yaml:"user-agent" json:"user-agent"`
PackageVersion string `yaml:"package-version" json:"package-version"`
RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
OS string `yaml:"os" json:"os"`
Arch string `yaml:"arch" json:"arch"`
Timeout string `yaml:"timeout" json:"timeout"`
Timezone string `yaml:"timezone" json:"timezone"`
StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"`
}
// CodexHeaderDefaults configures fallback header values injected into Codex
// model requests for OAuth/file-backed auth when the client omits them.
// UserAgent applies to HTTP and websocket requests; BetaFeatures only applies to websockets.
type CodexHeaderDefaults struct {
UserAgent string `yaml:"user-agent" json:"user-agent"`
BetaFeatures string `yaml:"beta-features" json:"beta-features"`
}
// XAIConfig configures provider-wide xAI request behavior.
type XAIConfig struct {
// InjectXSearch injects xAI's native x_search tool when the request does not declare it.
InjectXSearch bool `yaml:"inject-x-search" json:"inject-x-search"`
}
// AntigravityConfig configures provider-wide Antigravity request behavior.
type AntigravityConfig struct {
// SensitiveWords is a list of words to obfuscate with zero-width characters in system instructions.
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
}
// CodexConfig configures provider-wide Codex request behavior.
type CodexConfig struct {
IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"`
// DisableCodexCloaking disables forcing the official Codex identity headers on HTTP/SSE and WebSocket requests.
DisableCodexCloaking bool `yaml:"disable-codex-cloaking" json:"disable-codex-cloaking"`
// StreamBootstrapBuffering holds back initial handshake events (response.created,
// response.in_progress and the websocket metadata frames) until the first generated event
// arrives. The upstream delivers server_is_overloaded rejections inside an HTTP 200 stream
// right after those handshake events instead of returning 503 on the wire, so buffering them
// keeps the downstream response headers uncommitted long enough to retry on another credential.
// Trade-off: the response headers are delayed until the upstream starts generating, which can
// trip client or reverse-proxy read timeouts. Default is false.
StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"`
// OptimizeMultiAgentV2 optimizes official Codex multi-agent requests.
OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"`
// LiveMediaRelay terminates and relays Codex Live WebRTC media in this process.
LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"`
}
// CodexLiveMediaRelayConfig configures the in-process Codex Live WebRTC gateway.
type CodexLiveMediaRelayConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
MaxSessions int `yaml:"max-sessions" json:"max-sessions"`
DisablePrivateRemoteIPs bool `yaml:"disable-private-remote-ips" json:"disable-private-remote-ips"`
PublicIP string `yaml:"public-ip" json:"public-ip"`
UDPPortMin uint16 `yaml:"udp-port-min" json:"udp-port-min"`
UDPPortMax uint16 `yaml:"udp-port-max" json:"udp-port-max"`
ICEServers []CodexLiveICEServer `yaml:"ice-servers" json:"ice-servers"`
}
// CodexLiveICEServer configures a STUN or TURN server for the media relay.
type CodexLiveICEServer struct {
URLs []string `yaml:"urls" json:"urls"`
Username string `yaml:"username" json:"-"`
Credential string `yaml:"credential" json:"-"`
}
// TLSConfig holds HTTPS server settings.
type TLSConfig struct {
// Enable toggles HTTPS server mode.
Enable bool `yaml:"enable" json:"enable"`
// Cert is the path to the TLS certificate file.
Cert string `yaml:"cert" json:"cert"`
// Key is the path to the TLS private key file.
Key string `yaml:"key" json:"key"`
}
// PprofConfig holds pprof HTTP server settings.
type PprofConfig struct {
// Enable toggles the pprof HTTP debug server.
Enable bool `yaml:"enable" json:"enable"`
// Addr is the host:port address for the pprof HTTP server.
Addr string `yaml:"addr" json:"addr"`
}
// RemoteManagement holds management API configuration under 'remote-management'.
type RemoteManagement struct {
// AllowRemote toggles remote (non-localhost) access to management API.
AllowRemote bool `yaml:"allow-remote"`
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
SecretKey string `yaml:"secret-key"`
// DisableControlPanel skips serving the management UI when true.
DisableControlPanel bool `yaml:"disable-control-panel"`
}
// QuotaExceeded defines the behavior when API quota limits are exceeded.
// It provides configuration options for automatic failover mechanisms.
type QuotaExceeded struct {
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
// AntigravityCredits enables credits-based last-resort fallback for Claude models.
// When all free-tier auths are exhausted (429/503), the conductor retries with
// an auth that has available Google One AI credits.
AntigravityCredits bool `yaml:"antigravity-credits" json:"antigravity-credits"`
}
// RoutingConfig configures how credentials are selected for requests.
type RoutingConfig struct {
// Strategy selects the credential selection strategy.
// Supported values: "round-robin" (default), "weighted-round-robin", "fill-first".
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
// SessionAffinity enables universal session-sticky routing for all clients.
// Explicit Claude Code, Codex, OpenCode, and pi session headers are preferred,
// followed by prompt_cache_key, Responses conversation IDs, legacy body IDs,
// execution or derived session identity, and the existing message-content hash fallback.
// Automatic failover is always enabled when bound auth becomes unavailable.
SessionAffinity bool `yaml:"session-affinity,omitempty" json:"session-affinity,omitempty"`
// SessionAffinityTTL specifies how long session-to-auth bindings are retained.
// Default: 1h. Accepts duration strings like "30m", "1h", "2h30m".
SessionAffinityTTL string `yaml:"session-affinity-ttl,omitempty" json:"session-affinity-ttl,omitempty"`
}
// OAuthModelAlias defines a model ID alias for a specific channel.
// It maps the upstream model name (Name) to the client-visible alias (Alias).
// When Fork is true, the alias is added as an additional model in listings while
// keeping the original model ID available.
type OAuthModelAlias struct {
Name string `yaml:"name" json:"name"`
Alias string `yaml:"alias" json:"alias"`
Fork bool `yaml:"fork,omitempty" json:"fork,omitempty"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
}
// PayloadConfig defines default and override parameter rules applied to provider payloads.
type PayloadConfig struct {
// Default defines rules that only set parameters when they are missing in the payload.
Default []PayloadRule `yaml:"default" json:"default"`
// DefaultRaw defines rules that set raw JSON values only when they are missing.
DefaultRaw []PayloadRule `yaml:"default-raw" json:"default-raw"`
// Override defines rules that always set parameters, overwriting any existing values.
Override []PayloadRule `yaml:"override" json:"override"`
// OverrideRaw defines rules that always set raw JSON values, overwriting any existing values.
OverrideRaw []PayloadRule `yaml:"override-raw" json:"override-raw"`
// Filter defines rules that remove parameters from the payload by JSON path.
Filter []PayloadFilterRule `yaml:"filter" json:"filter"`
}
// PayloadFilterRule describes a rule to remove specific JSON paths from matching model payloads.
type PayloadFilterRule struct {
// Models lists model entries with name pattern and protocol constraint.
Models []PayloadModelRule `yaml:"models" json:"models"`
// Params lists JSON paths (gjson/sjson syntax) to remove from the payload.
Params []string `yaml:"params" json:"params"`
}
// PayloadRule describes a single rule targeting a list of models with parameter updates.
type PayloadRule struct {
// Models lists model entries with name pattern and protocol constraint.
Models []PayloadModelRule `yaml:"models" json:"models"`
// Params maps JSON paths (gjson/sjson syntax) to values written into the payload.
// For *-raw rules, values are treated as raw JSON fragments (strings are used as-is).
Params map[string]any `yaml:"params" json:"params"`
}
// PayloadModelRule ties a model name pattern to a specific translator protocol.
type PayloadModelRule struct {
// Name is the model name or wildcard pattern (e.g., "gpt-*", "*-5", "gemini-*-pro").
Name string `yaml:"name" json:"name"`
// Protocol restricts the rule to a specific translator format (e.g., "gemini", "responses").
Protocol string `yaml:"protocol" json:"protocol"`
// Headers restricts the rule to requests whose headers match all configured wildcard patterns.
Headers map[string]string `yaml:"headers" json:"headers"`
// FromProtocol restricts the rule to a specific source protocol (e.g., "gemini", "responses").
FromProtocol string `yaml:"from-protocol" json:"from-protocol"`
// Match requires payload JSON paths to equal the configured values.
Match []map[string]any `yaml:"match" json:"match"`
// NotMatch requires payload JSON paths to not equal the configured values.
NotMatch []map[string]any `yaml:"not-match" json:"not-match"`
// Exist requires payload JSON paths to exist and not be null.
Exist []string `yaml:"exist" json:"exist"`
// NotExist requires payload JSON paths to be missing or null.
NotExist []string `yaml:"not-exist" json:"not-exist"`
}
// CloakConfig configures request cloaking for non-Claude-Code clients.
// Cloaking disguises API requests to appear as originating from the official Claude Code CLI.
type CloakConfig struct {
// Mode controls cloaking behavior: "auto" (default), "always", or "never".
// Supplying this CloakConfig explicitly enables cloaking for an unprofiled API key.
// - "auto": cloak unless strong request signals identify a verified native entrypoint
// - "always": cloak every unconfirmed client; confirmed native Claude Code remains passthrough
// - "never": never apply cloaking
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// StrictMode controls how caller system prompts are handled when cloaking.
// - false (default): legacy-model whitelist uses a user reminder; all other models use a mid-conversation system message
// - true: strip caller system prompts and keep only the Claude Code billing and identity blocks
StrictMode bool `yaml:"strict-mode,omitempty" json:"strict-mode,omitempty"`
// SensitiveWords is a list of words to obfuscate with zero-width characters.
// This can help bypass certain content filters.
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
// CacheUserID controls whether Claude user_id values are cached per API key.
// When false, a fresh random user_id is generated for every request.
CacheUserID *bool `yaml:"cache-user-id,omitempty" json:"cache-user-id,omitempty"`
}
// ClaudeKey represents the configuration for a Claude API key,
// including the API key itself and an optional base URL for the API endpoint.
type ClaudeKey struct {
// APIKey is the authentication key for accessing Claude API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Priority controls selection preference when multiple credentials match.
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Weight controls proportional selection under weighted-round-robin.
// An omitted value defaults to 1; non-positive values exclude this credential; maximum 1,000,000.
Weight *int `yaml:"weight,omitempty" json:"weight,omitempty"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Claude API endpoint.
// If empty, the default Claude API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
// Models defines upstream model names and aliases for request routing.
Models []ClaudeModel `yaml:"models" json:"models"`
// Headers optionally adds extra HTTP headers for requests sent with this key.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
// RebuildMidSystemMessage moves Claude messages with role "system" into the top-level system field.
RebuildMidSystemMessage bool `yaml:"rebuild-mid-system-message,omitempty" json:"rebuild-mid-system-message,omitempty"`
// DisableCooling overrides the global cooling policy for this credential when set.
// True disables auth/model cooldowns; false explicitly enables them.
DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
// RequestRetry optionally overrides the global request-retry for this credential.
// Nil or a negative value means "use the global request-retry". 0 disables additional retry rounds.
RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"`
// RequestScopedErrors configures custom classification rules for upstream errors.
RequestScopedErrors []RequestScopedErrorRule `yaml:"request-scoped-errors,omitempty" json:"request-scoped-errors,omitempty"`
// Cloak configures request cloaking for non-Claude-Code clients.
Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`
// FingerprintProfile selects the Claude Code request fingerprint for this
// credential on Anthropic Messages. Empty/default keeps the caller request
// fingerprint and headers, including first-party api.anthropic.com API keys.
// "claude-code-cli" opts official Anthropic API keys, custom gateways, and
// delegated providers such as Kimi into the Claude Code OAuth CLI Messages
// shape (OAuth betas, CCH signing, stable CLI identity) without treating the
// credential as a real OAuth token for refresh/profile/runtime semantics.
// CCH is a per-request hash and follows the native gate: it is emitted only on
// api.anthropic.com and Vertex, so an opt-in on any other gateway sends the
// billing block unsigned and cannot bust that gateway's prompt cache. Kimi
// strips the attribution entirely by default and keeps it, unsigned, after an
// explicit opt-in. count_tokens keeps the native model/messages/tools shape.
// Recognized values are defined by NormalizeClaudeFingerprintProfile.
FingerprintProfile string `yaml:"fingerprint-profile,omitempty" json:"fingerprint-profile,omitempty"`
// ExperimentalCCHSigning is retained for configuration compatibility.
// CCH signing is automatic for Claude OAuth and supported direct upstreams.
ExperimentalCCHSigning bool `yaml:"experimental-cch-signing,omitempty" json:"experimental-cch-signing,omitempty"`
}
func (k ClaudeKey) GetAPIKey() string { return k.APIKey }
func (k ClaudeKey) GetBaseURL() string { return k.BaseURL }
func (k ClaudeKey) GetPrefix() string { return k.Prefix }
func (k ClaudeKey) GetProxyURL() string { return k.ProxyURL }
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
type ClaudeModel struct {
// Name is the upstream model identifier used when issuing requests.
Name string `yaml:"name" json:"name"`
// Alias is the client-facing model name that maps to Name.
Alias string `yaml:"alias" json:"alias"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
// MaxContextLength overrides the context window advertised to Codex clients.
MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"`
// ForceMapping rewrites upstream response model fields back to Alias.
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
// IsCompat preserves thinking blocks with empty signatures for compatible upstreams
// and enables provider-aware signed-thinking replay for Claude-compatible API-key models.
// Default false keeps the normal signature validation behavior.
IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"`
// Thinking configures the thinking/reasoning capability for this model.
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
}
func (m ClaudeModel) GetName() string { return m.Name }
func (m ClaudeModel) GetAlias() string { return m.Alias }
func (m ClaudeModel) GetDisplayName() string { return m.DisplayName }
func (m ClaudeModel) GetMaxContextLength() int { return m.MaxContextLength }
func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping }
func (m ClaudeModel) GetIsCompat() bool { return m.IsCompat }
func (m ClaudeModel) GetThinking() *registry.ThinkingSupport { return m.Thinking }
// CodexKey represents the configuration for a Codex API key,
// including the API key itself and an optional base URL for the API endpoint.
type CodexKey struct {
// APIKey is the authentication key for accessing Codex API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Priority controls selection preference when multiple credentials match.
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Weight controls proportional selection under weighted-round-robin.
// An omitted value defaults to 1; non-positive values exclude this credential; maximum 1,000,000.
Weight *int `yaml:"weight,omitempty" json:"weight,omitempty"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Codex API endpoint.
// If empty, the default Codex API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
// Websockets enables the Responses API websocket transport for this credential.
Websockets bool `yaml:"websockets,omitempty" json:"websockets,omitempty"`
// AlphaSearch allows this Codex API key to serve the Alpha Search endpoint.
AlphaSearch bool `yaml:"alpha-search,omitempty" json:"alpha-search,omitempty"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
// Models defines upstream model names and aliases for request routing.
Models []CodexModel `yaml:"models" json:"models"`
// Headers optionally adds extra HTTP headers for requests sent with this key.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
// DisableCooling overrides the global cooling policy for this credential when set.
// True disables auth/model cooldowns; false explicitly enables them.
DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
// RequestRetry optionally overrides the global request-retry for this credential.
// Nil or a negative value means "use the global request-retry". 0 disables additional retry rounds.
RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"`
// RequestScopedErrors configures custom classification rules for upstream errors.
RequestScopedErrors []RequestScopedErrorRule `yaml:"request-scoped-errors,omitempty" json:"request-scoped-errors,omitempty"`
}
func (k CodexKey) GetAPIKey() string { return k.APIKey }
func (k CodexKey) GetBaseURL() string { return k.BaseURL }
func (k CodexKey) GetPrefix() string { return k.Prefix }
func (k CodexKey) GetProxyURL() string { return k.ProxyURL }
// CodexModel describes a mapping between an alias and the actual upstream model name.
type CodexModel struct {
// Name is the upstream model identifier used when issuing requests.
Name string `yaml:"name" json:"name"`
// Alias is the client-facing model name that maps to Name.
Alias string `yaml:"alias" json:"alias"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
// MaxContextLength overrides the context window advertised to Codex clients.
MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"`
// ForceMapping rewrites upstream response model fields back to Alias.
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
// IsCompat converts Codex MultiAgentV2 agent_message items into portable
// Responses message/user input when codex.optimize-multi-agent-v2 is also true.
// Use this for third-party Responses-compatible endpoints that do not accept
// native agent_message items or empty-signature thinking blocks. Default false
// keeps the native behavior unchanged.
IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"`
// Thinking configures the thinking/reasoning capability for this model.
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
}
func (m CodexModel) GetName() string { return m.Name }
func (m CodexModel) GetAlias() string { return m.Alias }
func (m CodexModel) GetDisplayName() string { return m.DisplayName }
func (m CodexModel) GetMaxContextLength() int { return m.MaxContextLength }
func (m CodexModel) GetForceMapping() bool { return m.ForceMapping }
func (m CodexModel) GetIsCompat() bool { return m.IsCompat }
func (m CodexModel) GetThinking() *registry.ThinkingSupport { return m.Thinking }
// XAIKey uses the Codex API key structure for native xAI execution.
type XAIKey = CodexKey
// XAIModel uses the Codex model mapping structure for xAI models.
type XAIModel = CodexModel
// GeminiKey represents the configuration for a Gemini API key,
// including optional overrides for upstream base URL, proxy routing, and headers.
type GeminiKey struct {
// APIKey is the authentication key for accessing Gemini API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Priority controls selection preference when multiple credentials match.
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Weight controls proportional selection under weighted-round-robin.
// An omitted value defaults to 1; non-positive values exclude this credential; maximum 1,000,000.
Weight *int `yaml:"weight,omitempty" json:"weight,omitempty"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL optionally overrides the Gemini API endpoint.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
// ProxyURL optionally overrides the global proxy for this API key.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
// Models defines upstream model names and aliases for request routing.
Models []GeminiModel `yaml:"models,omitempty" json:"models,omitempty"`
// Headers optionally adds extra HTTP headers for requests sent with this key.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
// DisableCooling overrides the global cooling policy for this credential when set.
// True disables auth/model cooldowns; false explicitly enables them.
DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
// RequestRetry optionally overrides the global request-retry for this credential.
// Nil or a negative value means "use the global request-retry". 0 disables additional retry rounds.
RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"`
// RequestScopedErrors configures custom classification rules for upstream errors.
RequestScopedErrors []RequestScopedErrorRule `yaml:"request-scoped-errors,omitempty" json:"request-scoped-errors,omitempty"`
}
func (k GeminiKey) GetAPIKey() string { return k.APIKey }
func (k GeminiKey) GetBaseURL() string { return k.BaseURL }
func (k GeminiKey) GetPrefix() string { return k.Prefix }
func (k GeminiKey) GetProxyURL() string { return k.ProxyURL }
// GeminiModel describes a mapping between an alias and the actual upstream model name.
type GeminiModel struct {
// Name is the upstream model identifier used when issuing requests.
Name string `yaml:"name" json:"name"`
// Alias is the client-facing model name that maps to Name.
Alias string `yaml:"alias" json:"alias"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
// MaxContextLength overrides the context window advertised to Codex clients.
MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"`
// ForceMapping rewrites upstream response model fields back to Alias.
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
// IsCompat preserves thinking blocks with empty signatures for compatible upstreams.
// Default false keeps the normal signature validation behavior.
IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"`
// Thinking configures the thinking/reasoning capability for this model.
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
}
func (m GeminiModel) GetName() string { return m.Name }
func (m GeminiModel) GetAlias() string { return m.Alias }
func (m GeminiModel) GetDisplayName() string { return m.DisplayName }
func (m GeminiModel) GetMaxContextLength() int { return m.MaxContextLength }
func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping }
func (m GeminiModel) GetIsCompat() bool { return m.IsCompat }
func (m GeminiModel) GetThinking() *registry.ThinkingSupport { return m.Thinking }
// OpenAICompatibility represents the configuration for OpenAI API compatibility
// with external providers, allowing model aliases to be routed through OpenAI API format.
type OpenAICompatibility struct {
// Name is the identifier for this OpenAI compatibility configuration.
Name string `yaml:"name" json:"name"`
// Priority controls selection preference when multiple providers or credentials match.
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Disabled prevents this provider from being used for routing.
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
BaseURL string `yaml:"base-url" json:"base-url"`
// APIKeyEntries defines API keys with optional per-key proxy configuration.
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
// Models defines the model configurations including aliases for routing.
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
// Headers optionally adds extra HTTP headers for requests sent to this provider.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// SupportPromptCacheKey enables derived prompt_cache_key injection for supported requests.
SupportPromptCacheKey bool `yaml:"support-prompt-cache-key,omitempty" json:"support-prompt-cache-key,omitempty"`
// DisableCooling overrides the global cooling policy for this provider when set.
// True disables auth/model cooldowns; false explicitly enables them.
DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
// RequestRetry optionally overrides the global request-retry for this provider.
// Nil or a negative value means "use the global request-retry". 0 disables additional retry rounds.
RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"`
// RequestScopedErrors configures custom classification rules for upstream errors.
RequestScopedErrors []RequestScopedErrorRule `yaml:"request-scoped-errors,omitempty" json:"request-scoped-errors,omitempty"`
}
// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
type OpenAICompatibilityAPIKey struct {
// APIKey is the authentication key for accessing the external API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Weight controls proportional selection under weighted-round-robin.
// An omitted value defaults to 1; non-positive values exclude this credential; maximum 1,000,000.
Weight *int `yaml:"weight,omitempty" json:"weight,omitempty"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
}
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
// including the actual model name and its alias for API routing.
type OpenAICompatibilityModel struct {
// Name is the actual model name used by the external provider.
Name string `yaml:"name" json:"name"`
// Alias is the model name alias that clients will use to reference this model.
Alias string `yaml:"alias" json:"alias"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
// MaxContextLength overrides the context window advertised to Codex clients.
MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"`
// ForceMapping rewrites upstream response model fields back to Alias.
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
// Image marks this model as callable through /v1/images/generations and /v1/images/edits.
Image bool `yaml:"image,omitempty" json:"image,omitempty"`
// InputModalities declares chat/responses input capabilities (e.g. text, image) for Codex and other clients.
// This is separate from Image, which only enables /v1/images/* endpoints.
InputModalities []string `yaml:"input-modalities,omitempty" json:"input-modalities,omitempty"`
// OutputModalities declares supported output modalities when known (e.g. text, image).
OutputModalities []string `yaml:"output-modalities,omitempty" json:"output-modalities,omitempty"`
// IsCompat preserves Claude thinking blocks for compatible upstreams.
// Default false keeps the normal signature validation behavior.
IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"`
// Thinking configures the thinking/reasoning capability for this model.
// If nil, the model defaults to level-based reasoning with levels ["low", "medium", "high"].
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
}
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias }
func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName }
func (m OpenAICompatibilityModel) GetMaxContextLength() int { return m.MaxContextLength }
func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping }
func (m OpenAICompatibilityModel) GetIsCompat() bool { return m.IsCompat }
func (m OpenAICompatibilityModel) GetThinking() *registry.ThinkingSupport { return m.Thinking }

View file

@ -0,0 +1,79 @@
package config
import (
"bytes"
"encoding/json"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
// SanitizePayloadRules validates raw JSON payload rule params and drops invalid rules.
func (cfg *Config) SanitizePayloadRules() {
if cfg == nil {
return
}
cfg.Payload.DefaultRaw = sanitizePayloadRawRules(cfg.Payload.DefaultRaw, "default-raw")
cfg.Payload.OverrideRaw = sanitizePayloadRawRules(cfg.Payload.OverrideRaw, "override-raw")
}
func sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule {
if len(rules) == 0 {
return rules
}
out := make([]PayloadRule, 0, len(rules))
for i := range rules {
rule := rules[i]
if len(rule.Params) == 0 {
continue
}
invalid := false
for path, value := range rule.Params {
raw, ok := payloadRawString(value)
if !ok {
continue
}
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || !json.Valid(trimmed) {
log.WithFields(log.Fields{
"section": section,
"rule_index": i + 1,
"param": path,
}).Warn("payload rule dropped: invalid raw JSON")
invalid = true
break
}
}
if invalid {
continue
}
out = append(out, rule)
}
return out
}
func payloadRawString(value any) ([]byte, bool) {
switch typed := value.(type) {
case string:
return []byte(typed), true
case []byte:
return typed, true
default:
return nil, false
}
}
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
func looksLikeBcrypt(s string) bool {
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
}
// hashSecret hashes the given secret using bcrypt.
func hashSecret(secret string) (string, error) {
// Use default cost for simplicity.
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}

View file

@ -0,0 +1,819 @@
package config
import (
"bytes"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
persistCfg := cfg
// Load original YAML as a node tree to preserve comments and ordering.
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
var original yaml.Node
if err = yaml.Unmarshal(data, &original); err != nil {
return err
}
if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
return fmt.Errorf("invalid yaml document structure")
}
if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("expected root mapping node")
}
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
rendered, err := yaml.Marshal(persistCfg)
if err != nil {
return err
}
var generated yaml.Node
if err = yaml.Unmarshal(rendered, &generated); err != nil {
return err
}
if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
return fmt.Errorf("invalid generated yaml structure")
}
if generated.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("expected generated root mapping node")
}
// Remove deprecated sections before merging back the sanitized config.
removeLegacyAuthBlock(original.Content[0])
removeLegacyOpenAICompatAPIKeys(original.Content[0])
removeRemovedIntegrationKeys(original.Content[0])
removeLegacyGenerativeLanguageKeys(original.Content[0])
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models")
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-model-alias")
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-request-scoped-errors")
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "plugins", "configs")
// Merge generated into original in-place, preserving comments/order of existing nodes.
mergeMappingPreserve(original.Content[0], generated.Content[0])
normalizeCollectionNodeStyles(original.Content[0])
// Write back.
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err = enc.Encode(&original); err != nil {
_ = enc.Close()
return err
}
if err = enc.Close(); err != nil {
return err
}
data = NormalizeCommentIndentation(buf.Bytes())
_, err = f.Write(data)
return err
}
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
// while preserving comments and positions.
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
var root yaml.Node
if err = yaml.Unmarshal(data, &root); err != nil {
return err
}
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
return fmt.Errorf("invalid yaml document structure")
}
node := root.Content[0]
// descend mapping nodes following path
for i, key := range path {
if i == len(path)-1 {
// set final scalar
v := getOrCreateMapValue(node, key)
v.Kind = yaml.ScalarNode
v.Tag = "!!str"
v.Value = value
} else {
next := getOrCreateMapValue(node, key)
if next.Kind != yaml.MappingNode {
next.Kind = yaml.MappingNode
next.Tag = "!!map"
}
node = next
}
}
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err = enc.Encode(&root); err != nil {
_ = enc.Close()
return err
}
if err = enc.Close(); err != nil {
return err
}
data = NormalizeCommentIndentation(buf.Bytes())
_, err = f.Write(data)
return err
}
// NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
func NormalizeCommentIndentation(data []byte) []byte {
lines := bytes.Split(data, []byte("\n"))
changed := false
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " \t")
if len(trimmed) == 0 || trimmed[0] != '#' {
continue
}
if len(trimmed) == len(line) {
continue
}
lines[i] = append([]byte(nil), trimmed...)
changed = true
}
if !changed {
return data
}
return bytes.Join(lines, []byte("\n"))
}
// getOrCreateMapValue finds the value node for a given key in a mapping node.
// If not found, it appends a new key/value pair and returns the new value node.
func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
if mapNode.Kind != yaml.MappingNode {
mapNode.Kind = yaml.MappingNode
mapNode.Tag = "!!map"
mapNode.Content = nil
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
k := mapNode.Content[i]
if k.Value == key {
return mapNode.Content[i+1]
}
}
// append new key/value
mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
mapNode.Content = append(mapNode.Content, val)
return val
}
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
// key order and comments of existing keys in dst. New keys are only added if their
// value is non-zero and not a known default to avoid polluting the config with defaults.
func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {
var currentPath []string
if len(path) > 0 {
currentPath = path[0]
}
if dst == nil || src == nil {
return
}
if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
// If kinds do not match, prefer replacing dst with src semantics in-place
// but keep dst node object to preserve any attached comments at the parent level.
copyNodeShallow(dst, src)
return
}
for i := 0; i+1 < len(src.Content); i += 2 {
sk := src.Content[i]
sv := src.Content[i+1]
idx := findMapKeyIndex(dst, sk.Value)
childPath := appendPath(currentPath, sk.Value)
if idx >= 0 {
// Merge into existing value node (always update, even to zero values)
dv := dst.Content[idx+1]
mergeNodePreserve(dv, sv, childPath)
} else {
// New key: only add if value is non-zero and not a known default
candidate := deepCopyNode(sv)
pruneKnownDefaultsInNewNode(childPath, candidate)
if isKnownDefaultValue(childPath, candidate) {
continue
}
dst.Content = append(dst.Content, deepCopyNode(sk), candidate)
}
}
}
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
// reusing destination nodes to keep comments and anchors. For sequences, it updates
// in-place by index.
func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) {
var currentPath []string
if len(path) > 0 {
currentPath = path[0]
}
if dst == nil || src == nil {
return
}
switch src.Kind {
case yaml.MappingNode:
if dst.Kind != yaml.MappingNode {
copyNodeShallow(dst, src)
}
mergeMappingPreserve(dst, src, currentPath)
case yaml.SequenceNode:
// Preserve explicit null style if dst was null and src is empty sequence
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
// Keep as null to preserve original style
return
}
if dst.Kind != yaml.SequenceNode {
dst.Kind = yaml.SequenceNode
dst.Tag = "!!seq"
dst.Content = nil
}
reorderSequenceForMerge(dst, src)
// Update elements in place
minContent := len(dst.Content)
if len(src.Content) < minContent {
minContent = len(src.Content)
}
for i := 0; i < minContent; i++ {
if dst.Content[i] == nil {
dst.Content[i] = deepCopyNode(src.Content[i])
continue
}
mergeNodePreserve(dst.Content[i], src.Content[i], currentPath)
if dst.Content[i] != nil && src.Content[i] != nil &&
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
pruneMissingMapKeys(dst.Content[i], src.Content[i])
}
}
// Append any extra items from src
for i := len(dst.Content); i < len(src.Content); i++ {
dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
}
// Truncate if dst has extra items not in src
if len(src.Content) < len(dst.Content) {
dst.Content = dst.Content[:len(src.Content)]
}
case yaml.ScalarNode, yaml.AliasNode:
// For scalars, update Tag and Value but keep Style from dst to preserve quoting
dst.Kind = src.Kind
dst.Tag = src.Tag
dst.Value = src.Value
// Keep dst.Style as-is intentionally
case 0:
// Unknown/empty kind; do nothing
default:
// Fallback: replace shallowly
copyNodeShallow(dst, src)
}
}
// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
// Returns -1 when not found.
func findMapKeyIndex(mapNode *yaml.Node, key string) int {
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
return -1
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
return i
}
}
return -1
}
// appendPath appends a key to the path, returning a new slice to avoid modifying the original.
func appendPath(path []string, key string) []string {
if len(path) == 0 {
return []string{key}
}
newPath := make([]string, len(path)+1)
copy(newPath, path)
newPath[len(path)] = key
return newPath
}
// isKnownDefaultValue returns true if the given node at the specified path
// represents a known default value that should not be written to the config file.
// This prevents non-zero defaults from polluting the config.
func isKnownDefaultValue(path []string, node *yaml.Node) bool {
// Weight is pointer-backed, so an explicit zero is meaningful and must be preserved.
if len(path) > 0 && path[len(path)-1] == "weight" && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!int" {
return false
}
// First check if it's a zero value
if isZeroValueNode(node) {
return true
}
// Match known non-zero defaults by exact dotted path.
if len(path) == 0 {
return false
}
fullPath := strings.Join(path, ".")
// Check string defaults
if node.Kind == yaml.ScalarNode && node.Tag == "!!str" {
switch fullPath {
case "pprof.addr":
return node.Value == DefaultPprofAddr
case "plugins.dir":
return node.Value == "plugins"
case "routing.strategy":
return node.Value == "round-robin"
}
}
// Check integer defaults
if node.Kind == yaml.ScalarNode && node.Tag == "!!int" {
switch fullPath {
case "error-logs-max-files":
return node.Value == "10"
}
}
return false
}
// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node
// before it is appended into the destination YAML tree.
func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.MappingNode:
filtered := make([]*yaml.Node, 0, len(node.Content))
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode == nil || valueNode == nil {
continue
}
childPath := appendPath(path, keyNode.Value)
if isKnownDefaultValue(childPath, valueNode) {
continue
}
pruneKnownDefaultsInNewNode(childPath, valueNode)
if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) &&
len(valueNode.Content) == 0 {
continue
}
filtered = append(filtered, keyNode, valueNode)
}
node.Content = filtered
case yaml.SequenceNode:
for _, child := range node.Content {
pruneKnownDefaultsInNewNode(path, child)
}
}
}
// isZeroValueNode returns true if the YAML node represents a zero/default value
// that should not be written as a new key to preserve config cleanliness.
// For mappings and sequences, recursively checks if all children are zero values.
func isZeroValueNode(node *yaml.Node) bool {
if node == nil {
return true
}
switch node.Kind {
case yaml.ScalarNode:
switch node.Tag {
case "!!bool":
return node.Value == "false"
case "!!int", "!!float":
return node.Value == "0" || node.Value == "0.0"
case "!!str":
return node.Value == ""
case "!!null":
return true
}
case yaml.SequenceNode:
if len(node.Content) == 0 {
return true
}
// Check if all elements are zero values
for _, child := range node.Content {
if !isZeroValueNode(child) {
return false
}
}
return true
case yaml.MappingNode:
if len(node.Content) == 0 {
return true
}
// Check if all values are zero values (values are at odd indices)
for i := 1; i < len(node.Content); i += 2 {
if !isZeroValueNode(node.Content[i]) {
return false
}
}
return true
}
return false
}
// deepCopyNode creates a deep copy of a yaml.Node graph.
func deepCopyNode(n *yaml.Node) *yaml.Node {
return deepCopyNodeSeen(n, map[*yaml.Node]*yaml.Node{})
}
func deepCopyNodeSeen(n *yaml.Node, seen map[*yaml.Node]*yaml.Node) *yaml.Node {
if n == nil {
return nil
}
if cp, ok := seen[n]; ok {
return cp
}
cp := *n
seen[n] = &cp
if n.Alias != nil {
cp.Alias = deepCopyNodeSeen(n.Alias, seen)
}
if len(n.Content) > 0 {
cp.Content = make([]*yaml.Node, len(n.Content))
for i := range n.Content {
cp.Content[i] = deepCopyNodeSeen(n.Content[i], seen)
}
}
return &cp
}
// copyNodeShallow copies type/tag/value and resets content to match src, but
// keeps the same destination node pointer to preserve parent relations/comments.
func copyNodeShallow(dst, src *yaml.Node) {
if dst == nil || src == nil {
return
}
dst.Kind = src.Kind
dst.Tag = src.Tag
dst.Value = src.Value
// Replace content with deep copy from src
if len(src.Content) > 0 {
dst.Content = make([]*yaml.Node, len(src.Content))
for i := range src.Content {
dst.Content[i] = deepCopyNode(src.Content[i])
}
} else {
dst.Content = nil
}
}
func reorderSequenceForMerge(dst, src *yaml.Node) {
if dst == nil || src == nil {
return
}
if len(dst.Content) == 0 {
return
}
if len(src.Content) == 0 {
return
}
original := append([]*yaml.Node(nil), dst.Content...)
used := make([]bool, len(original))
ordered := make([]*yaml.Node, len(src.Content))
for i := range src.Content {
if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 {
ordered[i] = original[idx]
used[idx] = true
}
}
dst.Content = ordered
}
func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int {
if target == nil {
return -1
}
switch target.Kind {
case yaml.MappingNode:
id := sequenceElementIdentity(target)
if id != "" {
for i := range original {
if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode {
continue
}
if sequenceElementIdentity(original[i]) == id {
return i
}
}
}
case yaml.ScalarNode:
val := strings.TrimSpace(target.Value)
if val != "" {
for i := range original {
if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode {
continue
}
if strings.TrimSpace(original[i].Value) == val {
return i
}
}
}
default:
}
// Fallback to structural equality to preserve nodes lacking explicit identifiers.
for i := range original {
if used[i] || original[i] == nil {
continue
}
if nodesStructurallyEqual(original[i], target) {
return i
}
}
return -1
}
func sequenceElementIdentity(node *yaml.Node) string {
if node == nil || node.Kind != yaml.MappingNode {
return ""
}
identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"}
for _, k := range identityKeys {
if v := mappingScalarValue(node, k); v != "" {
return k + "=" + v
}
}
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
valNode := node.Content[i+1]
if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
continue
}
val := strings.TrimSpace(valNode.Value)
if val != "" {
return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val
}
}
return ""
}
func mappingScalarValue(node *yaml.Node, key string) string {
if node == nil || node.Kind != yaml.MappingNode {
return ""
}
lowerKey := strings.ToLower(key)
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
valNode := node.Content[i+1]
if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
continue
}
if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey {
return strings.TrimSpace(valNode.Value)
}
}
return ""
}
func nodesStructurallyEqual(a, b *yaml.Node) bool {
if a == nil || b == nil {
return a == b
}
if a.Kind != b.Kind {
return false
}
switch a.Kind {
case yaml.MappingNode:
if len(a.Content) != len(b.Content) {
return false
}
for i := 0; i+1 < len(a.Content); i += 2 {
if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
return false
}
if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) {
return false
}
}
return true
case yaml.SequenceNode:
if len(a.Content) != len(b.Content) {
return false
}
for i := range a.Content {
if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
return false
}
}
return true
case yaml.ScalarNode:
return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
case yaml.AliasNode:
return nodesStructurallyEqual(a.Alias, b.Alias)
default:
return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
}
}
func removeMapKey(mapNode *yaml.Node, key string) {
if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
return
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
return
}
}
}
func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, keyPath ...string) {
if len(keyPath) == 0 || dstRoot == nil || srcRoot == nil {
return
}
if len(keyPath) > 1 {
dstParent := dstRoot
srcParent := srcRoot
for _, key := range keyPath[:len(keyPath)-1] {
if key == "" || dstParent == nil || dstParent.Kind != yaml.MappingNode {
return
}
dstIdx := findMapKeyIndex(dstParent, key)
if dstIdx < 0 || dstIdx+1 >= len(dstParent.Content) {
return
}
dstParent = dstParent.Content[dstIdx+1]
if srcParent != nil && srcParent.Kind == yaml.MappingNode {
srcIdx := findMapKeyIndex(srcParent, key)
if srcIdx >= 0 && srcIdx+1 < len(srcParent.Content) {
srcParent = srcParent.Content[srcIdx+1]
} else {
srcParent = nil
}
}
}
if srcParent == nil || srcParent.Kind != yaml.MappingNode {
removeMapKey(dstParent, keyPath[len(keyPath)-1])
return
}
pruneMappingToGeneratedKeys(dstParent, srcParent, keyPath[len(keyPath)-1])
return
}
key := keyPath[0]
if key == "" {
return
}
if dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode {
return
}
dstIdx := findMapKeyIndex(dstRoot, key)
if dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) {
return
}
srcIdx := findMapKeyIndex(srcRoot, key)
if srcIdx < 0 {
// Keep an explicit empty mapping for oauth-model-alias and oauth-request-scoped-errors when previously present.
// When users delete the last channel via the management API,
// we want that deletion to persist across hot reloads and restarts.
if key == "oauth-model-alias" || key == "oauth-request-scoped-errors" {
dstRoot.Content[dstIdx+1] = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
return
}
removeMapKey(dstRoot, key)
return
}
if srcIdx+1 >= len(srcRoot.Content) {
return
}
srcVal := srcRoot.Content[srcIdx+1]
dstVal := dstRoot.Content[dstIdx+1]
if srcVal == nil {
dstRoot.Content[dstIdx+1] = nil
return
}
if srcVal.Kind != yaml.MappingNode {
dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
return
}
if dstVal == nil || dstVal.Kind != yaml.MappingNode {
dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
return
}
pruneMissingMapKeys(dstVal, srcVal)
}
func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) {
if dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode {
return
}
keep := make(map[string]struct{}, len(srcMap.Content)/2)
for i := 0; i+1 < len(srcMap.Content); i += 2 {
keyNode := srcMap.Content[i]
if keyNode == nil {
continue
}
key := strings.TrimSpace(keyNode.Value)
if key == "" {
continue
}
keep[key] = struct{}{}
}
for i := 0; i+1 < len(dstMap.Content); {
keyNode := dstMap.Content[i]
if keyNode == nil {
i += 2
continue
}
key := strings.TrimSpace(keyNode.Value)
if _, ok := keep[key]; !ok {
dstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...)
continue
}
i += 2
}
}
// normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
// lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
// remain compact.
func normalizeCollectionNodeStyles(node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.MappingNode:
node.Style = 0
for i := range node.Content {
normalizeCollectionNodeStyles(node.Content[i])
}
case yaml.SequenceNode:
if len(node.Content) == 0 {
node.Style = yaml.FlowStyle
} else {
node.Style = 0
}
for i := range node.Content {
normalizeCollectionNodeStyles(node.Content[i])
}
default:
// Scalars keep their existing style to preserve quoting
}
}
func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) {
if root == nil || root.Kind != yaml.MappingNode {
return
}
idx := findMapKeyIndex(root, "openai-compatibility")
if idx < 0 || idx+1 >= len(root.Content) {
return
}
seq := root.Content[idx+1]
if seq == nil || seq.Kind != yaml.SequenceNode {
return
}
for i := range seq.Content {
if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode {
removeMapKey(seq.Content[i], "api-keys")
}
}
}
func removeRemovedIntegrationKeys(root *yaml.Node) {
if root == nil || root.Kind != yaml.MappingNode {
return
}
removeMapKey(root, "ampcode")
removeMapKey(root, "amp-upstream-url")
removeMapKey(root, "amp-upstream-api-key")
removeMapKey(root, "amp-restrict-management-to-localhost")
removeMapKey(root, "amp-model-mappings")
}
func removeLegacyGenerativeLanguageKeys(root *yaml.Node) {
if root == nil || root.Kind != yaml.MappingNode {
return
}
removeMapKey(root, "generative-language-api-key")
}
func removeLegacyAuthBlock(root *yaml.Node) {
if root == nil || root.Kind != yaml.MappingNode {
return
}
removeMapKey(root, "auth")
}

View file

@ -0,0 +1,54 @@
package config
import "testing"
func TestParseConfigBytesPreservesCoolingOverridePresence(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
disable-cooling: true
gemini-api-key:
- api-key: gemini-key
disable-cooling: false
interactions-api-key:
- api-key: interactions-key
disable-cooling: false
claude-api-key:
- api-key: claude-key
disable-cooling: false
codex-api-key:
- api-key: codex-key
base-url: https://codex.example.com
disable-cooling: false
xai-api-key:
- api-key: xai-key
base-url: https://api.x.ai/v1
disable-cooling: false
openai-compatibility:
- name: compat
base-url: https://compat.example.com
disable-cooling: false
api-key-entries:
- api-key: compat-key
vertex-api-key:
- api-key: vertex-key
base-url: https://vertex.example.com
disable-cooling: false
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
overrides := map[string]*bool{
"gemini": cfg.GeminiKey[0].DisableCooling,
"interactions": cfg.InteractionsKey[0].DisableCooling,
"claude": cfg.ClaudeKey[0].DisableCooling,
"codex": cfg.CodexKey[0].DisableCooling,
"xai": cfg.XAIKey[0].DisableCooling,
"openai compatibility": cfg.OpenAICompatibility[0].DisableCooling,
"vertex": cfg.VertexCompatAPIKey[0].DisableCooling,
}
for name, override := range overrides {
if override == nil || *override {
t.Errorf("%s disable-cooling = %v, want explicit false", name, override)
}
}
}

View file

@ -0,0 +1,194 @@
package config
import (
"fmt"
"time"
"gopkg.in/yaml.v3"
)
const (
defaultCPAHeartbeatTimeout = 3 * time.Second
defaultCPACancelBound = 5 * time.Second
defaultReclaimGrace = 5 * time.Second
defaultCleanupInterval = 5 * time.Second
defaultReleaseFlushInterval = 250 * time.Millisecond
defaultReleaseMaxBackoff = 2 * time.Second
defaultBusyRetryMin = 250 * time.Millisecond
defaultBusyRetryMax = time.Second
maxCredentialConcurrencyLimit int64 = 1_000_000
)
// CredentialConcurrencyConfig controls the credential concurrency lifecycle managed by Home.
type CredentialConcurrencyConfig struct {
LifecycleConfigRevision int64 `yaml:"lifecycle-config-revision" json:"lifecycle-config-revision"`
ObservationBarrierRevision int64 `yaml:"observation-barrier-revision" json:"observation-barrier-revision"`
CPAHeartbeatTimeout time.Duration `yaml:"cpa-heartbeat-timeout" json:"cpa-heartbeat-timeout"`
CPACancelBound time.Duration `yaml:"cpa-cancel-bound" json:"cpa-cancel-bound"`
ReclaimGrace time.Duration `yaml:"reclaim-grace" json:"reclaim-grace"`
CleanupInterval time.Duration `yaml:"cleanup-interval" json:"cleanup-interval"`
ReleaseFlushInterval time.Duration `yaml:"release-flush-interval" json:"release-flush-interval"`
ReleaseMaxBackoff time.Duration `yaml:"release-max-backoff" json:"release-max-backoff"`
BusyRetryMin time.Duration `yaml:"busy-retry-min" json:"busy-retry-min"`
BusyRetryMax time.Duration `yaml:"busy-retry-max" json:"busy-retry-max"`
MaxLimit int64 `yaml:"max-limit" json:"max-limit"`
lifecycleConfigRevisionPresent bool
observationBarrierRevisionPresent bool
cpaHeartbeatTimeoutPresent bool
cpaCancelBoundPresent bool
reclaimGracePresent bool
cleanupIntervalPresent bool
releaseFlushIntervalPresent bool
releaseMaxBackoffPresent bool
busyRetryMinPresent bool
busyRetryMaxPresent bool
maxLimitPresent bool
}
// UnmarshalYAML preserves field presence so only absent lifecycle values receive legacy defaults.
func (c *CredentialConcurrencyConfig) UnmarshalYAML(value *yaml.Node) error {
type rawCredentialConcurrencyConfig struct {
LifecycleConfigRevision int64 `yaml:"lifecycle-config-revision"`
ObservationBarrierRevision int64 `yaml:"observation-barrier-revision"`
CPAHeartbeatTimeout time.Duration `yaml:"cpa-heartbeat-timeout"`
CPACancelBound time.Duration `yaml:"cpa-cancel-bound"`
ReclaimGrace time.Duration `yaml:"reclaim-grace"`
CleanupInterval time.Duration `yaml:"cleanup-interval"`
ReleaseFlushInterval time.Duration `yaml:"release-flush-interval"`
ReleaseMaxBackoff time.Duration `yaml:"release-max-backoff"`
BusyRetryMin time.Duration `yaml:"busy-retry-min"`
BusyRetryMax time.Duration `yaml:"busy-retry-max"`
MaxLimit int64 `yaml:"max-limit"`
}
var raw rawCredentialConcurrencyConfig
if errDecode := value.Decode(&raw); errDecode != nil {
return errDecode
}
*c = CredentialConcurrencyConfig{
LifecycleConfigRevision: raw.LifecycleConfigRevision,
ObservationBarrierRevision: raw.ObservationBarrierRevision,
CPAHeartbeatTimeout: raw.CPAHeartbeatTimeout,
CPACancelBound: raw.CPACancelBound,
ReclaimGrace: raw.ReclaimGrace,
CleanupInterval: raw.CleanupInterval,
ReleaseFlushInterval: raw.ReleaseFlushInterval,
ReleaseMaxBackoff: raw.ReleaseMaxBackoff,
BusyRetryMin: raw.BusyRetryMin,
BusyRetryMax: raw.BusyRetryMax,
MaxLimit: raw.MaxLimit,
lifecycleConfigRevisionPresent: credentialConcurrencyFieldPresent(value, "lifecycle-config-revision"),
observationBarrierRevisionPresent: credentialConcurrencyFieldPresent(value, "observation-barrier-revision"),
cpaHeartbeatTimeoutPresent: credentialConcurrencyFieldPresent(value, "cpa-heartbeat-timeout"),
cpaCancelBoundPresent: credentialConcurrencyFieldPresent(value, "cpa-cancel-bound"),
reclaimGracePresent: credentialConcurrencyFieldPresent(value, "reclaim-grace"),
cleanupIntervalPresent: credentialConcurrencyFieldPresent(value, "cleanup-interval"),
releaseFlushIntervalPresent: credentialConcurrencyFieldPresent(value, "release-flush-interval"),
releaseMaxBackoffPresent: credentialConcurrencyFieldPresent(value, "release-max-backoff"),
busyRetryMinPresent: credentialConcurrencyFieldPresent(value, "busy-retry-min"),
busyRetryMaxPresent: credentialConcurrencyFieldPresent(value, "busy-retry-max"),
maxLimitPresent: credentialConcurrencyFieldPresent(value, "max-limit"),
}
return nil
}
func credentialConcurrencyFieldPresent(value *yaml.Node, field string) bool {
if value == nil || value.Kind != yaml.MappingNode {
return false
}
for index := 0; index+1 < len(value.Content); index += 2 {
if value.Content[index].Value == field {
return true
}
}
return false
}
// WithDefaults applies the lifecycle defaults required for compatibility with older Home versions.
func (c CredentialConcurrencyConfig) WithDefaults() CredentialConcurrencyConfig {
if !c.cpaHeartbeatTimeoutPresent && c.CPAHeartbeatTimeout == 0 {
c.CPAHeartbeatTimeout = defaultCPAHeartbeatTimeout
}
if !c.cpaCancelBoundPresent && c.CPACancelBound == 0 {
c.CPACancelBound = defaultCPACancelBound
}
if !c.reclaimGracePresent && c.ReclaimGrace == 0 {
c.ReclaimGrace = defaultReclaimGrace
}
if !c.cleanupIntervalPresent && c.CleanupInterval == 0 {
c.CleanupInterval = defaultCleanupInterval
}
if !c.releaseFlushIntervalPresent && c.ReleaseFlushInterval == 0 {
c.ReleaseFlushInterval = defaultReleaseFlushInterval
}
if !c.releaseMaxBackoffPresent && c.ReleaseMaxBackoff == 0 {
c.ReleaseMaxBackoff = defaultReleaseMaxBackoff
}
if !c.busyRetryMinPresent && c.BusyRetryMin == 0 {
c.BusyRetryMin = defaultBusyRetryMin
}
if !c.busyRetryMaxPresent && c.BusyRetryMax == 0 {
c.BusyRetryMax = defaultBusyRetryMax
}
if !c.maxLimitPresent && c.MaxLimit == 0 {
c.MaxLimit = maxCredentialConcurrencyLimit
}
return c
}
// ValidateCredentialConcurrency validates values intrinsic to a credential concurrency configuration.
func ValidateCredentialConcurrency(cfg CredentialConcurrencyConfig) error {
if cfg.LifecycleConfigRevision < 0 || (cfg.lifecycleConfigRevisionPresent && cfg.LifecycleConfigRevision == 0) {
return fmt.Errorf("lifecycle configuration revision must be positive when present")
}
if cfg.ObservationBarrierRevision < 0 {
return fmt.Errorf("observation barrier revision must not be negative")
}
if cfg.CPAHeartbeatTimeout <= 0 || cfg.CPACancelBound <= 0 || cfg.ReclaimGrace <= 0 || cfg.CleanupInterval <= 0 {
return fmt.Errorf("credential concurrency lifecycle durations must be positive")
}
if cfg.ReleaseFlushInterval <= 0 || cfg.ReleaseMaxBackoff <= 0 || cfg.BusyRetryMin <= 0 || cfg.BusyRetryMax <= 0 {
return fmt.Errorf("credential concurrency limiter durations must be positive")
}
if cfg.ReleaseMaxBackoff < cfg.ReleaseFlushInterval {
return fmt.Errorf("credential concurrency release max backoff must not be less than release flush interval")
}
if cfg.BusyRetryMin%time.Millisecond != 0 || cfg.BusyRetryMax%time.Millisecond != 0 {
return fmt.Errorf("credential concurrency busy retry durations must be whole milliseconds")
}
if cfg.BusyRetryMax < cfg.BusyRetryMin {
return fmt.Errorf("credential concurrency busy retry max must not be less than busy retry min")
}
if cfg.MaxLimit < 1 || cfg.MaxLimit > maxCredentialConcurrencyLimit {
return fmt.Errorf("credential concurrency max limit must be between 1 and %d", maxCredentialConcurrencyLimit)
}
return nil
}
// ValidateCredentialConcurrencyLifecycle verifies the Home lifecycle timing safety invariant.
func ValidateCredentialConcurrencyLifecycle(nodeHeartbeatTimeout time.Duration, cfg CredentialConcurrencyConfig) error {
if nodeHeartbeatTimeout <= 0 {
return fmt.Errorf("credential concurrency lifecycle durations must be positive")
}
if errValidate := ValidateCredentialConcurrency(cfg); errValidate != nil {
return errValidate
}
left, leftOverflow := addCredentialConcurrencyDuration(nodeHeartbeatTimeout, cfg.ReclaimGrace)
right, rightOverflow := addCredentialConcurrencyDuration(cfg.CPAHeartbeatTimeout, cfg.CPACancelBound)
if leftOverflow || rightOverflow {
return fmt.Errorf("credential concurrency lifecycle timing safety invariant overflows")
}
if left <= right {
return fmt.Errorf("node heartbeat timeout plus reclaim grace must exceed CPA heartbeat timeout plus cancel bound")
}
return nil
}
func addCredentialConcurrencyDuration(left time.Duration, right time.Duration) (time.Duration, bool) {
if right > 0 && left > time.Duration(1<<63-1)-right {
return 0, true
}
return left + right, false
}

View file

@ -0,0 +1,131 @@
package config
import (
"fmt"
"testing"
"time"
"gopkg.in/yaml.v3"
)
type credentialConcurrencyFixtureWireConfig struct {
LifecycleConfigRevision int64
ObservationBarrierRevision int64
CPAHeartbeatTimeout time.Duration
CPACancelBound time.Duration
ReclaimGrace time.Duration
CleanupInterval time.Duration
ReleaseFlushInterval string `yaml:"release-flush-interval"`
ReleaseMaxBackoff string `yaml:"release-max-backoff"`
BusyRetryMin string `yaml:"busy-retry-min"`
BusyRetryMax string `yaml:"busy-retry-max"`
MaxLimit int64
}
type credentialConcurrencyFixtureHotDurations struct {
ReleaseFlushInterval time.Duration `yaml:"release-flush-interval"`
ReleaseMaxBackoff time.Duration `yaml:"release-max-backoff"`
BusyRetryMin time.Duration `yaml:"busy-retry-min"`
BusyRetryMax time.Duration `yaml:"busy-retry-max"`
}
func (c credentialConcurrencyFixtureWireConfig) config() (CredentialConcurrencyConfig, error) {
raw, errMarshal := yaml.Marshal(c)
if errMarshal != nil {
return CredentialConcurrencyConfig{}, fmt.Errorf("marshal fixture hot durations as YAML: %w", errMarshal)
}
var hot credentialConcurrencyFixtureHotDurations
if errUnmarshal := yaml.Unmarshal(raw, &hot); errUnmarshal != nil {
return CredentialConcurrencyConfig{}, fmt.Errorf("parse fixture hot durations as YAML: %w", errUnmarshal)
}
return CredentialConcurrencyConfig{
LifecycleConfigRevision: c.LifecycleConfigRevision,
ObservationBarrierRevision: c.ObservationBarrierRevision,
CPAHeartbeatTimeout: c.CPAHeartbeatTimeout,
CPACancelBound: c.CPACancelBound,
ReclaimGrace: c.ReclaimGrace,
CleanupInterval: c.CleanupInterval,
ReleaseFlushInterval: hot.ReleaseFlushInterval,
ReleaseMaxBackoff: hot.ReleaseMaxBackoff,
BusyRetryMin: hot.BusyRetryMin,
BusyRetryMax: hot.BusyRetryMax,
MaxLimit: c.MaxLimit,
}, nil
}
func credentialConcurrencyWireFixture(cpaHeartbeatTimeout time.Duration) credentialConcurrencyFixtureWireConfig {
return credentialConcurrencyFixtureWireConfig{
CPAHeartbeatTimeout: cpaHeartbeatTimeout,
CPACancelBound: 5 * time.Second,
ReclaimGrace: 5 * time.Second,
CleanupInterval: 5 * time.Second,
ReleaseFlushInterval: "250ms",
ReleaseMaxBackoff: "2s",
BusyRetryMin: "250ms",
BusyRetryMax: "1s",
MaxLimit: 1_000_000,
}
}
func credentialConcurrencyConfigFixture(cpaHeartbeatTimeout time.Duration) CredentialConcurrencyConfig {
return CredentialConcurrencyConfig{
CPAHeartbeatTimeout: cpaHeartbeatTimeout,
CPACancelBound: 5 * time.Second,
ReclaimGrace: 5 * time.Second,
CleanupInterval: 5 * time.Second,
ReleaseFlushInterval: 250 * time.Millisecond,
ReleaseMaxBackoff: 2 * time.Second,
BusyRetryMin: 250 * time.Millisecond,
BusyRetryMax: time.Second,
MaxLimit: 1_000_000,
}
}
func TestCredentialConcurrencyLifecycleFixture(t *testing.T) {
wireDefaults := credentialConcurrencyWireFixture(3 * time.Second)
wireDefaults.LifecycleConfigRevision = 1
defaults, errConfig := wireDefaults.config()
if errConfig != nil {
t.Fatal(errConfig)
}
expectedDefaults := credentialConcurrencyConfigFixture(3 * time.Second)
expectedDefaults.LifecycleConfigRevision = 1
if defaults != expectedDefaults {
t.Fatalf("defaults = %#v, want %#v", defaults, expectedDefaults)
}
if errValidate := ValidateCredentialConcurrency(defaults); errValidate != nil {
t.Fatalf("ValidateCredentialConcurrency(defaults) error = %v", errValidate)
}
invalidFixtures := []struct {
NodeHeartbeatTimeout time.Duration
Config credentialConcurrencyFixtureWireConfig
}{
{NodeHeartbeatTimeout: 3 * time.Second, Config: credentialConcurrencyWireFixture(3 * time.Second)},
{NodeHeartbeatTimeout: 20 * time.Second, Config: credentialConcurrencyWireFixture(0)},
}
expectedInvalid := []struct {
nodeHeartbeatTimeout time.Duration
config CredentialConcurrencyConfig
}{
{nodeHeartbeatTimeout: 3 * time.Second, config: credentialConcurrencyConfigFixture(3 * time.Second)},
{nodeHeartbeatTimeout: 20 * time.Second, config: credentialConcurrencyConfigFixture(0)},
}
if len(invalidFixtures) != len(expectedInvalid) {
t.Fatalf("invalid fixture count = %d, want %d", len(invalidFixtures), len(expectedInvalid))
}
for index, expected := range expectedInvalid {
item := invalidFixtures[index]
itemConfig, errConfig := item.Config.config()
if errConfig != nil {
t.Fatalf("invalid fixture %d config() error = %v", index, errConfig)
}
if item.NodeHeartbeatTimeout != expected.nodeHeartbeatTimeout || itemConfig != expected.config {
t.Fatalf("invalid fixture %d = %#v, want node heartbeat timeout %s and config %#v", index, itemConfig, expected.nodeHeartbeatTimeout, expected.config)
}
if errValidate := ValidateCredentialConcurrencyLifecycle(item.NodeHeartbeatTimeout, itemConfig); errValidate == nil {
t.Fatalf("invalid fixture %d passed", index)
}
}
}

View file

@ -0,0 +1,124 @@
package config
import (
"testing"
"time"
)
func TestCredentialConcurrencyLimiterConfig(t *testing.T) {
got := (CredentialConcurrencyConfig{}).WithDefaults()
if got.LifecycleConfigRevision != 0 || got.ObservationBarrierRevision != 0 {
t.Fatalf("default revisions = %d, %d, want 0, 0", got.LifecycleConfigRevision, got.ObservationBarrierRevision)
}
if got.CPAHeartbeatTimeout != 3*time.Second || got.CPACancelBound != 5*time.Second || got.ReclaimGrace != 5*time.Second || got.CleanupInterval != 5*time.Second {
t.Fatalf("default lifecycle config = %#v", got)
}
if got.ReleaseFlushInterval != 250*time.Millisecond || got.ReleaseMaxBackoff != 2*time.Second || got.BusyRetryMin != 250*time.Millisecond || got.BusyRetryMax != time.Second || got.MaxLimit != 1_000_000 {
t.Fatalf("default limiter config = %#v", got)
}
if errValidate := ValidateCredentialConcurrencyLifecycle(20*time.Second, got); errValidate != nil {
t.Fatalf("ValidateCredentialConcurrencyLifecycle() error = %v", errValidate)
}
if errValidate := ValidateCredentialConcurrencyLifecycle(2*time.Second, got); errValidate == nil {
t.Fatal("ValidateCredentialConcurrencyLifecycle() error = nil, want timing invariant failure")
}
}
func TestValidateCredentialConcurrencyAcceptsHomeAuthoritativeHeartbeat(t *testing.T) {
cfg := (CredentialConcurrencyConfig{}).WithDefaults()
cfg.CPAHeartbeatTimeout = 20 * time.Second
if errValidate := ValidateCredentialConcurrency(cfg); errValidate != nil {
t.Fatalf("ValidateCredentialConcurrency() error = %v", errValidate)
}
if errValidate := ValidateCredentialConcurrencyLifecycle(20*time.Second, cfg); errValidate == nil {
t.Fatal("ValidateCredentialConcurrencyLifecycle() error = nil, want Home timing invariant failure")
}
}
func TestCredentialConcurrencyConfigDefaultsOnlyMissingFields(t *testing.T) {
tests := []struct {
name string
payload string
}{
{
name: "explicit zero revision",
payload: "credential-concurrency:\n" +
" lifecycle-config-revision: 0\n" +
" cpa-heartbeat-timeout: 3s\n" +
" cpa-cancel-bound: 5s\n" +
" reclaim-grace: 5s\n" +
" cleanup-interval: 5s\n",
},
{
name: "explicit zero duration",
payload: "credential-concurrency:\n" +
" lifecycle-config-revision: 1\n" +
" cpa-heartbeat-timeout: 0s\n" +
" cpa-cancel-bound: 5s\n" +
" reclaim-grace: 5s\n" +
" cleanup-interval: 5s\n",
},
{
name: "explicit null duration",
payload: "credential-concurrency:\n" +
" lifecycle-config-revision: 1\n" +
" cpa-heartbeat-timeout: null\n" +
" cpa-cancel-bound: 5s\n" +
" reclaim-grace: 5s\n" +
" cleanup-interval: 5s\n",
},
{
name: "negative observation barrier",
payload: "credential-concurrency:\n" +
" lifecycle-config-revision: 1\n" +
" observation-barrier-revision: -1\n" +
" cpa-heartbeat-timeout: 3s\n" +
" cpa-cancel-bound: 5s\n" +
" reclaim-grace: 5s\n" +
" cleanup-interval: 5s\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed, errParse := ParseConfigBytes([]byte(test.payload))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if errValidate := ValidateCredentialConcurrencyLifecycle(20*time.Second, parsed.CredentialConcurrency); errValidate == nil {
t.Fatal("ValidateCredentialConcurrencyLifecycle() error = nil, want explicit invalid lifecycle value rejection")
}
})
}
}
func TestCredentialConcurrencyConfigRejectsInvalidLimiter(t *testing.T) {
tests := []CredentialConcurrencyConfig{
{ReleaseFlushInterval: time.Second, ReleaseMaxBackoff: 500 * time.Millisecond, BusyRetryMin: time.Millisecond, BusyRetryMax: time.Millisecond, MaxLimit: 1},
{ReleaseFlushInterval: time.Millisecond, ReleaseMaxBackoff: time.Millisecond, BusyRetryMin: 1500 * time.Microsecond, BusyRetryMax: 2 * time.Millisecond, MaxLimit: 1},
{ReleaseFlushInterval: time.Millisecond, ReleaseMaxBackoff: time.Millisecond, BusyRetryMin: time.Millisecond, BusyRetryMax: time.Millisecond, MaxLimit: 1_000_001},
}
for _, cfg := range tests {
cfg.CPAHeartbeatTimeout = 3 * time.Second
cfg.CPACancelBound = 5 * time.Second
cfg.ReclaimGrace = 5 * time.Second
cfg.CleanupInterval = 5 * time.Second
if errValidate := ValidateCredentialConcurrencyLifecycle(20*time.Second, cfg); errValidate == nil {
t.Fatalf("ValidateCredentialConcurrencyLifecycle(%#v) error = nil", cfg)
}
}
}
func TestValidateCredentialConcurrencyLifecycleRejectsSafetyOverflow(t *testing.T) {
cfg := CredentialConcurrencyConfig{
LifecycleConfigRevision: 1,
CPAHeartbeatTimeout: time.Duration(1<<63 - 1),
CPACancelBound: time.Nanosecond,
ReclaimGrace: time.Second,
CleanupInterval: time.Second,
}
if errValidate := ValidateCredentialConcurrencyLifecycle(time.Second, cfg); errValidate == nil {
t.Fatal("ValidateCredentialConcurrencyLifecycle() error = nil, want overflow rejection")
}
}

View file

@ -0,0 +1,87 @@
package config
import (
"fmt"
"time"
)
const (
DefaultInFlightMaxPartBytes = 256 * 1024
DefaultInFlightMaxPartCount = 64
DefaultInFlightMaxRevisionBytes = 16 * 1024 * 1024
DefaultInFlightMaxAggregateGroups = 100000
DefaultInFlightMaxDetails = 10000
DefaultInFlightMaxStringBytes = 256
)
// CredentialInFlightConfig controls in-flight credential observation snapshots.
type CredentialInFlightConfig struct {
SnapshotInterval string `yaml:"snapshot-interval" json:"snapshot-interval"`
StaleAfter string `yaml:"stale-after" json:"stale-after"`
MaxPartBytes int `yaml:"max-part-bytes" json:"max-part-bytes"`
MaxPartCount int `yaml:"max-part-count" json:"max-part-count"`
MaxRevisionBytes int `yaml:"max-revision-bytes" json:"max-revision-bytes"`
MaxAggregateGroups int `yaml:"max-aggregate-groups" json:"max-aggregate-groups"`
MaxDetails int `yaml:"max-details" json:"max-details"`
MaxStringBytes int `yaml:"max-string-bytes" json:"max-string-bytes"`
StagingRetention string `yaml:"staging-retention" json:"staging-retention"`
}
// DefaultCredentialInFlightConfig returns the in-flight observation defaults.
func DefaultCredentialInFlightConfig() CredentialInFlightConfig {
return CredentialInFlightConfig{
SnapshotInterval: "2s",
StaleAfter: "10s",
MaxPartBytes: DefaultInFlightMaxPartBytes,
MaxPartCount: DefaultInFlightMaxPartCount,
MaxRevisionBytes: DefaultInFlightMaxRevisionBytes,
MaxAggregateGroups: DefaultInFlightMaxAggregateGroups,
MaxDetails: DefaultInFlightMaxDetails,
MaxStringBytes: DefaultInFlightMaxStringBytes,
StagingRetention: "1m",
}
}
// Durations parses and validates the in-flight observation durations.
func (c CredentialInFlightConfig) Durations() (time.Duration, time.Duration, time.Duration, error) {
snapshotInterval, errSnapshot := time.ParseDuration(c.SnapshotInterval)
if errSnapshot != nil || snapshotInterval <= 0 {
return 0, 0, 0, fmt.Errorf("credential-in-flight.snapshot-interval must be positive")
}
staleAfter, errStale := time.ParseDuration(c.StaleAfter)
if errStale != nil || staleAfter <= 0 || snapshotInterval > staleAfter/3 {
return 0, 0, 0, fmt.Errorf("credential-in-flight.stale-after must be at least three snapshot intervals")
}
stagingRetention, errRetention := time.ParseDuration(c.StagingRetention)
if errRetention != nil || stagingRetention <= 0 {
return 0, 0, 0, fmt.Errorf("credential-in-flight.staging-retention must be positive")
}
return snapshotInterval, staleAfter, stagingRetention, nil
}
// Validate verifies the in-flight observation bounds.
func (c CredentialInFlightConfig) Validate() error {
if _, _, _, errDurations := c.Durations(); errDurations != nil {
return errDurations
}
if c.MaxPartBytes < 1024 || c.MaxPartCount <= 0 || c.MaxPartCount > DefaultInFlightMaxPartCount {
return fmt.Errorf("credential-in-flight part bounds are invalid")
}
if c.MaxRevisionBytes < c.MaxPartBytes || c.MaxRevisionBytes > DefaultInFlightMaxRevisionBytes {
return fmt.Errorf("credential-in-flight.max-revision-bytes is outside hard bounds")
}
requiredParts := (c.MaxRevisionBytes + c.MaxPartBytes - 1) / c.MaxPartBytes
if requiredParts > c.MaxPartCount {
return fmt.Errorf("credential-in-flight.max-revision-bytes exceeds part capacity")
}
if c.MaxAggregateGroups <= 0 || c.MaxAggregateGroups > DefaultInFlightMaxAggregateGroups {
return fmt.Errorf("credential-in-flight.max-aggregate-groups is invalid")
}
if c.MaxDetails < 0 || c.MaxDetails > DefaultInFlightMaxDetails {
return fmt.Errorf("credential-in-flight.max-details is invalid")
}
if c.MaxStringBytes <= 0 || c.MaxStringBytes > DefaultInFlightMaxStringBytes {
return fmt.Errorf("credential-in-flight.max-string-bytes is invalid")
}
return nil
}

View file

@ -0,0 +1,234 @@
package config
import (
"bytes"
"encoding/json"
"errors"
"io"
"math"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func TestLoadConfigOptionalMissingFallbackAppliesCredentialInFlightDefaults(t *testing.T) {
cfg, errLoad := LoadConfigOptional(filepath.Join(t.TempDir(), "missing.yaml"), true)
if errLoad != nil {
t.Fatalf("LoadConfigOptional() error = %v", errLoad)
}
assertOptionalConfigFallback(t, cfg)
}
func TestLoadConfigOptionalEmptyFallbackAppliesCredentialInFlightDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, nil, 0o600); errWrite != nil {
t.Fatal(errWrite)
}
cfg, errLoad := LoadConfigOptional(configPath, true)
if errLoad != nil {
t.Fatalf("LoadConfigOptional() error = %v", errLoad)
}
assertOptionalConfigFallback(t, cfg)
}
func TestLoadConfigOptionalWhitespaceFallbackAppliesCredentialInFlightDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte(" \t\n\r "), 0o600); errWrite != nil {
t.Fatal(errWrite)
}
cfg, errLoad := LoadConfigOptional(configPath, true)
if errLoad != nil {
t.Fatalf("LoadConfigOptional() error = %v", errLoad)
}
assertOptionalConfigFallback(t, cfg)
}
func TestLoadConfigOptionalInvalidFallbackAppliesCredentialInFlightDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte(":"), 0o600); errWrite != nil {
t.Fatal(errWrite)
}
cfg, errLoad := LoadConfigOptional(configPath, true)
if errLoad != nil {
t.Fatalf("LoadConfigOptional() error = %v", errLoad)
}
assertOptionalConfigFallback(t, cfg)
}
func assertOptionalConfigFallback(t *testing.T, cfg *Config) {
t.Helper()
if cfg.CredentialInFlight != DefaultCredentialInFlightConfig() {
t.Fatalf("CredentialInFlight = %#v, want %#v", cfg.CredentialInFlight, DefaultCredentialInFlightConfig())
}
if errValidate := cfg.CredentialInFlight.Validate(); errValidate != nil {
t.Fatalf("CredentialInFlight.Validate() error = %v", errValidate)
}
if cfg.ErrorLogsMaxFiles != 0 || cfg.WebsocketAuth || cfg.CredentialConcurrency != (CredentialConcurrencyConfig{}) {
t.Fatalf("fallback config changed existing empty-config defaults: %#v", cfg)
}
}
func TestCredentialInFlightConfigContractFixture(t *testing.T) {
raw, errRead := os.ReadFile(filepath.Join("..", "home", "testdata", "credential_in_flight_contract.json"))
if errRead != nil {
t.Fatal(errRead)
}
fixture, errDecode := decodeCredentialInFlightConfigFixture(raw)
if errDecode != nil {
t.Fatal(errDecode)
}
if fixture.Config != DefaultCredentialInFlightConfig() {
t.Fatalf("default config = %#v, want %#v", DefaultCredentialInFlightConfig(), fixture.Config)
}
if errValidate := fixture.Config.Validate(); errValidate != nil {
t.Fatalf("Validate() error = %v", errValidate)
}
assertCredentialInFlightConfigFields(t)
assertRequiredJSONKeys(t, raw, []string{"config", "part", "overflow"})
assertRequiredJSONKeys(t, fixture.ConfigJSON, []string{"snapshot-interval", "stale-after", "max-part-bytes", "max-part-count", "max-revision-bytes", "max-aggregate-groups", "max-details", "max-string-bytes", "staging-retention"})
}
func TestCredentialInFlightConfigFixtureRejectsInvalidJSON(t *testing.T) {
raw, errRead := os.ReadFile(filepath.Join("..", "home", "testdata", "credential_in_flight_contract.json"))
if errRead != nil {
t.Fatal(errRead)
}
for _, test := range []struct {
name string
raw []byte
}{
{name: "unknown config field", raw: bytes.Replace(raw, []byte(`"snapshot-interval": "2s"`), []byte(`"snapshot-interval": "2s", "secret": "secret"`), 1)},
{name: "trailing JSON", raw: append(append([]byte{}, raw...), []byte(` {"config": {}}`)...)},
} {
t.Run(test.name, func(t *testing.T) {
if _, errDecode := decodeCredentialInFlightConfigFixture(test.raw); errDecode == nil {
t.Fatal("decodeCredentialInFlightConfigFixture() error = nil")
}
})
}
}
func TestCredentialInFlightConfigDurationBounds(t *testing.T) {
for _, test := range []struct {
name string
stale string
every string
valid bool
}{
{name: "exact three intervals", every: "1s", stale: "3s", valid: true},
{name: "below three intervals", every: "1s", stale: "2999999999ns", valid: false},
{name: "near duration maximum", every: time.Duration(math.MaxInt64 / 2).String(), stale: time.Duration(math.MaxInt64).String(), valid: false},
} {
t.Run(test.name, func(t *testing.T) {
cfg := DefaultCredentialInFlightConfig()
cfg.SnapshotInterval = test.every
cfg.StaleAfter = test.stale
errValidate := cfg.Validate()
if (errValidate == nil) != test.valid {
t.Fatalf("Validate() error = %v, want valid = %t", errValidate, test.valid)
}
})
}
}
func TestCredentialInFlightConfigRejectsUnsafeBounds(t *testing.T) {
cfg := DefaultCredentialInFlightConfig()
cfg.StaleAfter = "5s"
if errValidate := cfg.Validate(); errValidate == nil {
t.Fatal("Validate() error = nil, want stale-after error")
}
cfg = DefaultCredentialInFlightConfig()
cfg.MaxRevisionBytes = 16*1024*1024 + 1
if errValidate := cfg.Validate(); errValidate == nil {
t.Fatal("Validate() error = nil, want hard revision bound error")
}
cfg = DefaultCredentialInFlightConfig()
cfg.MaxPartBytes = math.MaxInt
if errValidate := cfg.Validate(); errValidate == nil {
t.Fatal("Validate() error = nil, want overflow-safe part bound error")
}
}
type credentialInFlightConfigFixture struct {
Config CredentialInFlightConfig `json:"config"`
ConfigJSON json.RawMessage `json:"-"`
}
func decodeCredentialInFlightConfigFixture(raw []byte) (credentialInFlightConfigFixture, error) {
var fixture credentialInFlightConfigFixture
var document struct {
Config json.RawMessage `json:"config"`
Part json.RawMessage `json:"part"`
Overflow json.RawMessage `json:"overflow"`
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if errDecode := decoder.Decode(&document); errDecode != nil {
return fixture, errDecode
}
if errDecode := decoder.Decode(&struct{}{}); errDecode == nil {
return fixture, errors.New("unexpected trailing JSON")
} else if errDecode != io.EOF {
return fixture, errDecode
}
decoder = json.NewDecoder(bytes.NewReader(document.Config))
decoder.DisallowUnknownFields()
if errDecode := decoder.Decode(&fixture.Config); errDecode != nil {
return fixture, errDecode
}
if errDecode := decoder.Decode(&struct{}{}); errDecode == nil {
return fixture, errors.New("unexpected trailing config JSON")
} else if errDecode != io.EOF {
return fixture, errDecode
}
fixture.ConfigJSON = document.Config
return fixture, nil
}
func assertCredentialInFlightConfigFields(t *testing.T) {
t.Helper()
assertOrderedJSONFields(t, reflect.TypeOf(CredentialInFlightConfig{}), []jsonField{
{name: "SnapshotInterval", tag: "snapshot-interval"},
{name: "StaleAfter", tag: "stale-after"},
{name: "MaxPartBytes", tag: "max-part-bytes"},
{name: "MaxPartCount", tag: "max-part-count"},
{name: "MaxRevisionBytes", tag: "max-revision-bytes"},
{name: "MaxAggregateGroups", tag: "max-aggregate-groups"},
{name: "MaxDetails", tag: "max-details"},
{name: "MaxStringBytes", tag: "max-string-bytes"},
{name: "StagingRetention", tag: "staging-retention"},
})
}
type jsonField struct {
name string
tag string
}
func assertOrderedJSONFields(t *testing.T, structType reflect.Type, want []jsonField) {
t.Helper()
if structType.NumField() != len(want) {
t.Fatalf("%s field count = %d, want %d", structType.Name(), structType.NumField(), len(want))
}
for index, expected := range want {
field := structType.Field(index)
if field.Name != expected.name || field.Tag.Get("json") != expected.tag {
t.Fatalf("%s field %d = (%q, %q), want (%q, %q)", structType.Name(), index, field.Name, field.Tag.Get("json"), expected.name, expected.tag)
}
}
}
func assertRequiredJSONKeys(t *testing.T, raw json.RawMessage, required []string) {
t.Helper()
var fields map[string]json.RawMessage
if errDecode := json.Unmarshal(raw, &fields); errDecode != nil {
t.Fatalf("json.Unmarshal() error = %v", errDecode)
}
for _, key := range required {
if _, ok := fields[key]; !ok {
t.Fatalf("required JSON key %q is missing", key)
}
}
}

View file

@ -0,0 +1,147 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// DisableImageGenerationMode is a four-state config value for disable-image-generation.
//
// It supports:
// - false: enabled
// - true: disabled everywhere (including /v1/images/* endpoints)
// - "chat": disabled for all non-images endpoints, but enabled for /v1/images/generations and /v1/images/edits
// - "passthrough": never inject and never strip image_generation on non-images endpoints
// (the client payload is forwarded unchanged); on /v1/images/* endpoints behave like "chat"
type DisableImageGenerationMode int
const (
DisableImageGenerationOff DisableImageGenerationMode = iota
DisableImageGenerationAll
DisableImageGenerationChat
DisableImageGenerationPassthrough
)
func (m DisableImageGenerationMode) String() string {
switch m {
case DisableImageGenerationOff:
return "false"
case DisableImageGenerationAll:
return "true"
case DisableImageGenerationChat:
return "chat"
case DisableImageGenerationPassthrough:
return "passthrough"
default:
return "false"
}
}
func (m DisableImageGenerationMode) MarshalYAML() (any, error) {
switch m {
case DisableImageGenerationAll:
return true, nil
case DisableImageGenerationChat:
return "chat", nil
case DisableImageGenerationPassthrough:
return "passthrough", nil
default:
return false, nil
}
}
func (m *DisableImageGenerationMode) UnmarshalYAML(value *yaml.Node) error {
mode, err := parseDisableImageGenerationNode(value)
if err != nil {
return err
}
*m = mode
return nil
}
func (m DisableImageGenerationMode) MarshalJSON() ([]byte, error) {
switch m {
case DisableImageGenerationAll:
return []byte("true"), nil
case DisableImageGenerationChat:
return json.Marshal("chat")
case DisableImageGenerationPassthrough:
return json.Marshal("passthrough")
default:
return []byte("false"), nil
}
}
func (m *DisableImageGenerationMode) UnmarshalJSON(data []byte) error {
mode, err := parseDisableImageGenerationJSON(data)
if err != nil {
return err
}
*m = mode
return nil
}
func parseDisableImageGenerationNode(value *yaml.Node) (DisableImageGenerationMode, error) {
if value == nil {
return DisableImageGenerationOff, nil
}
// First try a typed bool decode (covers unquoted true/false and YAML 1.1 bools).
var b bool
if err := value.Decode(&b); err == nil && value.Kind == yaml.ScalarNode && value.ShortTag() == "!!bool" {
if b {
return DisableImageGenerationAll, nil
}
return DisableImageGenerationOff, nil
}
// Fall back to string decoding (covers quoted "true"/"false" and "chat").
var s string
if err := value.Decode(&s); err != nil {
return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value")
}
return parseDisableImageGenerationString(s)
}
func parseDisableImageGenerationJSON(data []byte) (DisableImageGenerationMode, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return DisableImageGenerationOff, nil
}
// bool
var b bool
if err := json.Unmarshal(trimmed, &b); err == nil {
if b {
return DisableImageGenerationAll, nil
}
return DisableImageGenerationOff, nil
}
// string
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value")
}
return parseDisableImageGenerationString(s)
}
func parseDisableImageGenerationString(s string) (DisableImageGenerationMode, error) {
s = strings.TrimSpace(strings.ToLower(s))
switch s {
case "", "false", "0", "off", "no":
return DisableImageGenerationOff, nil
case "true", "1", "on", "yes":
return DisableImageGenerationAll, nil
case "chat":
return DisableImageGenerationChat, nil
case "passthrough":
return DisableImageGenerationPassthrough, nil
default:
return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value %q (allowed: true, false, chat, passthrough)", s)
}
}

View file

@ -0,0 +1,96 @@
package config
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func TestDisableImageGenerationMode_UnmarshalYAML(t *testing.T) {
type wrapper struct {
V DisableImageGenerationMode `yaml:"disable-image-generation"`
}
{
var w wrapper
if err := yaml.Unmarshal([]byte("disable-image-generation: false\n"), &w); err != nil {
t.Fatalf("unmarshal false: %v", err)
}
if w.V != DisableImageGenerationOff {
t.Fatalf("false => %v, want %v", w.V, DisableImageGenerationOff)
}
}
{
var w wrapper
if err := yaml.Unmarshal([]byte("disable-image-generation: true\n"), &w); err != nil {
t.Fatalf("unmarshal true: %v", err)
}
if w.V != DisableImageGenerationAll {
t.Fatalf("true => %v, want %v", w.V, DisableImageGenerationAll)
}
}
{
var w wrapper
if err := yaml.Unmarshal([]byte("disable-image-generation: chat\n"), &w); err != nil {
t.Fatalf("unmarshal chat: %v", err)
}
if w.V != DisableImageGenerationChat {
t.Fatalf("chat => %v, want %v", w.V, DisableImageGenerationChat)
}
}
{
var w wrapper
if err := yaml.Unmarshal([]byte("disable-image-generation: passthrough\n"), &w); err != nil {
t.Fatalf("unmarshal passthrough: %v", err)
}
if w.V != DisableImageGenerationPassthrough {
t.Fatalf("passthrough => %v, want %v", w.V, DisableImageGenerationPassthrough)
}
}
}
func TestDisableImageGenerationMode_UnmarshalJSON(t *testing.T) {
{
var v DisableImageGenerationMode
if err := json.Unmarshal([]byte("false"), &v); err != nil {
t.Fatalf("unmarshal false: %v", err)
}
if v != DisableImageGenerationOff {
t.Fatalf("false => %v, want %v", v, DisableImageGenerationOff)
}
}
{
var v DisableImageGenerationMode
if err := json.Unmarshal([]byte("true"), &v); err != nil {
t.Fatalf("unmarshal true: %v", err)
}
if v != DisableImageGenerationAll {
t.Fatalf("true => %v, want %v", v, DisableImageGenerationAll)
}
}
{
var v DisableImageGenerationMode
if err := json.Unmarshal([]byte(`"chat"`), &v); err != nil {
t.Fatalf("unmarshal chat: %v", err)
}
if v != DisableImageGenerationChat {
t.Fatalf("chat => %v, want %v", v, DisableImageGenerationChat)
}
}
{
var v DisableImageGenerationMode
if err := json.Unmarshal([]byte(`"passthrough"`), &v); err != nil {
t.Fatalf("unmarshal passthrough: %v", err)
}
if v != DisableImageGenerationPassthrough {
t.Fatalf("passthrough => %v, want %v", v, DisableImageGenerationPassthrough)
}
}
}

View file

@ -0,0 +1,35 @@
package config
import "testing"
func TestSanitizeGeminiKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) {
cfg := &Config{
GeminiKey: []GeminiKey{
{APIKey: ""}, // empty key without base URL, should be dropped
{APIKey: " "}, // whitespace key without base URL, should be dropped
{APIKey: "", BaseURL: "https://custom-gemini.example.com", Headers: map[string]string{"Header-A": "1"}},
{APIKey: "", BaseURL: "https://custom-gemini.example.com", Headers: map[string]string{"Header-B": "2"}},
{APIKey: "key-1", BaseURL: "https://custom-gemini.example.com"},
},
InteractionsKey: []GeminiKey{
{APIKey: ""}, // empty key without base URL, should be dropped
{APIKey: " "}, // whitespace key without base URL, should be dropped
{APIKey: "", BaseURL: "https://custom-interactions.example.com"},
},
}
cfg.SanitizeGeminiKeys()
cfg.SanitizeInteractionsKeys()
if len(cfg.GeminiKey) != 3 {
t.Fatalf("expected 3 GeminiKey entries, got %d", len(cfg.GeminiKey))
}
if cfg.GeminiKey[0].BaseURL != "https://custom-gemini.example.com" {
t.Fatalf("expected BaseURL https://custom-gemini.example.com, got %s", cfg.GeminiKey[0].BaseURL)
}
if len(cfg.InteractionsKey) != 1 {
t.Fatalf("expected 1 InteractionsKey entry, got %d", len(cfg.InteractionsKey))
}
if cfg.InteractionsKey[0].BaseURL != "https://custom-interactions.example.com" {
t.Fatalf("expected BaseURL https://custom-interactions.example.com, got %s", cfg.InteractionsKey[0].BaseURL)
}
}

View file

@ -0,0 +1,22 @@
package config
// HomeConfig stores runtime-only Home control plane settings from -home-jwt.
type HomeConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
NodeID string `yaml:"-" json:"-"`
Host string `yaml:"host" json:"-"`
Port int `yaml:"port" json:"-"`
DisableClusterDiscovery bool `yaml:"disable-cluster-discovery" json:"-"`
TLS HomeTLSConfig `yaml:"tls" json:"-"`
}
// HomeTLSConfig configures client-side TLS for the home Redis connection.
type HomeTLSConfig struct {
Enable bool `yaml:"enable" json:"-"`
ServerName string `yaml:"server-name" json:"-"`
InsecureSkipVerify bool `yaml:"insecure-skip-verify" json:"-"`
CACert string `yaml:"ca-cert" json:"-"`
ClientCert string `yaml:"-" json:"-"`
ClientKey string `yaml:"-" json:"-"`
UseTargetServerName bool `yaml:"-" json:"-"`
}

View file

@ -0,0 +1,46 @@
package config
import "testing"
func TestParseConfigBytesIgnoresHomeConfig(t *testing.T) {
cfg, err := ParseConfigBytes([]byte(`
home:
enabled: true
host: home.example.com
port: 444
disable-cluster-discovery: true
tls:
enable: true
server-name: home.example.com
ca-cert: C:/certs/ca.pem
insecure-skip-verify: true
`))
if err != nil {
t.Fatalf("ParseConfigBytes() error = %v", err)
}
if cfg.Home.Enabled {
t.Fatal("Home.Enabled = true, want false")
}
if cfg.Home.Host != "" {
t.Fatalf("Home.Host = %q, want empty", cfg.Home.Host)
}
if cfg.Home.Port != 0 {
t.Fatalf("Home.Port = %d, want 0", cfg.Home.Port)
}
if cfg.Home.DisableClusterDiscovery {
t.Fatal("Home.DisableClusterDiscovery = true, want false")
}
if cfg.Home.TLS.Enable {
t.Fatal("Home.TLS.Enable = true, want false")
}
if cfg.Home.TLS.ServerName != "" {
t.Fatalf("Home.TLS.ServerName = %q, want empty", cfg.Home.TLS.ServerName)
}
if cfg.Home.TLS.CACert != "" {
t.Fatalf("Home.TLS.CACert = %q, want empty", cfg.Home.TLS.CACert)
}
if cfg.Home.TLS.InsecureSkipVerify {
t.Fatal("Home.TLS.InsecureSkipVerify = true, want false")
}
}

View file

@ -0,0 +1,57 @@
package config
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func TestCodexModelIsCompatConfigDecoding(t *testing.T) {
const yamlConfig = `codex-api-key:
- models:
- name: deepseek-upstream
alias: deepseek-alias
is-compat: true
- name: native-upstream
alias: native-alias
`
const jsonConfig = `{"codex-api-key":[{"models":[{"name":"deepseek-upstream","alias":"deepseek-alias","is-compat":true},{"name":"native-upstream","alias":"native-alias"}]}]}`
for _, testCase := range []struct {
name string
decode func(*Config) error
}{
{
name: "YAML",
decode: func(cfg *Config) error {
return yaml.Unmarshal([]byte(yamlConfig), cfg)
},
},
{
name: "JSON",
decode: func(cfg *Config) error {
return json.Unmarshal([]byte(jsonConfig), cfg)
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
var cfg Config
if errDecode := testCase.decode(&cfg); errDecode != nil {
t.Fatalf("decode error: %v", errDecode)
}
if len(cfg.CodexKey) != 1 || len(cfg.CodexKey[0].Models) != 2 {
t.Fatalf("unexpected codex-api-key models: %+v", cfg.CodexKey)
}
if !cfg.CodexKey[0].Models[0].IsCompat {
t.Fatalf("Models[0].IsCompat = false, want true")
}
if cfg.CodexKey[0].Models[1].IsCompat {
t.Fatalf("Models[1].IsCompat = true, want default false")
}
if !cfg.CodexKey[0].Models[0].GetIsCompat() {
t.Fatalf("GetIsCompat() = false, want true")
}
})
}
}

View file

@ -0,0 +1,86 @@
package config
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func TestMaxContextLengthConfigDecoding(t *testing.T) {
const want = 1048576
const yamlConfig = `codex-api-key:
- models:
- name: codex-upstream
alias: codex-alias
max-context-length: 1048576
claude-api-key:
- models:
- name: claude-upstream
alias: claude-alias
max-context-length: 1048576
gemini-api-key:
- models:
- name: gemini-upstream
alias: gemini-alias
max-context-length: 1048576
interactions-api-key:
- models:
- name: interactions-upstream
alias: interactions-alias
max-context-length: 1048576
xai-api-key:
- models:
- name: xai-upstream
alias: xai-alias
max-context-length: 1048576
openai-compatibility:
- models:
- name: compat-upstream
alias: compat-alias
max-context-length: 1048576
`
const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","max-context-length":1048576}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","max-context-length":1048576}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","max-context-length":1048576}]}],"interactions-api-key":[{"models":[{"name":"interactions-upstream","alias":"interactions-alias","max-context-length":1048576}]}],"xai-api-key":[{"models":[{"name":"xai-upstream","alias":"xai-alias","max-context-length":1048576}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","max-context-length":1048576}]}]}`
for _, testCase := range []struct {
name string
decode func(*Config) error
}{
{
name: "YAML",
decode: func(cfg *Config) error {
return yaml.Unmarshal([]byte(yamlConfig), cfg)
},
},
{
name: "JSON",
decode: func(cfg *Config) error {
return json.Unmarshal([]byte(jsonConfig), cfg)
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
var cfg Config
if errDecode := testCase.decode(&cfg); errDecode != nil {
t.Fatalf("decode config: %v", errDecode)
}
models := []struct {
name string
got int
}{
{name: "codex", got: cfg.CodexKey[0].Models[0].MaxContextLength},
{name: "claude", got: cfg.ClaudeKey[0].Models[0].MaxContextLength},
{name: "gemini", got: cfg.GeminiKey[0].Models[0].MaxContextLength},
{name: "interactions", got: cfg.InteractionsKey[0].Models[0].MaxContextLength},
{name: "xai", got: cfg.XAIKey[0].Models[0].MaxContextLength},
{name: "openai compatibility", got: cfg.OpenAICompatibility[0].Models[0].MaxContextLength},
}
for _, model := range models {
if model.got != want {
t.Errorf("%s max-context-length = %d, want %d", model.name, model.got, want)
}
}
})
}
}

View file

@ -0,0 +1,86 @@
package config
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func TestModelDisplayNameConfigDecoding(t *testing.T) {
const yamlConfig = `codex-api-key:
- models:
- name: codex-upstream
alias: codex-alias
display-name: Codex Name
xai-api-key:
- models:
- name: xai-upstream
alias: xai-alias
display-name: xAI Name
claude-api-key:
- models:
- name: claude-upstream
alias: claude-alias
display-name: Claude Name
gemini-api-key:
- models:
- name: gemini-upstream
alias: gemini-alias
display-name: Gemini Name
vertex-api-key:
- models:
- name: vertex-upstream
alias: vertex-alias
display-name: Vertex Name
openai-compatibility:
- models:
- name: compat-upstream
alias: compat-alias
display-name: Compatibility Name
`
const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","display-name":"Codex Name"}]}],"xai-api-key":[{"models":[{"name":"xai-upstream","alias":"xai-alias","display-name":"xAI Name"}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","display-name":"Claude Name"}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","display-name":"Gemini Name"}]}],"vertex-api-key":[{"models":[{"name":"vertex-upstream","alias":"vertex-alias","display-name":"Vertex Name"}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","display-name":"Compatibility Name"}]}]}`
for _, tt := range []struct {
name string
decode func(*Config) error
}{
{
name: "YAML",
decode: func(cfg *Config) error {
return yaml.Unmarshal([]byte(yamlConfig), cfg)
},
},
{
name: "JSON",
decode: func(cfg *Config) error {
return json.Unmarshal([]byte(jsonConfig), cfg)
},
},
} {
t.Run(tt.name, func(t *testing.T) {
var cfg Config
if errDecode := tt.decode(&cfg); errDecode != nil {
t.Fatalf("decode config: %v", errDecode)
}
if got := cfg.CodexKey[0].Models[0].DisplayName; got != "Codex Name" {
t.Fatalf("Codex display name = %q", got)
}
if got := cfg.XAIKey[0].Models[0].DisplayName; got != "xAI Name" {
t.Fatalf("xAI display name = %q", got)
}
if got := cfg.ClaudeKey[0].Models[0].DisplayName; got != "Claude Name" {
t.Fatalf("Claude display name = %q", got)
}
if got := cfg.GeminiKey[0].Models[0].DisplayName; got != "Gemini Name" {
t.Fatalf("Gemini display name = %q", got)
}
if got := cfg.VertexCompatAPIKey[0].Models[0].DisplayName; got != "Vertex Name" {
t.Fatalf("Vertex display name = %q", got)
}
if got := cfg.OpenAICompatibility[0].Models[0].DisplayName; got != "Compatibility Name" {
t.Fatalf("OpenAI compatibility display name = %q", got)
}
})
}
}

View file

@ -0,0 +1,56 @@
package config
import "testing"
func TestSanitizeOAuthModelAlias_PreservesOptionalFields(t *testing.T) {
cfg := &Config{
OAuthModelAlias: map[string][]OAuthModelAlias{
" CoDeX ": {
{Name: " gpt-5 ", Alias: " g5 ", Fork: true, DisplayName: " GPT Five ", ForceMapping: true},
{Name: "gpt-6", Alias: "g6"},
},
},
}
cfg.SanitizeOAuthModelAlias()
aliases := cfg.OAuthModelAlias["codex"]
if len(aliases) != 2 {
t.Fatalf("expected 2 sanitized aliases, got %d", len(aliases))
}
if aliases[0].Name != "gpt-5" || aliases[0].Alias != "g5" || !aliases[0].Fork || aliases[0].DisplayName != "GPT Five" || !aliases[0].ForceMapping {
t.Fatalf("unexpected sanitized first alias: %+v", aliases[0])
}
if aliases[1].Name != "gpt-6" || aliases[1].Alias != "g6" || aliases[1].Fork || aliases[1].DisplayName != "" || aliases[1].ForceMapping {
t.Fatalf("unexpected sanitized second alias: %+v", aliases[1])
}
}
func TestSanitizeOAuthModelAlias_AllowsMultipleAliasesForSameName(t *testing.T) {
cfg := &Config{
OAuthModelAlias: map[string][]OAuthModelAlias{
"antigravity": {
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
},
},
}
cfg.SanitizeOAuthModelAlias()
aliases := cfg.OAuthModelAlias["antigravity"]
expected := []OAuthModelAlias{
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true},
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true},
{Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true},
}
if len(aliases) != len(expected) {
t.Fatalf("expected %d sanitized aliases, got %d", len(expected), len(aliases))
}
for i, exp := range expected {
if aliases[i].Name != exp.Name || aliases[i].Alias != exp.Alias || aliases[i].Fork != exp.Fork {
t.Fatalf("expected alias %d to be name=%q alias=%q fork=%v, got name=%q alias=%q fork=%v", i, exp.Name, exp.Alias, exp.Fork, aliases[i].Name, aliases[i].Alias, aliases[i].Fork)
}
}
}

View file

@ -0,0 +1,115 @@
package config
import (
"testing"
)
func TestParseConfigOAuthRequestScopedErrors(t *testing.T) {
const yamlConfig = `
oauth-request-scoped-errors:
vertex:
- status: 400
match:
- "maximum_context_length"
- "context_length_exceeded"
match-regexr:
- "maximum_context_length$"
- "^context_length_exceeded"
action: "stop"
aistudio:
- status: 400
match:
- "invalid_argument"
action: "continue"
antigravity:
- status: 500
match:
- "internal_server_error"
action: "stop-and-cooldown"
claude:
- status: 429
match:
- "rate_limit"
action: "continue-and-cooldown"
codex:
- status: 400
match:
- "context_window_exceeded"
action: "stop"
kimi:
- status: 400
match:
- "length_limit"
action: "stop"
xai:
- status: 400
match:
- "max_tokens_exceeded"
action: "stop"
`
cfg, err := ParseConfigBytes([]byte(yamlConfig))
if err != nil {
t.Fatalf("ParseConfigFromBytes failed: %v", err)
}
if len(cfg.OAuthRequestScopedErrors) != 7 {
t.Fatalf("cfg.OAuthRequestScopedErrors len = %d, want 7", len(cfg.OAuthRequestScopedErrors))
}
vertexRules, ok := cfg.OAuthRequestScopedErrors["vertex"]
if !ok || len(vertexRules) != 1 {
t.Fatalf("vertex rules missing or len != 1: %#v", vertexRules)
}
rule := vertexRules[0]
if rule.Status != 400 || rule.Action != "stop" {
t.Errorf("unexpected vertex rule: %+v", rule)
}
if len(rule.Match) != 2 || len(rule.MatchRegexr) != 2 {
t.Errorf("unexpected vertex match len: %+v", rule)
}
}
func TestSanitizeOAuthRequestScopedErrors(t *testing.T) {
cfg := &Config{
OAuthRequestScopedErrors: map[string][]RequestScopedErrorRule{
" Vertex ": {
{
Status: 400,
Match: []string{" context_length ", ""},
MatchRegexr: []string{" ^error.* ", ""},
Action: " STOP ",
},
{
Status: 0, // invalid status
Match: []string{"foo"},
Action: "stop",
},
{
Status: 400, // missing match / action
},
},
" empty-channel ": {},
},
}
cfg.SanitizeOAuthRequestScopedErrors()
if len(cfg.OAuthRequestScopedErrors) != 1 {
t.Fatalf("expected 1 sanitized channel, got %d", len(cfg.OAuthRequestScopedErrors))
}
rules := cfg.OAuthRequestScopedErrors["vertex"]
if len(rules) != 1 {
t.Fatalf("expected 1 rule for vertex, got %d", len(rules))
}
if rules[0].Status != 400 || rules[0].Action != "stop" {
t.Errorf("unexpected sanitized rule: %+v", rules[0])
}
if len(rules[0].Match) != 1 || rules[0].Match[0] != "context_length" {
t.Errorf("unexpected sanitized match: %+v", rules[0].Match)
}
if len(rules[0].MatchRegexr) != 1 || rules[0].MatchRegexr[0] != "^error.*" {
t.Errorf("unexpected sanitized regexr: %+v", rules[0].MatchRegexr)
}
}

View file

@ -0,0 +1,106 @@
package config
import (
"fmt"
"strings"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
// ParseConfigBytes parses a YAML configuration payload into Config and applies the same
// in-memory normalizations as LoadConfigOptional, without persisting any changes to disk.
func ParseConfigBytes(data []byte) (*Config, error) {
if len(data) == 0 {
return nil, fmt.Errorf("config payload is empty")
}
if errValidate := validateCredentialWeightYAML(data); errValidate != nil {
return nil, errValidate
}
var cfg Config
// Keep defaults aligned with LoadConfigOptional.
cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
cfg.LoggingToFile = false
cfg.LogsMaxTotalSizeMB = 0
cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false
cfg.RedisUsageQueueRetentionSeconds = 60
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.CredentialInFlight = DefaultCredentialInFlightConfig()
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config payload: %w", err)
}
cfg.CredentialConcurrency = cfg.CredentialConcurrency.WithDefaults()
if errValidate := cfg.CredentialInFlight.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateCredentialWeights(); errValidate != nil {
return nil, errValidate
}
// Hash remote management key if plaintext is detected (nested), but do NOT persist.
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
hashed, errHash := bcrypt.GenerateFromPassword([]byte(cfg.RemoteManagement.SecretKey), bcrypt.DefaultCost)
if errHash != nil {
return nil, fmt.Errorf("hash remote management key: %w", errHash)
}
cfg.RemoteManagement.SecretKey = string(hashed)
}
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
if cfg.Pprof.Addr == "" {
cfg.Pprof.Addr = DefaultPprofAddr
}
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
if cfg.ErrorLogsMaxFiles < 0 {
cfg.ErrorLogsMaxFiles = 10
}
if cfg.RedisUsageQueueRetentionSeconds <= 0 {
cfg.RedisUsageQueueRetentionSeconds = 60
} else if cfg.RedisUsageQueueRetentionSeconds > 3600 {
log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600")
cfg.RedisUsageQueueRetentionSeconds = 3600
}
if cfg.MaxRetryCredentials < 0 {
cfg.MaxRetryCredentials = 0
}
cfg.NormalizePluginsConfig()
if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled {
return nil, errResolvePluginsDir
}
// Apply the same sanitization pipeline.
cfg.SanitizeGeminiKeys()
cfg.SanitizeInteractionsKeys()
cfg.SanitizeVertexCompatKeys()
cfg.SanitizeCodexKeys()
cfg.SanitizeXAIKeys()
cfg.SanitizeCodexHeaderDefaults()
cfg.SanitizeClaudeHeaderDefaults()
cfg.SanitizeClaudeKeys()
cfg.SanitizeOpenAICompatibility()
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
cfg.SanitizeOAuthModelAlias()
cfg.SanitizeOAuthRequestScopedErrors()
cfg.SanitizePayloadRules()
return &cfg, nil
}

View file

@ -0,0 +1,256 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestParseConfigBytes_PluginsDefaults(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins: {}
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if cfg.Plugins.Enabled {
t.Fatal("Plugins.Enabled = true, want false")
}
if cfg.Plugins.Dir != "plugins" {
t.Fatalf("Plugins.Dir = %q, want plugins", cfg.Plugins.Dir)
}
if cfg.Plugins.Configs == nil {
t.Fatal("Plugins.Configs = nil, want empty map")
}
if len(cfg.Plugins.Configs) != 0 {
t.Fatalf("len(Plugins.Configs) = %d, want 0", len(cfg.Plugins.Configs))
}
}
func TestParseConfigBytes_PluginsDirExpandsLeadingTilde(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
dir: "~/.cli-proxy-api/plugins"
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
want := filepath.Join(homeDir, ".cli-proxy-api", "plugins")
if cfg.Plugins.Dir != want {
t.Fatalf("Plugins.Dir = %q, want %q", cfg.Plugins.Dir, want)
}
}
func TestLoadConfig_PluginsDirExpandsLeadingTilde(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte("plugins:\n dir: \"~/.cli-proxy-api/plugins\"\n"), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg, errLoad := LoadConfig(configPath)
if errLoad != nil {
t.Fatalf("LoadConfig() error = %v", errLoad)
}
want := filepath.Join(homeDir, ".cli-proxy-api", "plugins")
if cfg.Plugins.Dir != want {
t.Fatalf("Plugins.Dir = %q, want %q", cfg.Plugins.Dir, want)
}
}
func TestParseConfigBytes_PluginStoreSources(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
store-sources:
- " https://community.example/registry.json "
- ""
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.Plugins.StoreSources) != 1 {
t.Fatalf("Plugins.StoreSources len = %d, want 1", len(cfg.Plugins.StoreSources))
}
source := cfg.Plugins.StoreSources[0]
if source != "https://community.example/registry.json" {
t.Fatalf("Plugins.StoreSources[0] = %#v", source)
}
}
func TestParseConfigBytes_PluginStoreAuth(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
store-auth:
- match: " https://plugins.example.com/ "
apply-to: ["registry", "artifact", "registry"]
type: bearer
token-env: " CLIPROXY_PLUGIN_STORE_TOKEN "
- match: ""
type: bearer
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.Plugins.StoreAuth) != 1 {
t.Fatalf("Plugins.StoreAuth len = %d, want 1", len(cfg.Plugins.StoreAuth))
}
auth := cfg.Plugins.StoreAuth[0]
if auth.Match != "https://plugins.example.com/" || auth.Type != "bearer" || auth.TokenEnv != "CLIPROXY_PLUGIN_STORE_TOKEN" {
t.Fatalf("Plugins.StoreAuth[0] = %#v", auth)
}
if len(auth.ApplyTo) != 2 || auth.ApplyTo[0] != "registry" || auth.ApplyTo[1] != "artifact" {
t.Fatalf("Plugins.StoreAuth[0].ApplyTo = %#v", auth.ApplyTo)
}
}
func TestParseConfigBytes_PluginAuthRevision(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte("plugins:\n auth-revision: 42\n"))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if cfg.Plugins.AuthRevision != 42 {
t.Fatalf("Plugins.AuthRevision = %d, want 42", cfg.Plugins.AuthRevision)
}
}
func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
configs:
sample: {}
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
plugin, ok := cfg.Plugins.Configs["sample"]
if !ok {
t.Fatal("Plugins.Configs[\"sample\"] missing")
}
if plugin.Enabled == nil {
t.Fatal("Plugin.Enabled = nil, want false pointer")
}
if *plugin.Enabled {
t.Fatal("Plugin.Enabled = true, want false")
}
if plugin.Priority != 0 {
t.Fatalf("Plugin.Priority = %d, want 0", plugin.Priority)
}
raw, errMarshal := yaml.Marshal(&plugin.Raw)
if errMarshal != nil {
t.Fatalf("yaml.Marshal(Raw) error = %v", errMarshal)
}
rawText := string(raw)
if strings.Contains(rawText, "enabled:") {
t.Fatalf("Raw YAML contains enabled default:\n%s", rawText)
}
if strings.Contains(rawText, "priority:") {
t.Fatalf("Raw YAML contains priority default:\n%s", rawText)
}
marshaled, errMarshalPlugin := yaml.Marshal(plugin)
if errMarshalPlugin != nil {
t.Fatalf("yaml.Marshal(plugin) error = %v", errMarshalPlugin)
}
marshaledText := string(marshaled)
if strings.Contains(marshaledText, "enabled:") {
t.Fatalf("Plugin YAML contains enabled default:\n%s", marshaledText)
}
if strings.Contains(marshaledText, "priority:") {
t.Fatalf("Plugin YAML contains priority default:\n%s", marshaledText)
}
}
func TestSaveConfigPreserveComments_PrunesDefaultPluginsDir(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte("debug: true\n"), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg := &Config{
Debug: true,
Plugins: PluginsConfig{
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
data, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
text := string(data)
if strings.Contains(text, "plugins:") {
t.Fatalf("saved config contains plugins default section:\n%s", text)
}
if strings.Contains(text, "dir: plugins") {
t.Fatalf("saved config contains default plugins dir:\n%s", text)
}
}
func TestParseConfigBytes_PluginInstanceRawYAML(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
enabled: true
dir: custom-plugins
configs:
sample:
enabled: false
priority: 7
config1: value1
config2:
nested: value2
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
plugin, ok := cfg.Plugins.Configs["sample"]
if !ok {
t.Fatal("Plugins.Configs[\"sample\"] missing")
}
if plugin.Enabled == nil {
t.Fatal("Plugin.Enabled = nil, want false pointer")
}
if *plugin.Enabled {
t.Fatal("Plugin.Enabled = true, want false")
}
if plugin.Priority != 7 {
t.Fatalf("Plugin.Priority = %d, want 7", plugin.Priority)
}
raw, errMarshal := yaml.Marshal(&plugin.Raw)
if errMarshal != nil {
t.Fatalf("yaml.Marshal(Raw) error = %v", errMarshal)
}
rawText := string(raw)
for _, want := range []string{
"enabled: false",
"priority: 7",
"config1: value1",
"config2:",
"nested: value2",
} {
if !strings.Contains(rawText, want) {
t.Fatalf("Raw YAML missing %q in:\n%s", want, rawText)
}
}
}

View file

@ -0,0 +1,46 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const defaultPluginsDir = "plugins"
// ResolvePluginsDir normalizes the plugin directory for consistent use throughout the app.
// It expands a leading tilde (~) to the user's home directory and defaults empty values to plugins.
func ResolvePluginsDir(pluginsDir string) (string, error) {
pluginsDir = strings.TrimSpace(pluginsDir)
if pluginsDir == "" {
pluginsDir = defaultPluginsDir
}
if strings.HasPrefix(pluginsDir, "~") {
homeDir, errUserHomeDir := os.UserHomeDir()
if errUserHomeDir != nil {
return "", fmt.Errorf("resolve plugins directory: %w", errUserHomeDir)
}
remainder := strings.TrimPrefix(pluginsDir, "~")
remainder = strings.TrimLeft(remainder, "/\\")
if remainder == "" {
return filepath.Clean(homeDir), nil
}
normalized := strings.ReplaceAll(remainder, "\\", "/")
return filepath.Clean(filepath.Join(homeDir, filepath.FromSlash(normalized))), nil
}
return filepath.Clean(pluginsDir), nil
}
// ResolvePluginsDir resolves and stores the effective plugin directory.
func (cfg *Config) ResolvePluginsDir() error {
if cfg == nil {
return nil
}
pluginsDir, errResolvePluginsDir := ResolvePluginsDir(cfg.Plugins.Dir)
if errResolvePluginsDir != nil {
return errResolvePluginsDir
}
cfg.Plugins.Dir = pluginsDir
return nil
}

View file

@ -0,0 +1,73 @@
package config
import "testing"
func TestParseConfigBytesRequestRetry(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
gemini-api-key:
- api-key: "gemini-zero"
request-retry: 0
- api-key: "gemini-unset"
interactions-api-key:
- api-key: "interactions-two"
request-retry: 2
codex-api-key:
- api-key: "codex-neg"
base-url: "https://codex.example.com"
request-retry: -1
xai-api-key:
- api-key: "xai-zero"
base-url: "https://api.x.ai/v1"
request-retry: 0
claude-api-key:
- api-key: "claude-three"
request-retry: 3
openai-compatibility:
- name: "compat"
base-url: "https://compat.example.com/v1"
request-retry: 0
api-key-entries:
- api-key: "compat-key"
vertex-api-key:
- api-key: "vertex-four"
request-retry: 4
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.GeminiKey) != 2 {
t.Fatalf("gemini-api-key count = %d, want 2", len(cfg.GeminiKey))
}
if cfg.GeminiKey[0].RequestRetry == nil || *cfg.GeminiKey[0].RequestRetry != 0 {
t.Fatalf("gemini[0].request-retry = %v, want 0", cfg.GeminiKey[0].RequestRetry)
}
if cfg.GeminiKey[1].RequestRetry != nil {
t.Fatalf("gemini[1].request-retry = %v, want unset", cfg.GeminiKey[1].RequestRetry)
}
if len(cfg.InteractionsKey) != 1 || cfg.InteractionsKey[0].RequestRetry == nil || *cfg.InteractionsKey[0].RequestRetry != 2 {
t.Fatalf("interactions[0].request-retry = %v, want 2", valueOrNil(cfg.InteractionsKey))
}
if len(cfg.CodexKey) != 1 || cfg.CodexKey[0].RequestRetry == nil || *cfg.CodexKey[0].RequestRetry != -1 {
t.Fatalf("codex[0].request-retry = %v, want -1", valueOrNil(cfg.CodexKey))
}
if len(cfg.XAIKey) != 1 || cfg.XAIKey[0].RequestRetry == nil || *cfg.XAIKey[0].RequestRetry != 0 {
t.Fatalf("xai[0].request-retry = %v, want 0", valueOrNil(cfg.XAIKey))
}
if len(cfg.ClaudeKey) != 1 || cfg.ClaudeKey[0].RequestRetry == nil || *cfg.ClaudeKey[0].RequestRetry != 3 {
t.Fatalf("claude[0].request-retry = %v, want 3", valueOrNil(cfg.ClaudeKey))
}
if len(cfg.OpenAICompatibility) != 1 || cfg.OpenAICompatibility[0].RequestRetry == nil || *cfg.OpenAICompatibility[0].RequestRetry != 0 {
t.Fatalf("openai-compatibility[0].request-retry = %v, want 0", cfg.OpenAICompatibility[0].RequestRetry)
}
if len(cfg.VertexCompatAPIKey) != 1 || cfg.VertexCompatAPIKey[0].RequestRetry == nil || *cfg.VertexCompatAPIKey[0].RequestRetry != 4 {
t.Fatalf("vertex[0].request-retry = %v, want 4", cfg.VertexCompatAPIKey[0].RequestRetry)
}
}
func valueOrNil[T any](items []T) any {
if len(items) == 0 {
return nil
}
return items[0]
}

View file

@ -0,0 +1,123 @@
package config
import (
"testing"
)
func TestParseConfigRequestScopedErrors(t *testing.T) {
const yamlConfig = `
gemini-api-key:
- api-key: gemini-key-1
request-scoped-errors:
- status: 400
match:
- "maximum_context_length"
- "context_length_exceeded"
match-regexr:
- "maximum_context_length$"
- "^context_length_exceeded"
action: stop
interactions-api-key:
- api-key: interactions-key-1
request-scoped-errors:
- status: 400
match:
- "invalid_argument"
action: continue
codex-api-key:
- api-key: codex-key-1
base-url: https://api.openai.com/v1
request-scoped-errors:
- status: 400
match:
- "context_window_exceeded"
action: stop-and-cooldown
xai-api-key:
- api-key: xai-key-1
base-url: https://api.x.ai/v1
request-scoped-errors:
- status: 500
match:
- "rate_limit_exceeded"
action: continue-and-cooldown
claude-api-key:
- api-key: claude-key-1
request-scoped-errors:
- status: 400
match:
- "prompt is too long"
action: stop
openai-compatibility:
- name: test-openai-compat
base-url: https://api.openai.compat/v1
api-key-entries:
- api-key: compat-key-1
request-scoped-errors:
- status: 400
match:
- maximum_context_length
- context_length_exceeded
match-regexr:
- "maximum_context_length$"
- "^context_length_exceeded"
action: stop
`
cfg, errParse := ParseConfigBytes([]byte(yamlConfig))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.GeminiKey) != 1 || len(cfg.GeminiKey[0].RequestScopedErrors) != 1 {
t.Fatalf("gemini[0].request-scoped-errors len = %d, want 1", len(cfg.GeminiKey[0].RequestScopedErrors))
}
gRule := cfg.GeminiKey[0].RequestScopedErrors[0]
if gRule.Status != 400 || len(gRule.Match) != 2 || len(gRule.MatchRegexr) != 2 || gRule.Action != "stop" {
t.Fatalf("unexpected gemini rule: %+v", gRule)
}
if len(cfg.InteractionsKey) != 1 || len(cfg.InteractionsKey[0].RequestScopedErrors) != 1 {
t.Fatalf("interactions[0].request-scoped-errors len = %d, want 1", len(cfg.InteractionsKey[0].RequestScopedErrors))
}
iRule := cfg.InteractionsKey[0].RequestScopedErrors[0]
if iRule.Status != 400 || len(iRule.Match) != 1 || iRule.Action != "continue" {
t.Fatalf("unexpected interactions rule: %+v", iRule)
}
if len(cfg.CodexKey) != 1 || len(cfg.CodexKey[0].RequestScopedErrors) != 1 {
t.Fatalf("codex[0].request-scoped-errors len = %d, want 1", len(cfg.CodexKey[0].RequestScopedErrors))
}
codexRule := cfg.CodexKey[0].RequestScopedErrors[0]
if codexRule.Status != 400 || codexRule.Action != "stop-and-cooldown" {
t.Fatalf("unexpected codex rule: %+v", codexRule)
}
if len(cfg.XAIKey) != 1 || len(cfg.XAIKey[0].RequestScopedErrors) != 1 {
t.Fatalf("xai[0].request-scoped-errors len = %d, want 1", len(cfg.XAIKey[0].RequestScopedErrors))
}
xaiRule := cfg.XAIKey[0].RequestScopedErrors[0]
if xaiRule.Status != 500 || xaiRule.Action != "continue-and-cooldown" {
t.Fatalf("unexpected xai rule: %+v", xaiRule)
}
if len(cfg.ClaudeKey) != 1 || len(cfg.ClaudeKey[0].RequestScopedErrors) != 1 {
t.Fatalf("claude[0].request-scoped-errors len = %d, want 1", len(cfg.ClaudeKey[0].RequestScopedErrors))
}
claudeRule := cfg.ClaudeKey[0].RequestScopedErrors[0]
if claudeRule.Status != 400 || claudeRule.Action != "stop" {
t.Fatalf("unexpected claude rule: %+v", claudeRule)
}
if len(cfg.OpenAICompatibility) != 1 || len(cfg.OpenAICompatibility[0].RequestScopedErrors) != 1 {
t.Fatalf("openai-compatibility[0].request-scoped-errors len = %d, want 1", len(cfg.OpenAICompatibility[0].RequestScopedErrors))
}
compatRule := cfg.OpenAICompatibility[0].RequestScopedErrors[0]
if compatRule.Status != 400 || len(compatRule.Match) != 2 || len(compatRule.MatchRegexr) != 2 || compatRule.Action != "stop" {
t.Fatalf("unexpected openai-compatibility rule: %+v", compatRule)
}
}

View file

@ -0,0 +1,82 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
package config
// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
// DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed.
//
// Supported values:
// - false (default): image_generation is enabled everywhere (normal behavior).
// - true: image_generation is disabled everywhere. The server stops injecting it, removes it from request payloads,
// and returns 404 for /v1/images/generations and /v1/images/edits.
// - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions),
// while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there.
// - "passthrough": do not modify the tool list on non-images endpoints — keep image_generation if the client
// sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat".
DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"`
// GPTImage2BaseModel sets the base (mainline) model used by the legacy hosted
// image_generation tool path when a Codex image request is not proxied directly
// through the Image API.
//
// The value must start with "gpt-" (case-insensitive). If empty or invalid, the
// default base model ("gpt-5.4-mini") is used.
GPTImage2BaseModel string `yaml:"gpt-image-2-base-model,omitempty" json:"gpt-image-2-base-model,omitempty"`
// VideoResultAuthCacheTTL controls how long video IDs stay pinned to the credential
// that created them. Accepts duration strings like "30m" or "3h".
// Empty or invalid values use the default 3h.
VideoResultAuthCacheTTL string `yaml:"video-result-auth-cache-ttl,omitempty" json:"video-result-auth-cache-ttl,omitempty"`
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
// credentials as well.
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
// RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"`
// CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers.
CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"`
// ClaudeCode configures Claude Code compatibility behavior.
ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"`
// APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"`
// PassthroughHeaders controls whether upstream response headers are forwarded to downstream clients.
// Default is false (disabled).
PassthroughHeaders bool `yaml:"passthrough-headers" json:"passthrough-headers"`
// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.
// <= 0 disables keep-alives. Value is in seconds.
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`
}
// ClaudeCodeConfig configures Claude Code compatibility behavior.
type ClaudeCodeConfig struct {
// DisableCloakingModelList disables model ID cloaking in Anthropic model list responses.
DisableCloakingModelList bool `yaml:"disable-cloaking-model-list" json:"disable-cloaking-model-list"`
}
// StreamingConfig holds server streaming behavior configuration.
type StreamingConfig struct {
// KeepAliveSeconds controls how often the server emits SSE heartbeats (": keep-alive\n\n").
// <= 0 disables keep-alives. Default is 0.
KeepAliveSeconds int `yaml:"keepalive-seconds,omitempty" json:"keepalive-seconds,omitempty"`
// BootstrapRetries controls how many times the server may retry a streaming request before any bytes are sent,
// to allow auth rotation / transient recovery.
// <= 0 disables bootstrap retries. Default is 0.
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`
}

View file

@ -0,0 +1,130 @@
package config
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
// VertexCompatKey represents the configuration for Vertex AI-compatible API keys.
// This supports third-party services that use Vertex AI-style endpoint paths
// (/publishers/google/models/{model}:streamGenerateContent) but authenticate
// with simple API keys instead of Google Cloud service account credentials.
//
// Example services: zenmux.ai and similar Vertex-compatible providers.
type VertexCompatKey struct {
// APIKey is the authentication key for accessing the Vertex-compatible API.
// Maps to the x-goog-api-key header.
APIKey string `yaml:"api-key" json:"api-key"`
// Priority controls selection preference when multiple credentials match.
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Weight controls proportional selection under weighted-round-robin.
// An omitted value defaults to 1; non-positive values exclude this credential; maximum 1,000,000.
Weight *int `yaml:"weight,omitempty" json:"weight,omitempty"`
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL optionally overrides the Vertex-compatible API endpoint.
// The executor will append "/v1/publishers/google/models/{model}:action" to this.
// When empty, requests fall back to the default Vertex API base URL.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
// ProxyURL optionally overrides the global proxy for this API key.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
// Headers optionally adds extra HTTP headers for requests sent with this key.
// Commonly used for cookies, user-agent, and other authentication headers.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// Models defines the model configurations including aliases for routing.
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
// DisableCooling overrides the global cooling policy for this credential when set.
// True disables auth/model cooldowns; false explicitly enables them.
DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
// RequestRetry optionally overrides the global request-retry for this credential.
// Nil or a negative value means "use the global request-retry". 0 disables additional retry rounds.
RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"`
}
func (k VertexCompatKey) GetAPIKey() string { return k.APIKey }
func (k VertexCompatKey) GetBaseURL() string { return k.BaseURL }
func (k VertexCompatKey) GetPrefix() string { return k.Prefix }
func (k VertexCompatKey) GetProxyURL() string { return k.ProxyURL }
// VertexCompatModel represents a model configuration for Vertex compatibility,
// including the actual model name and its alias for API routing.
type VertexCompatModel struct {
// Name is the actual model name used by the external provider.
Name string `yaml:"name" json:"name"`
// Alias is the model name alias that clients will use to reference this model.
Alias string `yaml:"alias" json:"alias"`
// DisplayName is the optional human-readable name shown in model catalogs.
DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"`
// ForceMapping rewrites upstream response model fields back to Alias.
ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"`
// Thinking configures the thinking/reasoning capability for this model.
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
}
func (m VertexCompatModel) GetName() string { return m.Name }
func (m VertexCompatModel) GetAlias() string { return m.Alias }
func (m VertexCompatModel) GetDisplayName() string { return m.DisplayName }
func (m VertexCompatModel) GetForceMapping() bool { return m.ForceMapping }
func (m VertexCompatModel) GetThinking() *registry.ThinkingSupport {
return m.Thinking
}
// SanitizeVertexCompatKeys deduplicates and normalizes Vertex-compatible API key credentials.
func (cfg *Config) SanitizeVertexCompatKeys() {
if cfg == nil {
return
}
seen := make(map[string]struct{}, len(cfg.VertexCompatAPIKey))
out := cfg.VertexCompatAPIKey[:0]
for i := range cfg.VertexCompatAPIKey {
entry := cfg.VertexCompatAPIKey[i]
entry.APIKey = strings.TrimSpace(entry.APIKey)
if entry.APIKey == "" {
continue
}
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
// Sanitize models: remove entries without valid alias
sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))
for _, model := range entry.Models {
model.Alias = strings.TrimSpace(model.Alias)
model.Name = strings.TrimSpace(model.Name)
if model.Alias != "" && model.Name != "" {
sanitizedModels = append(sanitizedModels, model)
}
}
entry.Models = sanitizedModels
// Use API key + base URL as uniqueness key
uniqueKey := entry.APIKey + "|" + entry.BaseURL
if _, exists := seen[uniqueKey]; exists {
continue
}
seen[uniqueKey] = struct{}{}
out = append(out, entry)
}
cfg.VertexCompatAPIKey = out
}

View file

@ -0,0 +1,153 @@
package config
import (
"fmt"
"github.com/router-for-me/CLIProxyAPI/v7/internal/credentialweight"
"gopkg.in/yaml.v3"
)
// MaxCredentialWeight is the largest positive credential routing weight.
const MaxCredentialWeight = int(credentialweight.Max)
// ValidateCredentialWeight validates one optional config credential weight.
func ValidateCredentialWeight(weight *int) error {
if weight == nil {
return nil
}
_, errNormalize := credentialweight.Normalize(int64(*weight))
return errNormalize
}
func validateCredentialWeightYAML(data []byte) error {
var document yaml.Node
if errUnmarshal := yaml.Unmarshal(data, &document); errUnmarshal != nil {
return nil
}
if len(document.Content) == 0 {
return nil
}
root := document.Content[0]
families := map[string]struct{}{
"gemini-api-key": {}, "interactions-api-key": {}, "claude-api-key": {},
"vertex-api-key": {}, "codex-api-key": {}, "xai-api-key": {},
}
for index := 0; root != nil && root.Kind == yaml.MappingNode && index+1 < len(root.Content); index += 2 {
name := root.Content[index].Value
value := root.Content[index+1]
if _, ok := families[name]; ok {
if errValidate := validateWeightSequenceNode(value, name); errValidate != nil {
return errValidate
}
continue
}
if name == "openai-compatibility" {
if errValidate := validateOpenAICompatibilityWeightNodes(value); errValidate != nil {
return errValidate
}
}
}
return nil
}
func validateWeightSequenceNode(sequence *yaml.Node, path string) error {
if sequence == nil || sequence.Kind != yaml.SequenceNode {
return nil
}
for index, item := range sequence.Content {
if errValidate := validateWeightMappingNode(item, fmt.Sprintf("%s[%d]", path, index)); errValidate != nil {
return errValidate
}
}
return nil
}
func validateWeightMappingNode(mapping *yaml.Node, path string) error {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil
}
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value != "weight" {
continue
}
value := mapping.Content[index+1]
if value.Kind != yaml.ScalarNode || value.Tag != "!!int" {
return fmt.Errorf("%s.weight: weight must be an integer", path)
}
var weight int64
if errDecode := value.Decode(&weight); errDecode != nil {
return fmt.Errorf("%s.weight: weight must be an integer", path)
}
if _, errNormalize := credentialweight.Normalize(weight); errNormalize != nil {
return fmt.Errorf("%s.weight: %w", path, errNormalize)
}
}
return nil
}
func validateOpenAICompatibilityWeightNodes(sequence *yaml.Node) error {
if sequence == nil || sequence.Kind != yaml.SequenceNode {
return nil
}
for providerIndex, provider := range sequence.Content {
if provider == nil || provider.Kind != yaml.MappingNode {
continue
}
for index := 0; index+1 < len(provider.Content); index += 2 {
if provider.Content[index].Value != "api-key-entries" {
continue
}
path := fmt.Sprintf("openai-compatibility[%d].api-key-entries", providerIndex)
if errValidate := validateWeightSequenceNode(provider.Content[index+1], path); errValidate != nil {
return errValidate
}
}
}
return nil
}
// ValidateCredentialWeights validates weights for every API-key family.
func (cfg *Config) ValidateCredentialWeights() error {
if cfg == nil {
return nil
}
for index := range cfg.GeminiKey {
if errValidate := ValidateCredentialWeight(cfg.GeminiKey[index].Weight); errValidate != nil {
return fmt.Errorf("gemini-api-key[%d].weight: %w", index, errValidate)
}
}
for index := range cfg.InteractionsKey {
if errValidate := ValidateCredentialWeight(cfg.InteractionsKey[index].Weight); errValidate != nil {
return fmt.Errorf("interactions-api-key[%d].weight: %w", index, errValidate)
}
}
for index := range cfg.ClaudeKey {
if errValidate := ValidateCredentialWeight(cfg.ClaudeKey[index].Weight); errValidate != nil {
return fmt.Errorf("claude-api-key[%d].weight: %w", index, errValidate)
}
}
for index := range cfg.VertexCompatAPIKey {
if errValidate := ValidateCredentialWeight(cfg.VertexCompatAPIKey[index].Weight); errValidate != nil {
return fmt.Errorf("vertex-api-key[%d].weight: %w", index, errValidate)
}
}
for index := range cfg.CodexKey {
if errValidate := ValidateCredentialWeight(cfg.CodexKey[index].Weight); errValidate != nil {
return fmt.Errorf("codex-api-key[%d].weight: %w", index, errValidate)
}
}
for index := range cfg.XAIKey {
if errValidate := ValidateCredentialWeight(cfg.XAIKey[index].Weight); errValidate != nil {
return fmt.Errorf("xai-api-key[%d].weight: %w", index, errValidate)
}
}
for providerIndex := range cfg.OpenAICompatibility {
for keyIndex := range cfg.OpenAICompatibility[providerIndex].APIKeyEntries {
weight := cfg.OpenAICompatibility[providerIndex].APIKeyEntries[keyIndex].Weight
if errValidate := ValidateCredentialWeight(weight); errValidate != nil {
return fmt.Errorf("openai-compatibility[%d].api-key-entries[%d].weight: %w", providerIndex, keyIndex, errValidate)
}
}
}
return nil
}

View file

@ -0,0 +1,62 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAPIKeyWeightValidation(t *testing.T) {
tests := []struct {
name string
weight string
valid bool
}{
{name: "negative excludes", weight: "-1", valid: true},
{name: "maximum", weight: "1000000", valid: true},
{name: "fraction", weight: "1.5", valid: false},
{name: "above maximum", weight: "1000001", valid: false},
{name: "integer overflow", weight: "9223372036854775808", valid: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, errParse := ParseConfigBytes([]byte("gemini-api-key:\n - api-key: key\n weight: " + test.weight + "\n"))
if (errParse == nil) != test.valid {
t.Fatalf("ParseConfigBytes(weight=%s) error = %v, want valid=%v", test.weight, errParse, test.valid)
}
})
}
}
func TestAPIKeyWeightParsingAndZeroPersistence(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`xai-api-key:
- api-key: key
base-url: https://api.x.ai/v1
weight: 0
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.XAIKey) != 1 || cfg.XAIKey[0].Weight == nil || *cfg.XAIKey[0].Weight != 0 {
t.Fatalf("parsed weight = %#v, want explicit zero", cfg.XAIKey)
}
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte(`xai-api-key:
- api-key: key
base-url: https://api.x.ai/v1
`), 0644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
saved, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if !strings.Contains(string(saved), "weight: 0") {
t.Fatalf("saved config does not preserve explicit zero weight:\n%s", saved)
}
}

View file

@ -0,0 +1,20 @@
package config
import "testing"
func TestSanitizeXAIKeysClearsCodexAlphaSearchCapability(t *testing.T) {
cfg := &Config{XAIKey: []XAIKey{{
APIKey: "xai-key",
BaseURL: "https://api.x.ai/v1",
AlphaSearch: true,
}}}
cfg.SanitizeXAIKeys()
if len(cfg.XAIKey) != 1 {
t.Fatalf("XAI key count = %d, want 1", len(cfg.XAIKey))
}
if cfg.XAIKey[0].AlphaSearch {
t.Fatal("SanitizeXAIKeys() retained the Codex-only alpha-search capability")
}
}

View file

@ -0,0 +1,95 @@
package config
import "testing"
func TestParseConfigBytesXAIConfig(t *testing.T) {
defaultCfg, errDefault := ParseConfigBytes([]byte(`{}`))
if errDefault != nil {
t.Fatalf("ParseConfigBytes(default) error = %v", errDefault)
}
if defaultCfg.XAI.InjectXSearch {
t.Fatal("xai.inject-x-search = true by default, want false")
}
enabledCfg, errEnabled := ParseConfigBytes([]byte(`xai:
inject-x-search: true
`))
if errEnabled != nil {
t.Fatalf("ParseConfigBytes(enabled) error = %v", errEnabled)
}
if !enabledCfg.XAI.InjectXSearch {
t.Fatal("xai.inject-x-search = false, want true")
}
}
func TestParseConfigBytesXAIAPIKeyMatchesCodexShape(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`xai-api-key:
- api-key: " xai-key "
priority: 3
weight: 5
prefix: " team-xai "
base-url: " https://api.x.ai/v1 "
websockets: true
proxy-url: " http://proxy.local "
headers:
X-Custom: value
models:
- name: grok-4.5
alias: grok-latest
display-name: Grok Latest
force-mapping: true
excluded-models:
- " grok-3-* "
disable-cooling: true
request-retry: 0
- api-key: dropped
base-url: " "
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if len(cfg.XAIKey) != 1 {
t.Fatalf("xai-api-key count = %d, want 1", len(cfg.XAIKey))
}
entry := cfg.XAIKey[0]
if entry.APIKey != " xai-key " {
t.Fatalf("api-key = %q, want original Codex-compatible value", entry.APIKey)
}
if entry.Priority != 3 {
t.Fatalf("priority = %d, want 3", entry.Priority)
}
if entry.Weight == nil || *entry.Weight != 5 {
t.Fatalf("weight = %v, want 5", entry.Weight)
}
if entry.Prefix != "team-xai" {
t.Fatalf("prefix = %q, want team-xai", entry.Prefix)
}
if entry.BaseURL != "https://api.x.ai/v1" {
t.Fatalf("base-url = %q, want https://api.x.ai/v1", entry.BaseURL)
}
if !entry.Websockets {
t.Fatal("websockets = false, want true")
}
if entry.ProxyURL != " http://proxy.local " {
t.Fatalf("proxy-url = %q, want original Codex-compatible value", entry.ProxyURL)
}
if entry.DisableCooling == nil || !*entry.DisableCooling {
t.Fatalf("disable-cooling = %v, want true", entry.DisableCooling)
}
if entry.RequestRetry == nil || *entry.RequestRetry != 0 {
t.Fatalf("request-retry = %v, want 0", entry.RequestRetry)
}
if entry.Headers["X-Custom"] != "value" {
t.Fatalf("X-Custom header = %q, want value", entry.Headers["X-Custom"])
}
if len(entry.Models) != 1 {
t.Fatalf("model count = %d, want 1", len(entry.Models))
}
model := entry.Models[0]
if model.Name != "grok-4.5" || model.Alias != "grok-latest" || model.DisplayName != "Grok Latest" || !model.ForceMapping {
t.Fatalf("unexpected model mapping: %+v", model)
}
if len(entry.ExcludedModels) != 1 || entry.ExcludedModels[0] != "grok-3-*" {
t.Fatalf("excluded-models = %#v, want [grok-3-*]", entry.ExcludedModels)
}
}