hkfires commited on
Commit ·
190f7ae
1
Parent(s): 701f30e
refactor(diff): improve security and stability of config change detection
Browse filesIntroduce 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 +34 -5
- internal/watcher/diff/config_diff_test.go +24 -0
- internal/watcher/diff/model_hash.go +58 -18
- internal/watcher/diff/model_hash_test.go +55 -0
- internal/watcher/diff/openai_compat.go +60 -1
- internal/watcher/diff/openai_compat_test.go +74 -0
- internal/watcher/watcher.go +4 -4
- internal/watcher/watcher_test.go +1 -1
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,
|
| 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,
|
| 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,
|
| 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,
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
// ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models.
|
| 25 |
func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string {
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
// ComputeClaudeModelsHash returns a stable hash for Claude model aliases.
|
| 35 |
func ComputeClaudeModelsHash(models []config.ClaudeModel) string {
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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"
|