| @@ -0,0 +1,309 @@ |
| | 1 | package config |
| | 2 | |
| | 3 | import ( |
| | 4 | "reflect" |
| | 5 | "testing" |
| | 6 | |
| | 7 | "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" |
| | 8 | "gopkg.in/yaml.v3" |
| | 9 | ) |
| | 10 | |
| | 11 | func TestCloneForRuntimeNil(t *testing.T) { |
| | 12 | var cfg *Config |
| | 13 | if got := cfg.CloneForRuntime(); got != nil { |
| | 14 | t.Fatalf("CloneForRuntime() = %#v, want nil", got) |
| | 15 | } |
| | 16 | } |
| | 17 | |
| | 18 | func TestCloneForRuntimeDeepCopiesConfig(t *testing.T) { |
| | 19 | cfg := sampleCloneRuntimeConfig() |
| | 20 | |
| | 21 | clone := cfg.CloneForRuntime() |
| | 22 | if clone == nil { |
| | 23 | t.Fatal("CloneForRuntime() = nil") |
| | 24 | } |
| | 25 | if clone == cfg { |
| | 26 | t.Fatal("CloneForRuntime() returned original pointer") |
| | 27 | } |
| | 28 | |
| | 29 | mutateOriginalConfig(cfg) |
| | 30 | |
| | 31 | if clone.Home.Host != "home.local" { |
| | 32 | t.Fatalf("clone.Home.Host = %q, want home.local", clone.Home.Host) |
| | 33 | } |
| | 34 | if clone.APIKeys[0] != "client-key" { |
| | 35 | t.Fatalf("clone.APIKeys[0] = %q, want client-key", clone.APIKeys[0]) |
| | 36 | } |
| | 37 | if clone.OAuthExcludedModels["codex"][0] != "hidden-model" { |
| | 38 | t.Fatalf("clone.OAuthExcludedModels[codex][0] = %q, want hidden-model", clone.OAuthExcludedModels["codex"][0]) |
| | 39 | } |
| | 40 | if clone.OAuthModelAlias["codex"][0].Alias != "client-model" { |
| | 41 | t.Fatalf("clone.OAuthModelAlias[codex][0].Alias = %q, want client-model", clone.OAuthModelAlias["codex"][0].Alias) |
| | 42 | } |
| | 43 | if got := pluginRawScalar(t, clone.Plugins.Configs["sample"].Raw, "mode"); got != "first" { |
| | 44 | t.Fatalf("clone plugin raw mode = %q, want first", got) |
| | 45 | } |
| | 46 | if clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "low" { |
| | 47 | t.Fatalf("clone thinking level = %q, want low", clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0]) |
| | 48 | } |
| | 49 | if got := clone.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "value" { |
| | 50 | t.Fatalf("clone payload object key = %#v, want value", got) |
| | 51 | } |
| | 52 | |
| | 53 | clone.APIKeys[0] = "clone-client-key" |
| | 54 | clone.OAuthExcludedModels["codex"][0] = "clone-hidden-model" |
| | 55 | clone.OAuthModelAlias["codex"][0].Alias = "clone-client-model" |
| | 56 | clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "clone-low" |
| | 57 | clone.Payload.Default[0].Params["object"].(map[string]any)["key"] = "clone-value" |
| | 58 | plugin := clone.Plugins.Configs["sample"] |
| | 59 | setPluginRawScalar(t, &plugin.Raw, "mode", "third") |
| | 60 | clone.Plugins.Configs["sample"] = plugin |
| | 61 | |
| | 62 | if cfg.APIKeys[0] != "mutated-client-key" { |
| | 63 | t.Fatalf("cfg.APIKeys[0] = %q, want mutated-client-key", cfg.APIKeys[0]) |
| | 64 | } |
| | 65 | if cfg.OAuthExcludedModels["codex"][0] != "mutated-hidden-model" { |
| | 66 | t.Fatalf("cfg.OAuthExcludedModels[codex][0] = %q, want mutated-hidden-model", cfg.OAuthExcludedModels["codex"][0]) |
| | 67 | } |
| | 68 | if cfg.OAuthModelAlias["codex"][0].Alias != "mutated-client-model" { |
| | 69 | t.Fatalf("cfg.OAuthModelAlias[codex][0].Alias = %q, want mutated-client-model", cfg.OAuthModelAlias["codex"][0].Alias) |
| | 70 | } |
| | 71 | if got := pluginRawScalar(t, cfg.Plugins.Configs["sample"].Raw, "mode"); got != "second" { |
| | 72 | t.Fatalf("cfg plugin raw mode = %q, want second", got) |
| | 73 | } |
| | 74 | if cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "mutated-low" { |
| | 75 | t.Fatalf("cfg thinking level = %q, want mutated-low", cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0]) |
| | 76 | } |
| | 77 | if got := cfg.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "mutated-value" { |
| | 78 | t.Fatalf("cfg payload object key = %#v, want mutated-value", got) |
| | 79 | } |
| | 80 | } |
| | 81 | |
| | 82 | func TestCloneForRuntimeDoesNotShareReferenceFields(t *testing.T) { |
| | 83 | cfg := sampleCloneRuntimeConfig() |
| | 84 | clone := cfg.CloneForRuntime() |
| | 85 | |
| | 86 | assertNoSharedRuntimeReferences(t, reflect.ValueOf(cfg), reflect.ValueOf(clone), "Config") |
| | 87 | } |
| | 88 | |
| | 89 | func sampleCloneRuntimeConfig() *Config { |
| | 90 | cacheStrict := true |
| | 91 | bypassStrict := false |
| | 92 | pluginEnabled := false |
| | 93 | cacheUserID := true |
| | 94 | |
| | 95 | return &Config{ |
| | 96 | SDKConfig: SDKConfig{ |
| | 97 | APIKeys: []string{"client-key"}, |
| | 98 | Streaming: StreamingConfig{ |
| | 99 | KeepAliveSeconds: 3, |
| | 100 | BootstrapRetries: 2, |
| | 101 | }, |
| | 102 | }, |
| | 103 | Home: HomeConfig{ |
| | 104 | Enabled: true, |
| | 105 | Host: "home.local", |
| | 106 | Port: 8081, |
| | 107 | TLS: HomeTLSConfig{ |
| | 108 | Enable: true, |
| | 109 | ServerName: "home.local", |
| | 110 | CACert: "ca", |
| | 111 | ClientCert: "cert", |
| | 112 | ClientKey: "key", |
| | 113 | UseTargetServerName: true, |
| | 114 | }, |
| | 115 | }, |
| | 116 | Plugins: PluginsConfig{ |
| | 117 | Enabled: true, |
| | 118 | Dir: "plugins", |
| | 119 | StoreSources: []string{"https://plugins.example/store.json"}, |
| | 120 | Configs: map[string]PluginInstanceConfig{ |
| | 121 | "sample": { |
| | 122 | Enabled: &pluginEnabled, |
| | 123 | Priority: 10, |
| | 124 | Raw: samplePluginRawNode("first"), |
| | 125 | }, |
| | 126 | }, |
| | 127 | }, |
| | 128 | AntigravitySignatureCacheEnabled: &cacheStrict, |
| | 129 | AntigravitySignatureBypassStrict: &bypassStrict, |
| | 130 | GeminiKey: []GeminiKey{{ |
| | 131 | APIKey: "gemini-key", |
| | 132 | Models: []GeminiModel{{Name: "gemini-upstream", Alias: "gemini-client"}}, |
| | 133 | Headers: map[string]string{"X-Gemini": "one"}, |
| | 134 | ExcludedModels: []string{"gemini-hidden"}, |
| | 135 | }}, |
| | 136 | CodexKey: []CodexKey{{ |
| | 137 | APIKey: "codex-key", |
| | 138 | Models: []CodexModel{{Name: "codex-upstream", Alias: "codex-client"}}, |
| | 139 | Headers: map[string]string{"X-Codex": "one"}, |
| | 140 | ExcludedModels: []string{"codex-hidden-key"}, |
| | 141 | }}, |
| | 142 | ClaudeKey: []ClaudeKey{{ |
| | 143 | APIKey: "claude-key", |
| | 144 | Models: []ClaudeModel{{Name: "claude-upstream", Alias: "claude-client"}}, |
| | 145 | Headers: map[string]string{"X-Claude": "one"}, |
| | 146 | ExcludedModels: []string{"claude-hidden"}, |
| | 147 | Cloak: &CloakConfig{ |
| | 148 | SensitiveWords: []string{"secret"}, |
| | 149 | CacheUserID: &cacheUserID, |
| | 150 | }, |
| | 151 | }}, |
| | 152 | OpenAICompatibility: []OpenAICompatibility{{ |
| | 153 | Name: "compat", |
| | 154 | APIKeyEntries: []OpenAICompatibilityAPIKey{{APIKey: "compat-key", ProxyURL: "http://proxy.local"}}, |
| | 155 | Models: []OpenAICompatibilityModel{{ |
| | 156 | Name: "compat-upstream", |
| | 157 | Alias: "compat-client", |
| | 158 | Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "high"}}, |
| | 159 | }}, |
| | 160 | Headers: map[string]string{"X-Compat": "one"}, |
| | 161 | }}, |
| | 162 | VertexCompatAPIKey: []VertexCompatKey{{ |
| | 163 | APIKey: "vertex-key", |
| | 164 | Headers: map[string]string{"X-Vertex": "one"}, |
| | 165 | Models: []VertexCompatModel{{Name: "vertex-upstream", Alias: "vertex-client"}}, |
| | 166 | ExcludedModels: []string{"vertex-hidden"}, |
| | 167 | }}, |
| | 168 | OAuthExcludedModels: map[string][]string{ |
| | 169 | "codex": {"hidden-model"}, |
| | 170 | }, |
| | 171 | OAuthModelAlias: map[string][]OAuthModelAlias{ |
| | 172 | "codex": {{Name: "upstream-model", Alias: "client-model", Fork: true}}, |
| | 173 | }, |
| | 174 | Payload: PayloadConfig{ |
| | 175 | Default: []PayloadRule{{ |
| | 176 | Models: []PayloadModelRule{{ |
| | 177 | Name: "model-*", |
| | 178 | Headers: map[string]string{"X-Tier": "gold"}, |
| | 179 | Match: []map[string]any{{"tier": "gold"}}, |
| | 180 | Exist: []string{"$.messages"}, |
| | 181 | }}, |
| | 182 | Params: map[string]any{ |
| | 183 | "object": map[string]any{"key": "value"}, |
| | 184 | "array": []any{"first", map[string]any{"nested": "value"}}, |
| | 185 | }, |
| | 186 | }}, |
| | 187 | Filter: []PayloadFilterRule{{ |
| | 188 | Models: []PayloadModelRule{{Name: "model-*"}}, |
| | 189 | Params: []string{"$.secret"}, |
| | 190 | }}, |
| | 191 | }, |
| | 192 | } |
| | 193 | } |
| | 194 | |
| | 195 | func mutateOriginalConfig(cfg *Config) { |
| | 196 | cfg.Home.Host = "mutated-home.local" |
| | 197 | cfg.APIKeys[0] = "mutated-client-key" |
| | 198 | cfg.OAuthExcludedModels["codex"][0] = "mutated-hidden-model" |
| | 199 | cfg.OAuthModelAlias["codex"][0].Alias = "mutated-client-model" |
| | 200 | cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "mutated-low" |
| | 201 | cfg.Payload.Default[0].Params["object"].(map[string]any)["key"] = "mutated-value" |
| | 202 | plugin := cfg.Plugins.Configs["sample"] |
| | 203 | setPluginRawScalar(nil, &plugin.Raw, "mode", "second") |
| | 204 | cfg.Plugins.Configs["sample"] = plugin |
| | 205 | } |
| | 206 | |
| | 207 | func samplePluginRawNode(mode string) yaml.Node { |
| | 208 | modeValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: mode, Anchor: "modeAnchor"} |
| | 209 | return yaml.Node{ |
| | 210 | Kind: yaml.MappingNode, |
| | 211 | Tag: "!!map", |
| | 212 | Content: []*yaml.Node{ |
| | 213 | {Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, |
| | 214 | {Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, |
| | 215 | {Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode"}, |
| | 216 | modeValue, |
| | 217 | {Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode-alias"}, |
| | 218 | {Kind: yaml.AliasNode, Alias: modeValue}, |
| | 219 | }, |
| | 220 | } |
| | 221 | } |
| | 222 | |
| | 223 | func pluginRawScalar(t *testing.T, node yaml.Node, key string) string { |
| | 224 | t.Helper() |
| | 225 | for i := 0; i+1 < len(node.Content); i += 2 { |
| | 226 | if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil { |
| | 227 | return node.Content[i+1].Value |
| | 228 | } |
| | 229 | } |
| | 230 | t.Fatalf("raw plugin node missing key %q", key) |
| | 231 | return "" |
| | 232 | } |
| | 233 | |
| | 234 | func setPluginRawScalar(t *testing.T, node *yaml.Node, key, value string) { |
| | 235 | if t != nil { |
| | 236 | t.Helper() |
| | 237 | } |
| | 238 | for i := 0; i+1 < len(node.Content); i += 2 { |
| | 239 | if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil { |
| | 240 | node.Content[i+1].Value = value |
| | 241 | return |
| | 242 | } |
| | 243 | } |
| | 244 | if t != nil { |
| | 245 | t.Fatalf("raw plugin node missing key %q", key) |
| | 246 | } |
| | 247 | } |
| | 248 | |
| | 249 | func assertNoSharedRuntimeReferences(t *testing.T, original, clone reflect.Value, path string) { |
| | 250 | t.Helper() |
| | 251 | if !original.IsValid() || !clone.IsValid() { |
| | 252 | return |
| | 253 | } |
| | 254 | if original.Kind() == reflect.Interface { |
| | 255 | if original.IsNil() || clone.IsNil() { |
| | 256 | return |
| | 257 | } |
| | 258 | assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path) |
| | 259 | return |
| | 260 | } |
| | 261 | if original.Kind() != clone.Kind() { |
| | 262 | t.Fatalf("%s kind mismatch: %s != %s", path, original.Kind(), clone.Kind()) |
| | 263 | } |
| | 264 | |
| | 265 | switch original.Kind() { |
| | 266 | case reflect.Pointer: |
| | 267 | if original.IsNil() || clone.IsNil() { |
| | 268 | return |
| | 269 | } |
| | 270 | if original.Pointer() == clone.Pointer() { |
| | 271 | t.Fatalf("%s shares pointer %x", path, original.Pointer()) |
| | 272 | } |
| | 273 | assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path+"->"+original.Type().Elem().String()) |
| | 274 | case reflect.Map: |
| | 275 | if original.IsNil() || clone.IsNil() { |
| | 276 | return |
| | 277 | } |
| | 278 | if original.Pointer() == clone.Pointer() { |
| | 279 | t.Fatalf("%s shares map pointer %x", path, original.Pointer()) |
| | 280 | } |
| | 281 | iter := original.MapRange() |
| | 282 | for iter.Next() { |
| | 283 | key := iter.Key() |
| | 284 | assertNoSharedRuntimeReferences(t, iter.Value(), clone.MapIndex(key), path+"["+keyForPath(key)+"]") |
| | 285 | } |
| | 286 | case reflect.Slice: |
| | 287 | if original.IsNil() || clone.IsNil() { |
| | 288 | return |
| | 289 | } |
| | 290 | if original.Pointer() == clone.Pointer() { |
| | 291 | t.Fatalf("%s shares slice pointer %x", path, original.Pointer()) |
| | 292 | } |
| | 293 | for i := 0; i < original.Len(); i++ { |
| | 294 | assertNoSharedRuntimeReferences(t, original.Index(i), clone.Index(i), path+"[]") |
| | 295 | } |
| | 296 | case reflect.Struct: |
| | 297 | for i := 0; i < original.NumField(); i++ { |
| | 298 | field := original.Type().Field(i) |
| | 299 | assertNoSharedRuntimeReferences(t, original.Field(i), clone.Field(i), path+"."+field.Name) |
| | 300 | } |
| | 301 | } |
| | 302 | } |
| | 303 | |
| | 304 | func keyForPath(key reflect.Value) string { |
| | 305 | if key.Kind() == reflect.String { |
| | 306 | return key.String() |
| | 307 | } |
| | 308 | return key.Type().String() |
| | 309 | } |