diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 1ce60151..c7bfaf07 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 { @@ -194,6 +199,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/oauth_excluded.go b/internal/watcher/diff/oauth_excluded.go index 4f08c4d6..5cac62b3 100644 --- a/internal/watcher/diff/oauth_excluded.go +++ b/internal/watcher/diff/oauth_excluded.go @@ -122,6 +122,11 @@ type VertexModelsSummary struct { count int } +type GeminiModelsSummary struct { + hash string + count int +} + // SummarizeVertexModels hashes vertex-compatible models for change detection. func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummary { if len(models) == 0 { @@ -149,3 +154,24 @@ func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummar count: len(names), } } + +// 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), + } +} 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,