Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
76
backend/internal/config/api_key_is_compat_test.go
Normal file
76
backend/internal/config/api_key_is_compat_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
34
backend/internal/config/claude_code_test.go
Normal file
34
backend/internal/config/claude_code_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
44
backend/internal/config/claude_fingerprint_profile.go
Normal file
44
backend/internal/config/claude_fingerprint_profile.go
Normal 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
|
||||
}
|
||||
58
backend/internal/config/claude_fingerprint_profile_test.go
Normal file
58
backend/internal/config/claude_fingerprint_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
backend/internal/config/claude_header_defaults_test.go
Normal file
59
backend/internal/config/claude_header_defaults_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
81
backend/internal/config/clone.go
Normal file
81
backend/internal/config/clone.go
Normal 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
|
||||
}
|
||||
}
|
||||
324
backend/internal/config/clone_test.go
Normal file
324
backend/internal/config/clone_test.go
Normal 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: ®istry.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()
|
||||
}
|
||||
106
backend/internal/config/codex_live.go
Normal file
106
backend/internal/config/codex_live.go
Normal 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
|
||||
}
|
||||
119
backend/internal/config/codex_live_test.go
Normal file
119
backend/internal/config/codex_live_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
174
backend/internal/config/config.go
Normal file
174
backend/internal/config/config.go
Normal 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"`
|
||||
}
|
||||
6
backend/internal/config/config_defaults.go
Normal file
6
backend/internal/config/config_defaults.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package config
|
||||
|
||||
const (
|
||||
DefaultPprofAddr = "127.0.0.1:8316"
|
||||
DefaultAuthDir = "~/.cli-proxy-api"
|
||||
)
|
||||
185
backend/internal/config/config_load.go
Normal file
185
backend/internal/config/config_load.go
Normal 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
|
||||
}
|
||||
392
backend/internal/config/config_normalization.go
Normal file
392
backend/internal/config/config_normalization.go
Normal 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
|
||||
}
|
||||
743
backend/internal/config/config_types.go
Normal file
743
backend/internal/config/config_types.go
Normal 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 }
|
||||
79
backend/internal/config/config_validation.go
Normal file
79
backend/internal/config/config_validation.go
Normal 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
|
||||
}
|
||||
819
backend/internal/config/config_yaml.go
Normal file
819
backend/internal/config/config_yaml.go
Normal 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")
|
||||
}
|
||||
54
backend/internal/config/cooling_override_test.go
Normal file
54
backend/internal/config/cooling_override_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
194
backend/internal/config/credential_concurrency.go
Normal file
194
backend/internal/config/credential_concurrency.go
Normal 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
|
||||
}
|
||||
131
backend/internal/config/credential_concurrency_fixture_test.go
Normal file
131
backend/internal/config/credential_concurrency_fixture_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
backend/internal/config/credential_concurrency_test.go
Normal file
124
backend/internal/config/credential_concurrency_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
87
backend/internal/config/credential_in_flight.go
Normal file
87
backend/internal/config/credential_in_flight.go
Normal 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
|
||||
}
|
||||
234
backend/internal/config/credential_in_flight_test.go
Normal file
234
backend/internal/config/credential_in_flight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
147
backend/internal/config/disable_image_generation_mode.go
Normal file
147
backend/internal/config/disable_image_generation_mode.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
backend/internal/config/gemini_keys_normalization_test.go
Normal file
35
backend/internal/config/gemini_keys_normalization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
22
backend/internal/config/home.go
Normal file
22
backend/internal/config/home.go
Normal 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:"-"`
|
||||
}
|
||||
46
backend/internal/config/home_test.go
Normal file
46
backend/internal/config/home_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
57
backend/internal/config/is_compat_test.go
Normal file
57
backend/internal/config/is_compat_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
backend/internal/config/max_context_length_test.go
Normal file
86
backend/internal/config/max_context_length_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
backend/internal/config/model_display_name_test.go
Normal file
86
backend/internal/config/model_display_name_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
56
backend/internal/config/oauth_model_alias_test.go
Normal file
56
backend/internal/config/oauth_model_alias_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
backend/internal/config/oauth_request_scoped_errors_test.go
Normal file
115
backend/internal/config/oauth_request_scoped_errors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
106
backend/internal/config/parse.go
Normal file
106
backend/internal/config/parse.go
Normal 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
|
||||
}
|
||||
256
backend/internal/config/plugin_config_test.go
Normal file
256
backend/internal/config/plugin_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
46
backend/internal/config/plugin_path.go
Normal file
46
backend/internal/config/plugin_path.go
Normal 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
|
||||
}
|
||||
73
backend/internal/config/request_retry_test.go
Normal file
73
backend/internal/config/request_retry_test.go
Normal 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]
|
||||
}
|
||||
123
backend/internal/config/request_scoped_errors_test.go
Normal file
123
backend/internal/config/request_scoped_errors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
backend/internal/config/sdk_config.go
Normal file
82
backend/internal/config/sdk_config.go
Normal 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"`
|
||||
}
|
||||
130
backend/internal/config/vertex_compat.go
Normal file
130
backend/internal/config/vertex_compat.go
Normal 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
|
||||
}
|
||||
153
backend/internal/config/weight.go
Normal file
153
backend/internal/config/weight.go
Normal 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
|
||||
}
|
||||
62
backend/internal/config/weight_test.go
Normal file
62
backend/internal/config/weight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
backend/internal/config/xai_alpha_search_test.go
Normal file
20
backend/internal/config/xai_alpha_search_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
95
backend/internal/config/xai_api_key_test.go
Normal file
95
backend/internal/config/xai_api_key_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue