Skip to content

Commit 7f026e1

Browse files
Add runtime config clone
1 parent a65ced4 commit 7f026e1

3 files changed

Lines changed: 402 additions & 1 deletion

File tree

internal/config/clone.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)