hkfires commited on
Commit
190f7ae
·
1 Parent(s): 701f30e

refactor(diff): improve security and stability of config change detection

Browse files

Introduce formatProxyURL helper to sanitize proxy addresses before
logging, stripping credentials and path components while preserving
host information. Rework model hash computation to sort and deduplicate
name/alias pairs with case normalization, ensuring consistent output
regardless of input ordering. Add signature-based identification for
anonymous OpenAI-compatible provider entries to maintain stable keys
across configuration reloads. Replace direct stdout prints with
structured logger calls for file change notifications.

internal/watcher/diff/config_diff.go CHANGED
@@ -2,6 +2,7 @@ package diff
2
 
3
  import (
4
  "fmt"
 
5
  "reflect"
6
  "strings"
7
 
@@ -45,7 +46,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
45
  changes = append(changes, fmt.Sprintf("max-retry-interval: %d -> %d", oldCfg.MaxRetryInterval, newCfg.MaxRetryInterval))
46
  }
47
  if oldCfg.ProxyURL != newCfg.ProxyURL {
48
- changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", oldCfg.ProxyURL, newCfg.ProxyURL))
49
  }
50
  if oldCfg.WebsocketAuth != newCfg.WebsocketAuth {
51
  changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth))
@@ -75,7 +76,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
75
  changes = append(changes, fmt.Sprintf("gemini[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
76
  }
77
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
78
- changes = append(changes, fmt.Sprintf("gemini[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
79
  }
80
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
81
  changes = append(changes, fmt.Sprintf("gemini[%d].api-key: updated", i))
@@ -102,7 +103,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
102
  changes = append(changes, fmt.Sprintf("claude[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
103
  }
104
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
105
- changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
106
  }
107
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
108
  changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
@@ -129,7 +130,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
129
  changes = append(changes, fmt.Sprintf("codex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
130
  }
131
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
132
- changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
133
  }
134
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
135
  changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
@@ -219,7 +220,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
219
  changes = append(changes, fmt.Sprintf("vertex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
220
  }
221
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
222
- changes = append(changes, fmt.Sprintf("vertex[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
223
  }
224
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
225
  changes = append(changes, fmt.Sprintf("vertex[%d].api-key: updated", i))
@@ -257,3 +258,31 @@ func equalStringMap(a, b map[string]string) bool {
257
  }
258
  return true
259
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import (
4
  "fmt"
5
+ "net/url"
6
  "reflect"
7
  "strings"
8
 
 
46
  changes = append(changes, fmt.Sprintf("max-retry-interval: %d -> %d", oldCfg.MaxRetryInterval, newCfg.MaxRetryInterval))
47
  }
48
  if oldCfg.ProxyURL != newCfg.ProxyURL {
49
+ changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", formatProxyURL(oldCfg.ProxyURL), formatProxyURL(newCfg.ProxyURL)))
50
  }
51
  if oldCfg.WebsocketAuth != newCfg.WebsocketAuth {
52
  changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth))
 
76
  changes = append(changes, fmt.Sprintf("gemini[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
77
  }
78
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
79
+ changes = append(changes, fmt.Sprintf("gemini[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
80
  }
81
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
82
  changes = append(changes, fmt.Sprintf("gemini[%d].api-key: updated", i))
 
103
  changes = append(changes, fmt.Sprintf("claude[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
104
  }
105
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
106
+ changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
107
  }
108
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
109
  changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
 
130
  changes = append(changes, fmt.Sprintf("codex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
131
  }
132
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
133
+ changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
134
  }
135
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
136
  changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
 
220
  changes = append(changes, fmt.Sprintf("vertex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
221
  }
222
  if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
223
+ changes = append(changes, fmt.Sprintf("vertex[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
224
  }
225
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
226
  changes = append(changes, fmt.Sprintf("vertex[%d].api-key: updated", i))
 
258
  }
259
  return true
260
  }
261
+
262
+ func formatProxyURL(raw string) string {
263
+ trimmed := strings.TrimSpace(raw)
264
+ if trimmed == "" {
265
+ return "<none>"
266
+ }
267
+ parsed, err := url.Parse(trimmed)
268
+ if err != nil {
269
+ return "<redacted>"
270
+ }
271
+ host := strings.TrimSpace(parsed.Host)
272
+ scheme := strings.TrimSpace(parsed.Scheme)
273
+ if host == "" {
274
+ // Allow host:port style without scheme.
275
+ parsed2, err2 := url.Parse("http://" + trimmed)
276
+ if err2 == nil {
277
+ host = strings.TrimSpace(parsed2.Host)
278
+ }
279
+ scheme = ""
280
+ }
281
+ if host == "" {
282
+ return "<redacted>"
283
+ }
284
+ if scheme == "" {
285
+ return host
286
+ }
287
+ return scheme + "://" + host
288
+ }
internal/watcher/diff/config_diff_test.go CHANGED
@@ -416,6 +416,30 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
416
  expectContains(t, changes, "openai-compatibility:")
417
  }
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  func TestBuildConfigChangeDetails_SecretAndUpstreamUpdates(t *testing.T) {
420
  oldCfg := &config.Config{
421
  AmpCode: config.AmpCode{
 
416
  expectContains(t, changes, "openai-compatibility:")
417
  }
418
 
419
+ func TestFormatProxyURL(t *testing.T) {
420
+ tests := []struct {
421
+ name string
422
+ in string
423
+ want string
424
+ }{
425
+ {name: "empty", in: "", want: "<none>"},
426
+ {name: "invalid", in: "http://[::1", want: "<redacted>"},
427
+ {name: "fullURLRedactsUserinfoAndPath", in: "http://user:pass@example.com:8080/path?x=1#frag", want: "http://example.com:8080"},
428
+ {name: "socks5RedactsUserinfoAndPath", in: "socks5://user:pass@192.168.1.1:1080/path?x=1", want: "socks5://192.168.1.1:1080"},
429
+ {name: "socks5HostPort", in: "socks5://proxy.example.com:1080/", want: "socks5://proxy.example.com:1080"},
430
+ {name: "hostPortNoScheme", in: "example.com:1234/path?x=1", want: "example.com:1234"},
431
+ {name: "relativePathRedacted", in: "/just/path", want: "<redacted>"},
432
+ {name: "schemeAndHost", in: "https://example.com", want: "https://example.com"},
433
+ }
434
+ for _, tt := range tests {
435
+ t.Run(tt.name, func(t *testing.T) {
436
+ if got := formatProxyURL(tt.in); got != tt.want {
437
+ t.Fatalf("expected %q, got %q", tt.want, got)
438
+ }
439
+ })
440
+ }
441
+ }
442
+
443
  func TestBuildConfigChangeDetails_SecretAndUpstreamUpdates(t *testing.T) {
444
  oldCfg := &config.Config{
445
  AmpCode: config.AmpCode{
internal/watcher/diff/model_hash.go CHANGED
@@ -13,32 +13,47 @@ import (
13
  // ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models.
14
  // Used to detect model list changes during hot reload.
15
  func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string {
16
- if len(models) == 0 {
17
- return ""
18
- }
19
- data, _ := json.Marshal(models)
20
- sum := sha256.Sum256(data)
21
- return hex.EncodeToString(sum[:])
 
 
 
 
 
22
  }
23
 
24
  // ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models.
25
  func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string {
26
- if len(models) == 0 {
27
- return ""
28
- }
29
- data, _ := json.Marshal(models)
30
- sum := sha256.Sum256(data)
31
- return hex.EncodeToString(sum[:])
 
 
 
 
 
32
  }
33
 
34
  // ComputeClaudeModelsHash returns a stable hash for Claude model aliases.
35
  func ComputeClaudeModelsHash(models []config.ClaudeModel) string {
36
- if len(models) == 0 {
37
- return ""
38
- }
39
- data, _ := json.Marshal(models)
40
- sum := sha256.Sum256(data)
41
- return hex.EncodeToString(sum[:])
 
 
 
 
 
42
  }
43
 
44
  // ComputeExcludedModelsHash returns a normalized hash for excluded model lists.
@@ -60,3 +75,28 @@ func ComputeExcludedModelsHash(excluded []string) string {
60
  sum := sha256.Sum256(data)
61
  return hex.EncodeToString(sum[:])
62
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  // ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models.
14
  // Used to detect model list changes during hot reload.
15
  func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string {
16
+ keys := normalizeModelPairs(func(out func(key string)) {
17
+ for _, model := range models {
18
+ name := strings.TrimSpace(model.Name)
19
+ alias := strings.TrimSpace(model.Alias)
20
+ if name == "" && alias == "" {
21
+ continue
22
+ }
23
+ out(strings.ToLower(name) + "|" + strings.ToLower(alias))
24
+ }
25
+ })
26
+ return hashJoined(keys)
27
  }
28
 
29
  // ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models.
30
  func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string {
31
+ keys := normalizeModelPairs(func(out func(key string)) {
32
+ for _, model := range models {
33
+ name := strings.TrimSpace(model.Name)
34
+ alias := strings.TrimSpace(model.Alias)
35
+ if name == "" && alias == "" {
36
+ continue
37
+ }
38
+ out(strings.ToLower(name) + "|" + strings.ToLower(alias))
39
+ }
40
+ })
41
+ return hashJoined(keys)
42
  }
43
 
44
  // ComputeClaudeModelsHash returns a stable hash for Claude model aliases.
45
  func ComputeClaudeModelsHash(models []config.ClaudeModel) string {
46
+ keys := normalizeModelPairs(func(out func(key string)) {
47
+ for _, model := range models {
48
+ name := strings.TrimSpace(model.Name)
49
+ alias := strings.TrimSpace(model.Alias)
50
+ if name == "" && alias == "" {
51
+ continue
52
+ }
53
+ out(strings.ToLower(name) + "|" + strings.ToLower(alias))
54
+ }
55
+ })
56
+ return hashJoined(keys)
57
  }
58
 
59
  // ComputeExcludedModelsHash returns a normalized hash for excluded model lists.
 
75
  sum := sha256.Sum256(data)
76
  return hex.EncodeToString(sum[:])
77
  }
78
+
79
+ func normalizeModelPairs(collect func(out func(key string))) []string {
80
+ seen := make(map[string]struct{})
81
+ keys := make([]string, 0)
82
+ collect(func(key string) {
83
+ if _, exists := seen[key]; exists {
84
+ return
85
+ }
86
+ seen[key] = struct{}{}
87
+ keys = append(keys, key)
88
+ })
89
+ if len(keys) == 0 {
90
+ return nil
91
+ }
92
+ sort.Strings(keys)
93
+ return keys
94
+ }
95
+
96
+ func hashJoined(keys []string) string {
97
+ if len(keys) == 0 {
98
+ return ""
99
+ }
100
+ sum := sha256.Sum256([]byte(strings.Join(keys, "\n")))
101
+ return hex.EncodeToString(sum[:])
102
+ }
internal/watcher/diff/model_hash_test.go CHANGED
@@ -25,6 +25,27 @@ func TestComputeOpenAICompatModelsHash_Deterministic(t *testing.T) {
25
  }
26
  }
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  func TestComputeVertexCompatModelsHash_DifferentInputs(t *testing.T) {
29
  models := []config.VertexCompatModel{{Name: "gemini-pro", Alias: "pro"}}
30
  hash1 := ComputeVertexCompatModelsHash(models)
@@ -37,6 +58,20 @@ func TestComputeVertexCompatModelsHash_DifferentInputs(t *testing.T) {
37
  }
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  func TestComputeClaudeModelsHash_Empty(t *testing.T) {
41
  if got := ComputeClaudeModelsHash(nil); got != "" {
42
  t.Fatalf("expected empty hash for nil models, got %q", got)
@@ -46,6 +81,20 @@ func TestComputeClaudeModelsHash_Empty(t *testing.T) {
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  func TestComputeExcludedModelsHash_Normalizes(t *testing.T) {
50
  hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"})
51
  hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"})
@@ -68,6 +117,9 @@ func TestComputeOpenAICompatModelsHash_Empty(t *testing.T) {
68
  if got := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{}); got != "" {
69
  t.Fatalf("expected empty hash for empty slice, got %q", got)
70
  }
 
 
 
71
  }
72
 
73
  func TestComputeVertexCompatModelsHash_Empty(t *testing.T) {
@@ -77,6 +129,9 @@ func TestComputeVertexCompatModelsHash_Empty(t *testing.T) {
77
  if got := ComputeVertexCompatModelsHash([]config.VertexCompatModel{}); got != "" {
78
  t.Fatalf("expected empty hash for empty slice, got %q", got)
79
  }
 
 
 
80
  }
81
 
82
  func TestComputeExcludedModelsHash_Empty(t *testing.T) {
 
25
  }
26
  }
27
 
28
+ func TestComputeOpenAICompatModelsHash_NormalizesAndDedups(t *testing.T) {
29
+ a := []config.OpenAICompatibilityModel{
30
+ {Name: "gpt-4", Alias: "gpt4"},
31
+ {Name: " "},
32
+ {Name: "GPT-4", Alias: "GPT4"},
33
+ {Alias: "a1"},
34
+ }
35
+ b := []config.OpenAICompatibilityModel{
36
+ {Alias: "A1"},
37
+ {Name: "gpt-4", Alias: "gpt4"},
38
+ }
39
+ h1 := ComputeOpenAICompatModelsHash(a)
40
+ h2 := ComputeOpenAICompatModelsHash(b)
41
+ if h1 == "" || h2 == "" {
42
+ t.Fatal("expected non-empty hashes for non-empty model sets")
43
+ }
44
+ if h1 != h2 {
45
+ t.Fatalf("expected normalized hashes to match, got %s / %s", h1, h2)
46
+ }
47
+ }
48
+
49
  func TestComputeVertexCompatModelsHash_DifferentInputs(t *testing.T) {
50
  models := []config.VertexCompatModel{{Name: "gemini-pro", Alias: "pro"}}
51
  hash1 := ComputeVertexCompatModelsHash(models)
 
58
  }
59
  }
60
 
61
+ func TestComputeVertexCompatModelsHash_IgnoresBlankAndOrder(t *testing.T) {
62
+ a := []config.VertexCompatModel{
63
+ {Name: "m1", Alias: "a1"},
64
+ {Name: " "},
65
+ {Name: "M1", Alias: "A1"},
66
+ }
67
+ b := []config.VertexCompatModel{
68
+ {Name: "m1", Alias: "a1"},
69
+ }
70
+ if h1, h2 := ComputeVertexCompatModelsHash(a), ComputeVertexCompatModelsHash(b); h1 == "" || h1 != h2 {
71
+ t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2)
72
+ }
73
+ }
74
+
75
  func TestComputeClaudeModelsHash_Empty(t *testing.T) {
76
  if got := ComputeClaudeModelsHash(nil); got != "" {
77
  t.Fatalf("expected empty hash for nil models, got %q", got)
 
81
  }
82
  }
83
 
84
+ func TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) {
85
+ a := []config.ClaudeModel{
86
+ {Name: "m1", Alias: "a1"},
87
+ {Name: " "},
88
+ {Name: "M1", Alias: "A1"},
89
+ }
90
+ b := []config.ClaudeModel{
91
+ {Name: "m1", Alias: "a1"},
92
+ }
93
+ if h1, h2 := ComputeClaudeModelsHash(a), ComputeClaudeModelsHash(b); h1 == "" || h1 != h2 {
94
+ t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2)
95
+ }
96
+ }
97
+
98
  func TestComputeExcludedModelsHash_Normalizes(t *testing.T) {
99
  hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"})
100
  hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"})
 
117
  if got := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{}); got != "" {
118
  t.Fatalf("expected empty hash for empty slice, got %q", got)
119
  }
120
+ if got := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: " "}, {Alias: ""}}); got != "" {
121
+ t.Fatalf("expected empty hash for blank models, got %q", got)
122
+ }
123
  }
124
 
125
  func TestComputeVertexCompatModelsHash_Empty(t *testing.T) {
 
129
  if got := ComputeVertexCompatModelsHash([]config.VertexCompatModel{}); got != "" {
130
  t.Fatalf("expected empty hash for empty slice, got %q", got)
131
  }
132
+ if got := ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: " "}}); got != "" {
133
+ t.Fatalf("expected empty hash for blank models, got %q", got)
134
+ }
135
  }
136
 
137
  func TestComputeExcludedModelsHash_Empty(t *testing.T) {
internal/watcher/diff/openai_compat.go CHANGED
@@ -1,6 +1,8 @@
1
  package diff
2
 
3
  import (
 
 
4
  "fmt"
5
  "sort"
6
  "strings"
@@ -120,5 +122,62 @@ func openAICompatKey(entry config.OpenAICompatibility, index int) (string, strin
120
  return "alias:" + alias, alias
121
  }
122
  }
123
- return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  }
 
1
  package diff
2
 
3
  import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
  "fmt"
7
  "sort"
8
  "strings"
 
122
  return "alias:" + alias, alias
123
  }
124
  }
125
+ sig := openAICompatSignature(entry)
126
+ if sig == "" {
127
+ return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1)
128
+ }
129
+ short := sig
130
+ if len(short) > 8 {
131
+ short = short[:8]
132
+ }
133
+ return "sig:" + sig, "compat-" + short
134
+ }
135
+
136
+ func openAICompatSignature(entry config.OpenAICompatibility) string {
137
+ var parts []string
138
+
139
+ if v := strings.TrimSpace(entry.Name); v != "" {
140
+ parts = append(parts, "name="+strings.ToLower(v))
141
+ }
142
+ if v := strings.TrimSpace(entry.BaseURL); v != "" {
143
+ parts = append(parts, "base="+v)
144
+ }
145
+
146
+ models := make([]string, 0, len(entry.Models))
147
+ for _, model := range entry.Models {
148
+ name := strings.TrimSpace(model.Name)
149
+ alias := strings.TrimSpace(model.Alias)
150
+ if name == "" && alias == "" {
151
+ continue
152
+ }
153
+ models = append(models, strings.ToLower(name)+"|"+strings.ToLower(alias))
154
+ }
155
+ if len(models) > 0 {
156
+ sort.Strings(models)
157
+ parts = append(parts, "models="+strings.Join(models, ","))
158
+ }
159
+
160
+ if len(entry.Headers) > 0 {
161
+ keys := make([]string, 0, len(entry.Headers))
162
+ for k := range entry.Headers {
163
+ if trimmed := strings.TrimSpace(k); trimmed != "" {
164
+ keys = append(keys, strings.ToLower(trimmed))
165
+ }
166
+ }
167
+ if len(keys) > 0 {
168
+ sort.Strings(keys)
169
+ parts = append(parts, "headers="+strings.Join(keys, ","))
170
+ }
171
+ }
172
+
173
+ // Intentionally exclude API key material; only count non-empty entries.
174
+ if count := countAPIKeys(entry); count > 0 {
175
+ parts = append(parts, fmt.Sprintf("api_keys=%d", count))
176
+ }
177
+
178
+ if len(parts) == 0 {
179
+ return ""
180
+ }
181
+ sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
182
+ return hex.EncodeToString(sum[:])
183
  }
internal/watcher/diff/openai_compat_test.go CHANGED
@@ -1,6 +1,7 @@
1
  package diff
2
 
3
  import (
 
4
  "testing"
5
 
6
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -89,6 +90,79 @@ func TestOpenAICompatKeyFallbacks(t *testing.T) {
89
  }
90
  }
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  func TestCountOpenAIModelsSkipsBlanks(t *testing.T) {
93
  models := []config.OpenAICompatibilityModel{
94
  {Name: "m1"},
 
1
  package diff
2
 
3
  import (
4
+ "strings"
5
  "testing"
6
 
7
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
 
90
  }
91
  }
92
 
93
+ func TestOpenAICompatKey_UsesName(t *testing.T) {
94
+ entry := config.OpenAICompatibility{Name: "My-Provider"}
95
+ key, label := openAICompatKey(entry, 0)
96
+ if key != "name:My-Provider" || label != "My-Provider" {
97
+ t.Fatalf("expected name key, got %s/%s", key, label)
98
+ }
99
+ }
100
+
101
+ func TestOpenAICompatKey_SignatureFallbackWhenOnlyAPIKeys(t *testing.T) {
102
+ entry := config.OpenAICompatibility{
103
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "k1"}, {APIKey: "k2"}},
104
+ }
105
+ key, label := openAICompatKey(entry, 0)
106
+ if !strings.HasPrefix(key, "sig:") || !strings.HasPrefix(label, "compat-") {
107
+ t.Fatalf("expected signature key, got %s/%s", key, label)
108
+ }
109
+ }
110
+
111
+ func TestOpenAICompatSignature_EmptyReturnsEmpty(t *testing.T) {
112
+ if got := openAICompatSignature(config.OpenAICompatibility{}); got != "" {
113
+ t.Fatalf("expected empty signature, got %q", got)
114
+ }
115
+ }
116
+
117
+ func TestOpenAICompatSignature_StableAndNormalized(t *testing.T) {
118
+ a := config.OpenAICompatibility{
119
+ Name: " Provider ",
120
+ BaseURL: "http://base",
121
+ Models: []config.OpenAICompatibilityModel{
122
+ {Name: "m1"},
123
+ {Name: " "},
124
+ {Alias: "A1"},
125
+ },
126
+ Headers: map[string]string{
127
+ "X-Test": "1",
128
+ " ": "ignored",
129
+ },
130
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
131
+ {APIKey: "k1"},
132
+ {APIKey: " "},
133
+ },
134
+ }
135
+ b := config.OpenAICompatibility{
136
+ Name: "provider",
137
+ BaseURL: "http://base",
138
+ Models: []config.OpenAICompatibilityModel{
139
+ {Alias: "a1"},
140
+ {Name: "m1"},
141
+ },
142
+ Headers: map[string]string{
143
+ "x-test": "2",
144
+ },
145
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
146
+ {APIKey: "k2"},
147
+ },
148
+ }
149
+
150
+ sigA := openAICompatSignature(a)
151
+ sigB := openAICompatSignature(b)
152
+ if sigA == "" || sigB == "" {
153
+ t.Fatalf("expected non-empty signatures, got %q / %q", sigA, sigB)
154
+ }
155
+ if sigA != sigB {
156
+ t.Fatalf("expected normalized signatures to match, got %s / %s", sigA, sigB)
157
+ }
158
+
159
+ c := b
160
+ c.Models = append(c.Models, config.OpenAICompatibilityModel{Name: "m2"})
161
+ if sigC := openAICompatSignature(c); sigC == sigB {
162
+ t.Fatalf("expected signature to change when models change, got %s", sigC)
163
+ }
164
+ }
165
+
166
  func TestCountOpenAIModelsSkipsBlanks(t *testing.T) {
167
  models := []config.OpenAICompatibilityModel{
168
  {Name: "m1"},
internal/watcher/watcher.go CHANGED
@@ -658,7 +658,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
658
  log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name))
659
  return
660
  }
661
- fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name))
662
  w.addOrUpdateClient(event.Name)
663
  return
664
  }
@@ -666,7 +666,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
666
  log.Debugf("ignoring remove for unknown auth file: %s", filepath.Base(event.Name))
667
  return
668
  }
669
- fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name))
670
  w.removeClient(event.Name)
671
  return
672
  }
@@ -675,7 +675,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
675
  log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name))
676
  return
677
  }
678
- fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name))
679
  w.addOrUpdateClient(event.Name)
680
  }
681
  }
@@ -715,7 +715,7 @@ func (w *Watcher) reloadConfigIfChanged() {
715
  log.Debugf("config file content unchanged (hash match), skipping reload")
716
  return
717
  }
718
- fmt.Printf("config file changed, reloading: %s\n", w.configPath)
719
  if w.reloadConfig() {
720
  finalHash := newHash
721
  if updatedData, errRead := os.ReadFile(w.configPath); errRead == nil && len(updatedData) > 0 {
 
658
  log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name))
659
  return
660
  }
661
+ log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
662
  w.addOrUpdateClient(event.Name)
663
  return
664
  }
 
666
  log.Debugf("ignoring remove for unknown auth file: %s", filepath.Base(event.Name))
667
  return
668
  }
669
+ log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
670
  w.removeClient(event.Name)
671
  return
672
  }
 
675
  log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name))
676
  return
677
  }
678
+ log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
679
  w.addOrUpdateClient(event.Name)
680
  }
681
  }
 
715
  log.Debugf("config file content unchanged (hash match), skipping reload")
716
  return
717
  }
718
+ log.Infof("config file changed, reloading: %s", w.configPath)
719
  if w.reloadConfig() {
720
  finalHash := newHash
721
  if updatedData, errRead := os.ReadFile(w.configPath); errRead == nil && len(updatedData) > 0 {
internal/watcher/watcher_test.go CHANGED
@@ -5,7 +5,6 @@ import (
5
  "crypto/sha256"
6
  "encoding/json"
7
  "fmt"
8
- "github.com/fsnotify/fsnotify"
9
  "os"
10
  "path/filepath"
11
  "strings"
@@ -13,6 +12,7 @@ import (
13
  "testing"
14
  "time"
15
 
 
16
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17
  "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
18
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
 
5
  "crypto/sha256"
6
  "encoding/json"
7
  "fmt"
 
8
  "os"
9
  "path/filepath"
10
  "strings"
 
12
  "testing"
13
  "time"
14
 
15
+ "github.com/fsnotify/fsnotify"
16
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17
  "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
18
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"