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 @@
1package config
2
3import (
4 "reflect"
5
6 "gopkg.in/yaml.v3"
7)
8
9var yamlNodeType = reflect.TypeOf(yaml.Node{})
10
11// CloneForRuntime returns an independent in-memory snapshot of the full config.
12func (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
20func 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 @@
1package config
2
3import (
4 "reflect"
5 "testing"
6
7 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
8 "gopkg.in/yaml.v3"
9)
10
11func 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
18func 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
82func TestCloneForRuntimeDoesNotShareReferenceFields(t *testing.T) {
83 cfg := sampleCloneRuntimeConfig()
84 clone := cfg.CloneForRuntime()
85
86 assertNoSharedRuntimeReferences(t, reflect.ValueOf(cfg), reflect.ValueOf(clone), "Config")
87}
88
89func 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
195func 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
207func 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
223func 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
234func 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
249func 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
304func 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
1525 1525
1526// deepCopyNode creates a deep copy of a yaml.Node graph.1526// deepCopyNode creates a deep copy of a yaml.Node graph.
1527func deepCopyNode(n *yaml.Node) *yaml.Node {1527func deepCopyNode(n *yaml.Node) *yaml.Node {
1528 return deepCopyNodeSeen(n, map[*yaml.Node]*yaml.Node{})
1529}
1530
1531func deepCopyNodeSeen(n *yaml.Node, seen map[*yaml.Node]*yaml.Node) *yaml.Node {
1528 if n == nil {1532 if n == nil {
1529 return nil1533 return nil
1530 }1534 }
1535 if cp, ok := seen[n]; ok {
1536 return cp
1537 }
1531 cp := *n1538 cp := *n
1539 seen[n] = &cp
1540 if n.Alias != nil {
1541 cp.Alias = deepCopyNodeSeen(n.Alias, seen)
1542 }
1532 if len(n.Content) > 0 {1543 if len(n.Content) > 0 {
1533 cp.Content = make([]*yaml.Node, len(n.Content))1544 cp.Content = make([]*yaml.Node, len(n.Content))
1534 for i := range n.Content {1545 for i := range n.Content {
1535 cp.Content[i] = deepCopyNode(n.Content[i])1546 cp.Content[i] = deepCopyNodeSeen(n.Content[i], seen)
1536 }1547 }
1537 }1548 }
1538 return &cp1549 return &cp