diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 1ce60151..e24fc893 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -90,6 +90,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i)) } + oldModels := SummarizeGeminiModels(o.Models) + newModels := SummarizeGeminiModels(n.Models) + if oldModels.hash != newModels.hash { + changes = append(changes, fmt.Sprintf("gemini[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) + } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { @@ -120,6 +125,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i)) } + oldModels := SummarizeClaudeModels(o.Models) + newModels := SummarizeClaudeModels(n.Models) + if oldModels.hash != newModels.hash { + changes = append(changes, fmt.Sprintf("claude[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) + } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { @@ -150,6 +160,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i)) } + oldModels := SummarizeCodexModels(o.Models) + newModels := SummarizeCodexModels(n.Models) + if oldModels.hash != newModels.hash { + changes = append(changes, fmt.Sprintf("codex[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) + } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { @@ -194,6 +209,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 { changes = append(changes, entries...) } + if entries, _ := DiffOAuthModelMappingChanges(oldCfg.OAuthModelMappings, newCfg.OAuthModelMappings); len(entries) > 0 { + changes = append(changes, entries...) + } // Remote management (never print the key) if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote { diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go index a224bdca..5779facc 100644 --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -71,6 +71,21 @@ func ComputeCodexModelsHash(models []config.CodexModel) string { return hashJoined(keys) } +// ComputeGeminiModelsHash returns a stable hash for Gemini model aliases. +func ComputeGeminiModelsHash(models []config.GeminiModel) string { + keys := normalizeModelPairs(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + } + }) + return hashJoined(keys) +} + // ComputeExcludedModelsHash returns a normalized hash for excluded model lists. func ComputeExcludedModelsHash(excluded []string) string { if len(excluded) == 0 { diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go new file mode 100644 index 00000000..9c2aa91a --- /dev/null +++ b/internal/watcher/diff/models_summary.go @@ -0,0 +1,121 @@ +package diff + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +type GeminiModelsSummary struct { + hash string + count int +} + +type ClaudeModelsSummary struct { + hash string + count int +} + +type CodexModelsSummary struct { + hash string + count int +} + +type VertexModelsSummary struct { + hash string + count int +} + +// SummarizeGeminiModels hashes Gemini model aliases for change detection. +func SummarizeGeminiModels(models []config.GeminiModel) GeminiModelsSummary { + if len(models) == 0 { + return GeminiModelsSummary{} + } + keys := normalizeModelPairs(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + } + }) + return GeminiModelsSummary{ + hash: hashJoined(keys), + count: len(keys), + } +} + +// SummarizeClaudeModels hashes Claude model aliases for change detection. +func SummarizeClaudeModels(models []config.ClaudeModel) ClaudeModelsSummary { + if len(models) == 0 { + return ClaudeModelsSummary{} + } + keys := normalizeModelPairs(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + } + }) + return ClaudeModelsSummary{ + hash: hashJoined(keys), + count: len(keys), + } +} + +// SummarizeCodexModels hashes Codex model aliases for change detection. +func SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary { + if len(models) == 0 { + return CodexModelsSummary{} + } + keys := normalizeModelPairs(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + } + }) + return CodexModelsSummary{ + hash: hashJoined(keys), + count: len(keys), + } +} + +// SummarizeVertexModels hashes Vertex-compatible model aliases for change detection. +func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummary { + if len(models) == 0 { + return VertexModelsSummary{} + } + names := make([]string, 0, len(models)) + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + if alias != "" { + name = alias + } + names = append(names, name) + } + if len(names) == 0 { + return VertexModelsSummary{} + } + sort.Strings(names) + sum := sha256.Sum256([]byte(strings.Join(names, "|"))) + return VertexModelsSummary{ + hash: hex.EncodeToString(sum[:]), + count: len(names), + } +} diff --git a/internal/watcher/diff/oauth_excluded.go b/internal/watcher/diff/oauth_excluded.go index 4f08c4d6..2039cf48 100644 --- a/internal/watcher/diff/oauth_excluded.go +++ b/internal/watcher/diff/oauth_excluded.go @@ -116,36 +116,3 @@ func SummarizeAmpModelMappings(mappings []config.AmpModelMapping) AmpModelMappin count: len(entries), } } - -type VertexModelsSummary struct { - hash string - count int -} - -// SummarizeVertexModels hashes vertex-compatible models for change detection. -func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummary { - if len(models) == 0 { - return VertexModelsSummary{} - } - names := make([]string, 0, len(models)) - for _, m := range models { - name := strings.TrimSpace(m.Name) - alias := strings.TrimSpace(m.Alias) - if name == "" && alias == "" { - continue - } - if alias != "" { - name = alias - } - names = append(names, name) - } - if len(names) == 0 { - return VertexModelsSummary{} - } - sort.Strings(names) - sum := sha256.Sum256([]byte(strings.Join(names, "|"))) - return VertexModelsSummary{ - hash: hex.EncodeToString(sum[:]), - count: len(names), - } -} diff --git a/internal/watcher/diff/oauth_model_mappings.go b/internal/watcher/diff/oauth_model_mappings.go new file mode 100644 index 00000000..9228dbab --- /dev/null +++ b/internal/watcher/diff/oauth_model_mappings.go @@ -0,0 +1,98 @@ +package diff + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +type OAuthModelMappingsSummary struct { + hash string + count int +} + +// SummarizeOAuthModelMappings summarizes OAuth model mappings per channel. +func SummarizeOAuthModelMappings(entries map[string][]config.ModelNameMapping) map[string]OAuthModelMappingsSummary { + if len(entries) == 0 { + return nil + } + out := make(map[string]OAuthModelMappingsSummary, len(entries)) + for k, v := range entries { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + out[key] = summarizeOAuthModelMappingList(v) + } + if len(out) == 0 { + return nil + } + return out +} + +// DiffOAuthModelMappingChanges compares OAuth model mappings maps. +func DiffOAuthModelMappingChanges(oldMap, newMap map[string][]config.ModelNameMapping) ([]string, []string) { + oldSummary := SummarizeOAuthModelMappings(oldMap) + newSummary := SummarizeOAuthModelMappings(newMap) + keys := make(map[string]struct{}, len(oldSummary)+len(newSummary)) + for k := range oldSummary { + keys[k] = struct{}{} + } + for k := range newSummary { + keys[k] = struct{}{} + } + changes := make([]string, 0, len(keys)) + affected := make([]string, 0, len(keys)) + for key := range keys { + oldInfo, okOld := oldSummary[key] + newInfo, okNew := newSummary[key] + switch { + case okOld && !okNew: + changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: removed", key)) + affected = append(affected, key) + case !okOld && okNew: + changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: added (%d entries)", key, newInfo.count)) + affected = append(affected, key) + case okOld && okNew && oldInfo.hash != newInfo.hash: + changes = append(changes, fmt.Sprintf("oauth-model-mappings[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count)) + affected = append(affected, key) + } + } + sort.Strings(changes) + sort.Strings(affected) + return changes, affected +} + +func summarizeOAuthModelMappingList(list []config.ModelNameMapping) OAuthModelMappingsSummary { + if len(list) == 0 { + return OAuthModelMappingsSummary{} + } + seen := make(map[string]struct{}, len(list)) + normalized := make([]string, 0, len(list)) + for _, mapping := range list { + name := strings.ToLower(strings.TrimSpace(mapping.Name)) + alias := strings.ToLower(strings.TrimSpace(mapping.Alias)) + if name == "" || alias == "" { + continue + } + key := name + "->" + alias + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, key) + } + if len(normalized) == 0 { + return OAuthModelMappingsSummary{} + } + sort.Strings(normalized) + sum := sha256.Sum256([]byte(strings.Join(normalized, "|"))) + return OAuthModelMappingsSummary{ + hash: hex.EncodeToString(sum[:]), + count: len(normalized), + } +} diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index e7c845a1..2f2b2690 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -62,6 +62,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea if base != "" { attrs["base_url"] = base } + if hash := diff.ComputeGeminiModelsHash(entry.Models); hash != "" { + attrs["models_hash"] = hash + } addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id,