History

Add runtime config clone

LTbinglingfeng committed 5 months ago

Commit 7f026e11 parent a65ced43 files · +402 −1Browse files

Unified · Split

3 files
  1. internal/config/clone.go +81 −0
  2. internal/config/clone_test.go +309 −0
  3. internal/config/config.go +12 −1
internal/config/clone.go+81−0
@@ -0,0 +1,81 @@
1+package config
2+
3+import (
4+ "reflect"
5+
6+ "gopkg.in/yaml.v3"
7+)
8+
9+var yamlNodeType = reflect.TypeOf(yaml.Node{})
10+
11+// CloneForRuntime returns an independent in-memory snapshot of the full config.
12+func (cfg *Config) CloneForRuntime() *Config {
13+ if cfg == nil {
14+ return nil
15+ }
16+ cloned := cloneRuntimeValue(reflect.ValueOf(cfg))
17+ return cloned.Interface().(*Config)
18+}
19+
20+func cloneRuntimeValue(v reflect.Value) reflect.Value {
21+ if !v.IsValid() {
22+ return v
23+ }
24+
25+ if v.Type() == yamlNodeType {
26+ node := v.Interface().(yaml.Node)
27+ return reflect.ValueOf(*deepCopyNode(&node))
28+ }
29+
30+ switch v.Kind() {
31+ case reflect.Pointer:
32+ if v.IsNil() {
33+ return reflect.Zero(v.Type())
34+ }
35+ out := reflect.New(v.Type().Elem())
36+ out.Elem().Set(cloneRuntimeValue(v.Elem()))
37+ return out
38+ case reflect.Interface:
39+ if v.IsNil() {
40+ return reflect.Zero(v.Type())
41+ }
42+ return cloneRuntimeValue(v.Elem())
43+ case reflect.Struct:
44+ out := reflect.New(v.Type()).Elem()
45+ for i := 0; i < v.NumField(); i++ {
46+ dst := out.Field(i)
47+ if !dst.CanSet() {
48+ return v
49+ }
50+ dst.Set(cloneRuntimeValue(v.Field(i)))
51+ }
52+ return out
53+ case reflect.Slice:
54+ if v.IsNil() {
55+ return reflect.Zero(v.Type())
56+ }
57+ out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
58+ for i := 0; i < v.Len(); i++ {
59+ out.Index(i).Set(cloneRuntimeValue(v.Index(i)))
60+ }
61+ return out
62+ case reflect.Array:
63+ out := reflect.New(v.Type()).Elem()
64+ for i := 0; i < v.Len(); i++ {
65+ out.Index(i).Set(cloneRuntimeValue(v.Index(i)))
66+ }
67+ return out
68+ case reflect.Map:
69+ if v.IsNil() {
70+ return reflect.Zero(v.Type())
71+ }
72+ out := reflect.MakeMapWithSize(v.Type(), v.Len())
73+ iter := v.MapRange()
74+ for iter.Next() {
75+ out.SetMapIndex(cloneRuntimeValue(iter.Key()), cloneRuntimeValue(iter.Value()))
76+ }
77+ return out
78+ default:
79+ return v
80+ }
81+}
internal/config/clone_test.go+309−0
@@ -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: &registry.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+}
internal/config/config.go+12−1
@@ -1525,14 +1525,25 @@ func isZeroValueNode(node *yaml.Node) bool { · 1524 unmodified lines
15251525
15261526 // deepCopyNode creates a deep copy of a yaml.Node graph.
15271527 func deepCopyNode(n *yaml.Node) *yaml.Node {
1528+ return deepCopyNodeSeen(n, map[*yaml.Node]*yaml.Node{})
1529+}
1530+
1531+func deepCopyNodeSeen(n *yaml.Node, seen map[*yaml.Node]*yaml.Node) *yaml.Node {
15281532 if n == nil {
15291533 return nil
15301534 }
1535+ if cp, ok := seen[n]; ok {
1536+ return cp
1537+ }
15311538 cp := *n
1539+ seen[n] = &cp
1540+ if n.Alias != nil {
1541+ cp.Alias = deepCopyNodeSeen(n.Alias, seen)
1542+ }
15321543 if len(n.Content) > 0 {
15331544 cp.Content = make([]*yaml.Node, len(n.Content))
15341545 for i := range n.Content {
1535− cp.Content[i] = deepCopyNode(n.Content[i])
1546+ cp.Content[i] = deepCopyNodeSeen(n.Content[i], seen)
15361547 }
15371548 }
15381549 return &cp