diff --git a/config.example.yaml b/config.example.yaml index 498d37cd..39678b48 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -547,7 +547,7 @@ nonstream-keepalive-interval: 0 # # on any other gateway (including Kimi) still sends the billing block, but without cch, # # so a per-request hash cannot bust that gateway's prompt cache. api.anthropic.com # # strips the block itself (0 tokens, no cache impact). Kimi drops the whole block by -# # default and keeps it, unsigned, after an explicit fingerprint-profile opt-in. +# # default and keeps it, unsigned, after an explicit fingerprint opt-in. # # A real Claude OAuth credential always signs, on every upstream: a downstream Claude # # Code pointed at CPA cannot produce that value itself. # # @@ -558,15 +558,16 @@ nonstream-keepalive-interval: 0 # # cloak: # # mode: "always" # recommended when upstream rejects non-CLI clients # # -# # Delegated Anthropic Messages OAuth files (Kimi, etc.) use the same field in the -# # auth JSON. Refresh keeps it. Example: +# # Delegated Anthropic Messages OAuth files (Kimi, etc.) use "fingerprint_profile" +# # in the auth JSON. Refresh keeps it. Example: # # { # # "type": "kimi", # # "access_token": "...", # # "refresh_token": "...", -# # "fingerprint-profile": "claude-code-cli" +# # "fingerprint_profile": "claude-code-cli" # # } -# # fingerprint-profile: "claude-code-cli" # optional: default is empty (caller-owned); uncomment to opt into Claude Code CLI Messages fingerprinting +# # Legacy "fingerprint-profile" credentials remain supported and are normalized at load time. +# # fingerprint-profile: "claude-code-cli" # optional claude-api-key provider field; default is empty (caller-owned); uncomment to opt in # experimental-cch-signing: false # deprecated compatibility field; CCH is generated automatically # # for real Claude OAuth on any upstream, and for claude-code-cli profiles # # only on api.anthropic.com; Vertex keeps provider-native signing @@ -697,13 +698,14 @@ nonstream-keepalive-interval: 0 # fork: true # keep the upstream model and also expose the alias as a separate client-visible model # display-name: "Model Name" # override the human-readable name shown in model catalogs # force-mapping: true # rewrite upstream response model fields back to the client-visible alias (example below uses antigravity only) -# Per-auth OAuth aliases can also be stored in an OAuth auth JSON file as "model-aliases". +# Per-auth OAuth aliases can also be stored in an OAuth auth JSON file as "model_aliases". +# Legacy "model-aliases" credentials remain supported and are normalized at load time. # They apply only to that selected auth and take precedence over global aliases for the same client-visible alias. # Example auth JSON: # { # "type": "codex", # "email": "user@example.com", -# "model-aliases": [ +# "model_aliases": [ # {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"}, # {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4"} # ] diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 04927d61..f42b6811 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -292,6 +292,9 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { } } } + if requestRetry, okRetry := authFileRequestRetryFromJSON(data); okRetry { + fileData["request_retry"] = requestRetry + } } files = append(files, fileData) @@ -424,9 +427,20 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth) gin.H { if websockets, ok := authWebsocketsValue(auth); ok { entry["websockets"] = websockets } + if requestRetry, ok := auth.RequestRetryOverride(); ok { + entry["request_retry"] = requestRetry + } return entry } +func authFileRequestRetryFromJSON(data []byte) (int, bool) { + var metadata map[string]any + if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { + return 0, false + } + return (&coreauth.Auth{Metadata: metadata}).RequestRetryOverride() +} + func authWeightValue(auth *coreauth.Auth) (int64, bool) { if auth == nil { return 0, false diff --git a/internal/api/handlers/management/auth_files_crud.go b/internal/api/handlers/management/auth_files_crud.go index 496930ff..2c193b32 100644 --- a/internal/api/handlers/management/auth_files_crud.go +++ b/internal/api/handlers/management/auth_files_crud.go @@ -476,6 +476,7 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut if err := json.Unmarshal(data, &metadata); err != nil { return nil, fmt.Errorf("invalid auth file: %w", err) } + coreauth.NormalizeCredentialMetadata(metadata) provider, _ := metadata["type"].(string) if provider == "" { provider = "unknown" diff --git a/internal/api/handlers/management/auth_files_fields.go b/internal/api/handlers/management/auth_files_fields.go index 0ec92b6f..4d791425 100644 --- a/internal/api/handlers/management/auth_files_fields.go +++ b/internal/api/handlers/management/auth_files_fields.go @@ -169,6 +169,7 @@ func setSourceAuthFileDisabled(path string, disabled bool) error { if metadata == nil { metadata = make(map[string]any) } + coreauth.NormalizeCredentialMetadata(metadata) metadata["disabled"] = disabled raw, errMarshal := json.Marshal(metadata) if errMarshal != nil { @@ -230,6 +231,22 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { return } delete(req, "name") + var errNormalize error + req, errNormalize = normalizeAuthFilePatchFields(req) + if errNormalize != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errNormalize.Error()}) + return + } + requestRetryPatch, errRequestRetry := decodeAuthFileRequestRetryPatch(req) + if errRequestRetry != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errRequestRetry.Error()}) + return + } + for key := range req { + if strings.TrimSpace(key) == "request_retry" { + delete(req, key) + } + } ctx := c.Request.Context() @@ -255,6 +272,7 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) return } + coreauth.NormalizeCredentialMetadata(targetAuth.Metadata) changed := false touchedRoots := make(map[string]struct{}, len(req)) @@ -302,6 +320,17 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { } changed = true } + if requestRetryPatch.Set { + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if requestRetryPatch.Value == nil { + delete(targetAuth.Metadata, "request_retry") + } else { + targetAuth.Metadata["request_retry"] = *requestRetryPatch.Value + } + changed = true + } if changed { syncAuthFileMetadataFields(targetAuth, touchedRoots) } @@ -331,6 +360,84 @@ func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) { return value, nil } +type authFileRequestRetryPatch struct { + Set bool + Value *int +} + +func normalizeAuthFilePatchFields(fields map[string]json.RawMessage) (map[string]json.RawMessage, error) { + normalized := make(map[string]json.RawMessage, len(fields)) + originalNames := make(map[string]string, len(fields)) + canonicalNames := make(map[string]bool, len(fields)) + for key, value := range fields { + parts := strings.Split(strings.TrimSpace(key), ".") + for index := range parts { + parts[index] = strings.TrimSpace(parts[index]) + } + originalRoot := parts[0] + parts[0] = coreauth.CanonicalCredentialMetadataKey(originalRoot) + canonicalPath := strings.Join(parts, ".") + if original, exists := originalNames[canonicalPath]; exists { + currentCanonical := originalRoot == parts[0] + if canonicalNames[canonicalPath] != currentCanonical { + if currentCanonical { + normalized[canonicalPath] = value + originalNames[canonicalPath] = key + canonicalNames[canonicalPath] = true + } + continue + } + return nil, fmt.Errorf("auth file fields %q and %q refer to the same field", original, key) + } + normalized[canonicalPath] = value + originalNames[canonicalPath] = key + canonicalNames[canonicalPath] = originalRoot == parts[0] + } + return normalized, nil +} + +func decodeAuthFileRequestRetryPatch(fields map[string]json.RawMessage) (authFileRequestRetryPatch, error) { + var raw json.RawMessage + found := false + for key, value := range fields { + fieldPath := strings.TrimSpace(key) + fieldRoot := rootAuthFileField(fieldPath) + if fieldRoot == "request_retry" && fieldPath != fieldRoot { + return authFileRequestRetryPatch{}, fmt.Errorf("request_retry does not support nested fields") + } + if fieldPath == "request_retry" { + found = true + raw = value + } + } + if !found { + return authFileRequestRetryPatch{}, nil + } + value, errDecode := decodeAuthFileFieldValue(raw) + if errDecode != nil { + return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") + } + if value == nil { + return authFileRequestRetryPatch{Set: true}, nil + } + number, okNumber := value.(json.Number) + if !okNumber { + return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") + } + parsed, errInt := number.Int64() + if errInt != nil { + return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") + } + normalized := int(parsed) + if int64(normalized) != parsed { + return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") + } + if normalized < 0 { + return authFileRequestRetryPatch{Set: true}, nil + } + return authFileRequestRetryPatch{Set: true, Value: &normalized}, nil +} + func rootAuthFileField(path string) string { path = strings.TrimSpace(path) if path == "" { diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go index c37d3757..16368fd5 100644 --- a/internal/api/handlers/management/auth_files_patch_fields_test.go +++ b/internal/api/handlers/management/auth_files_patch_fields_test.go @@ -369,3 +369,232 @@ func TestPatchAuthFileFields_RejectsInvalidWeights(t *testing.T) { } } } + +func TestPatchAuthFileFields_RequestRetryRoundTrip(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + + authDir := t.TempDir() + fileName := "request-retry.json" + store := fileauth.NewFileTokenStore() + store.SetBaseDir(authDir) + manager := coreauth.NewManager(store, nil, nil) + record := &coreauth.Auth{ + ID: fileName, + FileName: fileName, + Provider: "codex", + Attributes: map[string]string{ + "path": filepath.Join(authDir, fileName), + }, + Metadata: map[string]any{"type": "codex"}, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } + + handler := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + engine := gin.New() + engine.GET("/auth-files", handler.ListAuthFiles) + engine.PATCH("/auth-files/fields", handler.PatchAuthFileFields) + + patch := func(body string) *httptest.ResponseRecorder { + t.Helper() + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/auth-files/fields", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(response, request) + return response + } + getRequestRetry := func() *int { + t.Helper() + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/auth-files", nil)) + if response.Code != http.StatusOK { + t.Fatalf("GET status = %d body=%s", response.Code, response.Body.String()) + } + var payload struct { + Files []struct { + Name string `json:"name"` + RequestRetry *int `json:"request_retry"` + } `json:"files"` + } + if errDecode := json.Unmarshal(response.Body.Bytes(), &payload); errDecode != nil { + t.Fatalf("decode GET response: %v", errDecode) + } + if len(payload.Files) != 1 || payload.Files[0].Name != fileName { + t.Fatalf("GET files = %#v", payload.Files) + } + return payload.Files[0].RequestRetry + } + + if response := patch(`{"name":"request-retry.json","request-retry":2}`); response.Code != http.StatusOK { + t.Fatalf("PATCH status = %d body=%s", response.Code, response.Body.String()) + } + updated, ok := manager.GetByID(fileName) + if !ok { + t.Fatal("updated auth is missing") + } + if retry, okRetry := updated.RequestRetryOverride(); !okRetry || retry != 2 { + t.Fatalf("RequestRetryOverride() = (%d, %t), want (2, true)", retry, okRetry) + } + if _, exists := updated.Metadata["request-retry"]; exists { + t.Fatalf("legacy request-retry metadata remains: %#v", updated.Metadata) + } + persistedData, errRead := os.ReadFile(filepath.Join(authDir, fileName)) + if errRead != nil { + t.Fatalf("read persisted auth: %v", errRead) + } + var persisted map[string]any + if errUnmarshal := json.Unmarshal(persistedData, &persisted); errUnmarshal != nil { + t.Fatalf("decode persisted auth: %v", errUnmarshal) + } + if persisted["request_retry"] != float64(2) { + t.Fatalf("persisted request_retry = %#v, want 2", persisted["request_retry"]) + } + if _, exists := persisted["request-retry"]; exists { + t.Fatalf("persisted legacy request-retry remains: %#v", persisted) + } + if retry := getRequestRetry(); retry == nil || *retry != 2 { + t.Fatalf("GET request_retry = %#v, want 2", retry) + } + + if response := patch(`{"name":"request-retry.json","request_retry":0}`); response.Code != http.StatusOK { + t.Fatalf("PATCH underscore status = %d body=%s", response.Code, response.Body.String()) + } + if retry := getRequestRetry(); retry == nil || *retry != 0 { + t.Fatalf("GET request_retry = %#v, want explicit 0", retry) + } + + if response := patch(`{"name":"request-retry.json","request-retry":-1}`); response.Code != http.StatusOK { + t.Fatalf("PATCH negative status = %d body=%s", response.Code, response.Body.String()) + } + if retry := getRequestRetry(); retry != nil { + t.Fatalf("GET request_retry after negative clear = %#v, want omitted", retry) + } + + if response := patch(`{"name":"request-retry.json","request_retry":2}`); response.Code != http.StatusOK { + t.Fatalf("PATCH reset status = %d body=%s", response.Code, response.Body.String()) + } + if response := patch(`{"name":"request-retry.json","request-retry":2,"request_retry":3}`); response.Code != http.StatusOK { + t.Fatalf("PATCH canonical precedence status = %d body=%s", response.Code, response.Body.String()) + } + if retry := getRequestRetry(); retry == nil || *retry != 3 { + t.Fatalf("GET request_retry after alias conflict = %#v, want canonical 3", retry) + } + if response := patch(`{"name":"request-retry.json","request_retry":2}`); response.Code != http.StatusOK { + t.Fatalf("PATCH second reset status = %d body=%s", response.Code, response.Body.String()) + } + for _, body := range []string{ + `{"name":"request-retry.json","request-retry":"2"}`, + `{"name":"request-retry.json","request-retry":1.5}`, + `{"name":"request-retry.json","request_retry.child":2}`, + `{"name":"request-retry.json","request_retry .child":2}`, + `{"name":"request-retry.json","request-retry .child":2}`, + } { + if response := patch(body); response.Code != http.StatusBadRequest { + t.Fatalf("PATCH %s status = %d, want 400 body=%s", body, response.Code, response.Body.String()) + } + if retry := getRequestRetry(); retry == nil || *retry != 2 { + t.Fatalf("invalid PATCH changed request_retry to %#v", retry) + } + } + + if response := patch(`{"name":"request-retry.json","request_retry":null}`); response.Code != http.StatusOK { + t.Fatalf("PATCH null status = %d body=%s", response.Code, response.Body.String()) + } + if retry := getRequestRetry(); retry != nil { + t.Fatalf("GET request_retry after null clear = %#v, want omitted", retry) + } +} + +func TestAuthFileRequestRetryFromJSON(t *testing.T) { + tests := []struct { + name string + raw string + want int + ok bool + }{ + {name: "canonical", raw: `{"request_retry":2}`, want: 2, ok: true}, + {name: "legacy", raw: `{"request-retry":2}`, want: 2, ok: true}, + {name: "negative inherits", raw: `{"request_retry":-1}`}, + {name: "string integer compatibility", raw: `{"request_retry":"2"}`, want: 2, ok: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := authFileRequestRetryFromJSON([]byte(test.raw)) + if got != test.want || ok != test.ok { + t.Fatalf("authFileRequestRetryFromJSON(%s) = (%d, %t), want (%d, %t)", test.raw, got, ok, test.want, test.ok) + } + }) + } +} + +func TestNormalizeAuthFilePatchFieldsCanonicalizesLegacyRoots(t *testing.T) { + fields := map[string]json.RawMessage{ + "request-retry": json.RawMessage(`2`), + " disable-cooling ": json.RawMessage(`true`), + "fingerprint-profile.value": json.RawMessage(`"x"`), + "provider-specific": json.RawMessage(`"preserved"`), + } + + normalized, errNormalize := normalizeAuthFilePatchFields(fields) + if errNormalize != nil { + t.Fatalf("normalizeAuthFilePatchFields() error = %v", errNormalize) + } + for _, key := range []string{"request_retry", "disable_cooling", "fingerprint_profile.value", "provider-specific"} { + if _, exists := normalized[key]; !exists { + t.Fatalf("normalized fields missing %q: %#v", key, normalized) + } + } + + canonicalWins, errCanonicalWins := normalizeAuthFilePatchFields(map[string]json.RawMessage{ + "request-retry": json.RawMessage(`2`), + "request_retry": json.RawMessage(`3`), + }) + if errCanonicalWins != nil { + t.Fatalf("normalizeAuthFilePatchFields() canonical precedence error = %v", errCanonicalWins) + } + if got := string(canonicalWins["request_retry"]); got != "3" { + t.Fatalf("normalized request_retry = %s, want canonical value 3", got) + } + + _, errNestedDuplicate := normalizeAuthFilePatchFields(map[string]json.RawMessage{ + "disable_cooling.value": json.RawMessage(`true`), + "disable_cooling . value": json.RawMessage(`false`), + }) + if errNestedDuplicate == nil { + t.Fatal("normalizeAuthFilePatchFields() accepted equivalent nested paths") + } +} + +func TestSetSourceAuthFileDisabledNormalizesLegacyMetadata(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"codex","request-retry":2,"disable-cooling":true}`), 0o600); errWrite != nil { + t.Fatalf("write legacy auth file: %v", errWrite) + } + + if errDisable := setSourceAuthFileDisabled(path, true); errDisable != nil { + t.Fatalf("setSourceAuthFileDisabled() error = %v", errDisable) + } + persistedData, errRead := os.ReadFile(path) + if errRead != nil { + t.Fatalf("read persisted auth file: %v", errRead) + } + var persisted map[string]any + if errUnmarshal := json.Unmarshal(persistedData, &persisted); errUnmarshal != nil { + t.Fatalf("decode persisted auth file: %v", errUnmarshal) + } + if got := persisted["request_retry"]; got != float64(2) { + t.Fatalf("persisted request_retry = %#v, want 2", got) + } + if got := persisted["disable_cooling"]; got != true { + t.Fatalf("persisted disable_cooling = %#v, want true", got) + } + if got := persisted["disabled"]; got != true { + t.Fatalf("persisted disabled = %#v, want true", got) + } + for _, legacy := range []string{"request-retry", "disable-cooling"} { + if _, exists := persisted[legacy]; exists { + t.Fatalf("persisted metadata retained %q: %#v", legacy, persisted) + } + } +} diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index a7af35b9..d87f9e2e 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -263,6 +263,14 @@ func (h *Handler) PutRequestRetry(c *gin.Context) { h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v }) } +// Max retry credentials +func (h *Handler) GetMaxRetryCredentials(c *gin.Context) { + c.JSON(200, gin.H{"max-retry-credentials": h.cfg.MaxRetryCredentials}) +} +func (h *Handler) PutMaxRetryCredentials(c *gin.Context) { + h.updateIntField(c, func(v int) { h.cfg.MaxRetryCredentials = v }) +} + // Max retry interval func (h *Handler) GetMaxRetryInterval(c *gin.Context) { c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval}) diff --git a/internal/api/server_management.go b/internal/api/server_management.go index c783a077..fef08895 100644 --- a/internal/api/server_management.go +++ b/internal/api/server_management.go @@ -108,6 +108,9 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/request-retry", s.mgmt.GetRequestRetry) mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry) mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry) + mgmt.GET("/max-retry-credentials", s.mgmt.GetMaxRetryCredentials) + mgmt.PUT("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials) + mgmt.PATCH("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials) mgmt.GET("/max-retry-interval", s.mgmt.GetMaxRetryInterval) mgmt.PUT("/max-retry-interval", s.mgmt.PutMaxRetryInterval) mgmt.PATCH("/max-retry-interval", s.mgmt.PutMaxRetryInterval) diff --git a/internal/pluginhost/auth_callbacks.go b/internal/pluginhost/auth_callbacks.go index 559de6b8..caa9e7d8 100644 --- a/internal/pluginhost/auth_callbacks.go +++ b/internal/pluginhost/auth_callbacks.go @@ -317,6 +317,7 @@ func (h *Host) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { return nil, fmt.Errorf("invalid auth file: %w", errUnmarshal) } + coreauth.NormalizeCredentialMetadata(metadata) provider, _ := metadata["type"].(string) if strings.TrimSpace(provider) == "" { provider = "unknown" diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go index 68752b40..fcdebb98 100644 --- a/internal/pluginhost/auth_provider.go +++ b/internal/pluginhost/auth_provider.go @@ -492,6 +492,7 @@ func mergedStorageJSON(raw []byte, metadata map[string]any, provider string) ([] if provider != "" { out["type"] = provider } + coreauth.NormalizeCredentialMetadata(out) if len(out) == 0 { return nil, fmt.Errorf("plugin token storage payload is empty") } diff --git a/internal/pluginhost/auth_provider_test.go b/internal/pluginhost/auth_provider_test.go index ed349541..dc7979a1 100644 --- a/internal/pluginhost/auth_provider_test.go +++ b/internal/pluginhost/auth_provider_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "testing" "time" @@ -371,6 +372,78 @@ func TestPluginTokenStorageMergesRawMetadataAndProviderType(t *testing.T) { } } +func TestPluginTokenStorageNormalizesCredentialMetadataKeys(t *testing.T) { + tests := []struct { + name string + rawJSON []byte + metadata map[string]any + want map[string]any + }{ + { + name: "legacy raw keys", + rawJSON: []byte(`{"request-retry":2,"disable-cooling":true,"provider-specific-key":"preserved"}`), + want: map[string]any{ + "request_retry": float64(2), + "disable_cooling": true, + "provider-specific-key": "preserved", + "type": "plugin-provider", + }, + }, + { + name: "canonical metadata wins", + rawJSON: []byte(`{"request-retry":2,"disable-cooling":true}`), + metadata: map[string]any{ + "request_retry": 0, + "disable_cooling": false, + }, + want: map[string]any{ + "request_retry": float64(0), + "disable_cooling": false, + "type": "plugin-provider", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + storage := &pluginTokenStorage{ + provider: "plugin-provider", + rawJSON: test.rawJSON, + } + storage.SetMetadata(test.metadata) + + outputs := map[string][]byte{ + "RawJSON": storage.RawJSON(), + } + path := filepath.Join(t.TempDir(), "auth.json") + if errSave := storage.SaveTokenToFile(path); errSave != nil { + t.Fatalf("SaveTokenToFile() error = %v", errSave) + } + saved, errReadFile := os.ReadFile(path) + if errReadFile != nil { + t.Fatalf("ReadFile(saved token) error = %v", errReadFile) + } + outputs["SaveTokenToFile"] = saved + + for outputName, payload := range outputs { + var decoded map[string]any + if errUnmarshal := json.Unmarshal(payload, &decoded); errUnmarshal != nil { + t.Fatalf("%s decode error = %v", outputName, errUnmarshal) + } + if !reflect.DeepEqual(decoded, test.want) { + t.Errorf("%s decoded = %#v, want %#v", outputName, decoded, test.want) + } + if _, exists := decoded["request-retry"]; exists { + t.Errorf("%s retained request-retry: %#v", outputName, decoded) + } + if _, exists := decoded["disable-cooling"]; exists { + t.Errorf("%s retained disable-cooling: %#v", outputName, decoded) + } + } + }) + } +} + func TestPluginTokenStorageSkipsUnchangedFile(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") if errWriteFile := os.WriteFile(path, []byte(`{"disabled":false,"token":"secret","type":"plugin-provider"}`), 0o600); errWriteFile != nil { diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 408cf6e5..649888ef 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -299,6 +299,7 @@ func cloneModelInfos(models []*ModelInfo) []*ModelInfo { // Supported channels: // - claude // - gemini +// - gemini-interactions // - vertex // - aistudio // - codex @@ -312,6 +313,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetClaudeModels() case "gemini": return GetGeminiModels() + case "gemini-interactions": + return GetGeminiModels() case "vertex": return GetGeminiVertexModels() case "aistudio": diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go index 2ef087e9..934802fb 100644 --- a/internal/registry/model_definitions_test.go +++ b/internal/registry/model_definitions_test.go @@ -2,6 +2,13 @@ package registry import "testing" +func TestGetStaticModelDefinitionsByChannelSupportsGeminiInteractions(t *testing.T) { + models := GetStaticModelDefinitionsByChannel("gemini-interactions") + if len(models) == 0 { + t.Fatal("GetStaticModelDefinitionsByChannel(gemini-interactions) returned no models") + } +} + func TestModelOverrideHeadersFromEmbeddedModels(t *testing.T) { const wantUA = "codex-tui/0.144.0 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.144.0)" got := ModelOverrideHeaders("gpt-5.6-luna") diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index 7d01363d..0bea92a5 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -370,6 +370,7 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string if auth == nil { return "", fmt.Errorf("auth filestore: auth is nil") } + cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { return "", fmt.Errorf("auth filestore: %w", errWeight) } @@ -652,6 +653,7 @@ func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if err = json.Unmarshal(data, &metadata); err != nil { return nil, fmt.Errorf("unmarshal auth json: %w", err) } + cliproxyauth.NormalizeCredentialMetadata(metadata) if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil { return nil, errWeight } diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go index 58ec6f25..093d0154 100644 --- a/internal/store/objectstore.go +++ b/internal/store/objectstore.go @@ -160,6 +160,7 @@ func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (s if auth == nil { return "", fmt.Errorf("object store: auth is nil") } + cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { return "", fmt.Errorf("object store: %w", errWeight) } @@ -577,6 +578,7 @@ func (s *ObjectTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Aut if err = json.Unmarshal(data, &metadata); err != nil { return nil, fmt.Errorf("unmarshal auth json: %w", err) } + cliproxyauth.NormalizeCredentialMetadata(metadata) if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil { return nil, errWeight } diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go index c9a22174..3e265424 100644 --- a/internal/store/postgresstore.go +++ b/internal/store/postgresstore.go @@ -211,6 +211,7 @@ func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (stri if auth == nil { return "", fmt.Errorf("postgres store: auth is nil") } + cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { return "", fmt.Errorf("postgres store: %w", errWeight) } @@ -322,6 +323,7 @@ func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) log.WithError(err).Warnf("postgres store: skipping auth %s with invalid json", id) continue } + cliproxyauth.NormalizeCredentialMetadata(metadata) if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil { log.WithError(errWeight).Warnf("postgres store: skipping auth %s with invalid weight", id) continue diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index a3c81463..41cb14eb 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -81,6 +81,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([ if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { return nil, nil } + coreauth.NormalizeCredentialMetadata(metadata) if errWeight := coreauth.ValidateAuthWeight(&coreauth.Auth{Metadata: metadata}); errWeight != nil { return nil, fmt.Errorf("invalid weight in %s: %w", filepath.Base(fullPath), errWeight) } @@ -108,6 +109,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([ if auth == nil { continue } + coreauth.NormalizeCredentialMetadata(auth.Metadata) if len(auths) > 1 { coreauth.MarkPluginVirtualAuth(auth, fullPath, index) } @@ -269,7 +271,7 @@ func compactPluginAuths(auths []*coreauth.Auth) []*coreauth.Auth { } // extractOAuthModelAliasesFromMetadata reads per-account model aliases from OAuth JSON metadata. -// Supports both "model_aliases" and "model-aliases" keys. +// "model_aliases" is canonical; "model-aliases" remains a legacy alias. func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAuthModelAlias { if metadata == nil { return nil @@ -299,12 +301,11 @@ func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAut } // extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata. -// Supports both "excluded_models" and "excluded-models" keys, and accepts both []string and []interface{}. +// "excluded_models" is canonical; "excluded-models" remains a legacy alias. func extractExcludedModelsFromMetadata(metadata map[string]any) []string { if metadata == nil { return nil } - // Try both key formats raw, ok := metadata["excluded_models"] if !ok { raw, ok = metadata["excluded-models"] diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 7ce77b45..24e343d9 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -132,7 +132,7 @@ func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) { } } -func TestFileSynthesizer_Synthesize_KimiFingerprintProfile(t *testing.T) { +func TestFileSynthesizer_Synthesize_LegacyKimiFingerprintProfile(t *testing.T) { tempDir := t.TempDir() authData := map[string]any{ "type": "kimi", @@ -166,8 +166,11 @@ func TestFileSynthesizer_Synthesize_KimiFingerprintProfile(t *testing.T) { if got := auths[0].Attributes["fingerprint_profile"]; got != "claude-code-cli" { t.Fatalf("attributes fingerprint_profile = %q, want claude-code-cli", got) } - if got, _ := auths[0].Metadata["fingerprint-profile"].(string); got != "claude-code-cli" { - t.Fatalf("metadata fingerprint-profile = %q, want claude-code-cli", got) + if got, _ := auths[0].Metadata["fingerprint_profile"].(string); got != "claude-code-cli" { + t.Fatalf("metadata fingerprint_profile = %q, want claude-code-cli", got) + } + if _, exists := auths[0].Metadata["fingerprint-profile"]; exists { + t.Fatalf("legacy fingerprint-profile was not normalized: %#v", auths[0].Metadata) } } @@ -678,7 +681,7 @@ func TestFileSynthesizer_Synthesize_OAuthModelAliases(t *testing.T) { authData := map[string]any{ "type": "codex", "email": "codex@example.com", - "model-aliases": []map[string]any{ + "model_aliases": []map[string]any{ {"name": " gpt-5.3-codex-spark ", "alias": " gpt-5.5 "}, {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4", "fork": true}, {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"}, diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 8abef89e..c9c4dee9 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -77,6 +77,7 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str if auth == nil { return "", fmt.Errorf("auth filestore: auth is nil") } + cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { return "", fmt.Errorf("auth filestore: %w", errWeight) } @@ -236,6 +237,7 @@ func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Au if err = json.Unmarshal(data, &metadata); err != nil { return nil, fmt.Errorf("unmarshal auth json: %w", err) } + cliproxyauth.NormalizeCredentialMetadata(metadata) if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil { return nil, errWeight } @@ -265,6 +267,7 @@ func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Au if auth == nil { continue } + cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if len(auths) > 1 { cliproxyauth.MarkPluginVirtualAuth(auth, path, index) } diff --git a/sdk/auth/filestore_test.go b/sdk/auth/filestore_test.go index e638ffee..4ce9883f 100644 --- a/sdk/auth/filestore_test.go +++ b/sdk/auth/filestore_test.go @@ -146,6 +146,66 @@ func TestFileTokenStoreSaveExistingMetadataSetsFileAttributes(t *testing.T) { } } +func TestFileTokenStoreNormalizesLegacyCredentialMetadata(t *testing.T) { + t.Run("save", func(t *testing.T) { + baseDir := t.TempDir() + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auth := &cliproxyauth.Auth{ + ID: "legacy-save.json", + FileName: "legacy-save.json", + Metadata: map[string]any{ + "type": "codex", + "request-retry": 2, + "request_retry": 0, + "disable-cooling": true, + }, + } + + path, errSave := store.Save(context.Background(), auth) + if errSave != nil { + t.Fatalf("Save() error = %v", errSave) + } + persisted, errRead := os.ReadFile(path) + if errRead != nil { + t.Fatalf("read saved auth file: %v", errRead) + } + want := []byte(`{"type":"codex","request_retry":0,"disable_cooling":true,"disabled":false}`) + if !jsonEqual(persisted, want) { + t.Fatalf("saved auth file = %s, want JSON equal to %s", persisted, want) + } + }) + + t.Run("list", func(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "legacy-list.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"codex","request-retry":2,"disable-cooling":true}`), 0o600); errWrite != nil { + t.Fatalf("write legacy auth file: %v", errWrite) + } + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 1 { + t.Fatalf("List() len = %d, want 1", len(auths)) + } + if got := auths[0].Metadata["request_retry"]; got != float64(2) { + t.Fatalf("listed request_retry = %#v, want 2", got) + } + if got := auths[0].Metadata["disable_cooling"]; got != true { + t.Fatalf("listed disable_cooling = %#v, want true", got) + } + for _, legacy := range []string{"request-retry", "disable-cooling"} { + if _, exists := auths[0].Metadata[legacy]; exists { + t.Fatalf("listed metadata retained %q: %#v", legacy, auths[0].Metadata) + } + } + }) +} + func TestFileTokenStoreSaveRejectsInvalidWeight(t *testing.T) { baseDir := t.TempDir() store := NewFileTokenStore() diff --git a/sdk/cliproxy/auth/conductor_lifecycle.go b/sdk/cliproxy/auth/conductor_lifecycle.go index bcf7846c..f5944fad 100644 --- a/sdk/cliproxy/auth/conductor_lifecycle.go +++ b/sdk/cliproxy/auth/conductor_lifecycle.go @@ -69,6 +69,7 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) { if auth == nil { return nil, nil } + NormalizeCredentialMetadata(auth.Metadata) if errWeight := ValidateAuthWeight(auth); errWeight != nil { return nil, fmt.Errorf("register auth: %w", errWeight) } @@ -105,6 +106,7 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { if auth == nil || auth.ID == "" { return nil, nil } + NormalizeCredentialMetadata(auth.Metadata) if errWeight := ValidateAuthWeight(auth); errWeight != nil { return nil, fmt.Errorf("update auth: %w", errWeight) } @@ -237,6 +239,7 @@ func (m *Manager) Load(ctx context.Context) error { if auth == nil || auth.ID == "" { continue } + NormalizeCredentialMetadata(auth.Metadata) if errWeight := ValidateAuthWeight(auth); errWeight != nil { continue } diff --git a/sdk/cliproxy/auth/conductor_request_scoped_errors.go b/sdk/cliproxy/auth/conductor_request_scoped_errors.go index 0da42314..b8281712 100644 --- a/sdk/cliproxy/auth/conductor_request_scoped_errors.go +++ b/sdk/cliproxy/auth/conductor_request_scoped_errors.go @@ -67,7 +67,11 @@ func (m *Manager) runtimeConfigSnapshot() *internalconfig.Config { // extractRequestScopedErrorRules retrieves the configured RequestScopedErrorRule list for an auth. func extractRequestScopedErrorRules(auth *Auth, cfg *internalconfig.Config) []internalconfig.RequestScopedErrorRule { if auth != nil && auth.Metadata != nil { - if raw, ok := auth.Metadata["request_scoped_errors"]; ok && raw != nil { + raw, ok := auth.Metadata["request_scoped_errors"] + if !ok { + raw, ok = auth.Metadata["request-scoped-errors"] + } + if ok && raw != nil { switch typed := raw.(type) { case []internalconfig.RequestScopedErrorRule: if len(typed) > 0 { diff --git a/sdk/cliproxy/auth/conductor_request_scoped_errors_test.go b/sdk/cliproxy/auth/conductor_request_scoped_errors_test.go index 7a03060d..4d0137b3 100644 --- a/sdk/cliproxy/auth/conductor_request_scoped_errors_test.go +++ b/sdk/cliproxy/auth/conductor_request_scoped_errors_test.go @@ -1183,6 +1183,23 @@ func (e wrappedResponseBodyError) ResponseBody() []byte { return e.body } +func TestExtractRequestScopedErrorRulesSupportsLegacyMetadataKey(t *testing.T) { + auth := &Auth{Metadata: map[string]any{ + "request-scoped-errors": []any{ + map[string]any{ + "status": float64(429), + "match": []any{"legacy-rate-limit"}, + "action": "stop", + }, + }, + }} + + rules := extractRequestScopedErrorRules(auth, nil) + if len(rules) != 1 || rules[0].Status != 429 || rules[0].Action != "stop" { + t.Fatalf("legacy request-scoped-errors rules = %#v", rules) + } +} + func TestRequestScopedErrors_ResponseBodyProvider_MatchesUnderlyingPayload(t *testing.T) { previous := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) diff --git a/sdk/cliproxy/auth/metadata_keys.go b/sdk/cliproxy/auth/metadata_keys.go new file mode 100644 index 00000000..e861cfed --- /dev/null +++ b/sdk/cliproxy/auth/metadata_keys.go @@ -0,0 +1,45 @@ +package auth + +// CanonicalCredentialMetadataKey returns the canonical snake_case name for +// credential metadata keys that previously also accepted config-style aliases. +func CanonicalCredentialMetadataKey(key string) string { + switch key { + case "api-key": + return "api_key" + case "base-url": + return "base_url" + case "disable-cooling": + return "disable_cooling" + case "excluded-models": + return "excluded_models" + case "fingerprint-profile": + return "fingerprint_profile" + case "model-aliases": + return "model_aliases" + case "proxy-url": + return "proxy_url" + case "request-retry": + return "request_retry" + case "request-scoped-errors": + return "request_scoped_errors" + case "tool-prefix-disabled": + return "tool_prefix_disabled" + default: + return key + } +} + +// NormalizeCredentialMetadata rewrites recognized legacy keys to their +// canonical snake_case names. An explicitly present canonical value wins. +func NormalizeCredentialMetadata(metadata map[string]any) { + for key, value := range metadata { + canonical := CanonicalCredentialMetadataKey(key) + if canonical == key { + continue + } + if _, exists := metadata[canonical]; !exists { + metadata[canonical] = value + } + delete(metadata, key) + } +} diff --git a/sdk/cliproxy/auth/metadata_keys_test.go b/sdk/cliproxy/auth/metadata_keys_test.go new file mode 100644 index 00000000..52dc7a0a --- /dev/null +++ b/sdk/cliproxy/auth/metadata_keys_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "context" + "reflect" + "testing" +) + +func TestNormalizeCredentialMetadata(t *testing.T) { + metadata := map[string]any{ + "api-key": "legacy-key", + "base-url": "https://legacy.example", + "disable-cooling": true, + "excluded-models": []any{"legacy-model"}, + "fingerprint-profile": "claude-code-cli", + "model-aliases": []any{map[string]any{"name": "upstream", "alias": "public"}}, + "proxy-url": "http://legacy-proxy.example", + "request-retry": 3, + "request_retry": 0, + "request-scoped-errors": []any{map[string]any{"status": 429}}, + "tool-prefix-disabled": true, + "provider_field": "preserved", + } + + NormalizeCredentialMetadata(metadata) + + want := map[string]any{ + "api_key": "legacy-key", + "base_url": "https://legacy.example", + "disable_cooling": true, + "excluded_models": []any{"legacy-model"}, + "fingerprint_profile": "claude-code-cli", + "model_aliases": []any{map[string]any{"name": "upstream", "alias": "public"}}, + "proxy_url": "http://legacy-proxy.example", + "request_retry": 0, + "request_scoped_errors": []any{map[string]any{"status": 429}}, + "tool_prefix_disabled": true, + "provider_field": "preserved", + } + if !reflect.DeepEqual(metadata, want) { + t.Fatalf("NormalizeCredentialMetadata() = %#v, want %#v", metadata, want) + } +} + +func TestCanonicalCredentialMetadataKeyPreservesUnknownKeys(t *testing.T) { + if got := CanonicalCredentialMetadataKey("provider-specific-key"); got != "provider-specific-key" { + t.Fatalf("CanonicalCredentialMetadataKey() = %q, want provider-specific-key", got) + } +} + +func TestManagerRegisterNormalizesCredentialMetadata(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "legacy-auth", + Provider: "codex", + Metadata: map[string]any{ + "request-retry": 2, + "request_retry": 0, + }, + } + + registered, errRegister := manager.Register(context.Background(), auth) + if errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } + if got, ok := registered.Metadata["request_retry"]; !ok || got != 0 { + t.Fatalf("registered request_retry = %#v, want 0", got) + } + if _, exists := registered.Metadata["request-retry"]; exists { + t.Fatalf("registered metadata retained legacy key: %#v", registered.Metadata) + } +} diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go index 83c7384e..6f8fa28f 100644 --- a/sdk/cliproxy/auth/types_test.go +++ b/sdk/cliproxy/auth/types_test.go @@ -36,12 +36,17 @@ func TestRequestRetryOverride(t *testing.T) { auth = &Auth{Metadata: map[string]any{"request-retry": 2}} if got, ok := auth.RequestRetryOverride(); !ok || got != 2 { - t.Fatalf("request-retry=2 override = (%d, %t), want (2, true)", got, ok) + t.Fatalf("legacy request-retry=2 override = (%d, %t), want (2, true)", got, ok) } auth = &Auth{Metadata: map[string]any{"request-retry": -2}} if got, ok := auth.RequestRetryOverride(); ok || got != 0 { - t.Fatalf("request-retry=-2 override = (%d, %t), want (0, false)", got, ok) + t.Fatalf("legacy request-retry=-2 override = (%d, %t), want (0, false)", got, ok) + } + + auth = &Auth{Metadata: map[string]any{"request_retry": 0, "request-retry": 2}} + if got, ok := auth.RequestRetryOverride(); !ok || got != 0 { + t.Fatalf("canonical request_retry precedence = (%d, %t), want (0, true)", got, ok) } auth = &Auth{Metadata: map[string]any{"request_retry": "0"}} -- 2.54.0 From 71c3c144a07853520e01900ce263771e82140181 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:20:53 +0800 Subject: [PATCH 2/3] fix(registry): detect gemini interactions changes --- internal/registry/model_updater.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 4c398fb1..8025f083 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -190,8 +190,8 @@ func fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) { } // detectChangedProviders compares two model catalogs and returns provider names -// whose model definitions differ. Codex tiers (free/team/plus/pro) are grouped -// under a single "codex" provider. +// whose model definitions differ. Gemini changes affect both Gemini protocols, +// while Codex tiers (free/team/plus/pro) are grouped under one "codex" provider. func detectChangedProviders(oldData, newData *staticModelsJSON) []string { if oldData == nil || newData == nil { return nil @@ -206,6 +206,7 @@ func detectChangedProviders(oldData, newData *staticModelsJSON) []string { sections := []section{ {"claude", oldData.Claude, newData.Claude}, {"gemini", oldData.Gemini, newData.Gemini}, + {"gemini-interactions", oldData.Gemini, newData.Gemini}, {"vertex", oldData.Vertex, newData.Vertex}, {"aistudio", oldData.AIStudio, newData.AIStudio}, {"codex", oldData.CodexFree, newData.CodexFree}, -- 2.54.0 From ebda7509114dd544ac4dd02f51614d36d570a2de Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:53:30 +0800 Subject: [PATCH 3/3] feat(auth): enhance key deletion and patching with base URL validation --- .../api/handlers/management/config_lists.go | 88 ++++++++++----- .../config_lists_delete_keys_test.go | 105 ++++++++++++++++++ 2 files changed, 167 insertions(+), 26 deletions(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 5cff5b9b..042568c2 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -220,11 +220,24 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { if targetIndex == -1 && body.Match != nil { match := strings.TrimSpace(*body.Match) if match != "" { + baseRaw, hasBase := c.GetQuery("base-url") + base := strings.TrimSpace(baseRaw) + matches := make([]int, 0, 1) for i := range h.cfg.GeminiKey { - if h.cfg.GeminiKey[i].APIKey == match { - targetIndex = i - break + if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) != match { + continue + } + if hasBase && strings.TrimSpace(h.cfg.GeminiKey[i].BaseURL) != base { + continue } + matches = append(matches, i) + } + if len(matches) > 1 { + c.JSON(400, gin.H{"error": "multiple items match; index is required"}) + return + } + if len(matches) == 1 { + targetIndex = matches[0] } } } @@ -286,20 +299,25 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) - out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey)) - for _, v := range h.cfg.GeminiKey { - if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { - continue + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.GeminiKey { + if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val && strings.TrimSpace(h.cfg.GeminiKey[i].BaseURL) == base { + matchIndex = i + matchCount++ } - out = append(out, v) } - if len(out) != len(h.cfg.GeminiKey) { - h.cfg.GeminiKey = out - h.cfg.SanitizeGeminiKeys() - h.persistLocked(c) - } else { + if matchCount == 0 { c.JSON(404, gin.H{"error": "item not found"}) + return } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match; index is required"}) + return + } + h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...) + h.cfg.SanitizeGeminiKeys() + h.persistLocked(c) return } @@ -405,11 +423,24 @@ func (h *Handler) PatchInteractionsKey(c *gin.Context) { if targetIndex == -1 && body.Match != nil { match := strings.TrimSpace(*body.Match) if match != "" { + baseRaw, hasBase := c.GetQuery("base-url") + base := strings.TrimSpace(baseRaw) + matches := make([]int, 0, 1) for i := range h.cfg.InteractionsKey { - if h.cfg.InteractionsKey[i].APIKey == match { - targetIndex = i - break + if strings.TrimSpace(h.cfg.InteractionsKey[i].APIKey) != match { + continue + } + if hasBase && strings.TrimSpace(h.cfg.InteractionsKey[i].BaseURL) != base { + continue } + matches = append(matches, i) + } + if len(matches) > 1 { + c.JSON(400, gin.H{"error": "multiple items match; index is required"}) + return + } + if len(matches) == 1 { + targetIndex = matches[0] } } } @@ -471,20 +502,25 @@ func (h *Handler) DeleteInteractionsKey(c *gin.Context) { if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) - out := make([]config.GeminiKey, 0, len(h.cfg.InteractionsKey)) - for _, v := range h.cfg.InteractionsKey { - if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { - continue + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.InteractionsKey { + if strings.TrimSpace(h.cfg.InteractionsKey[i].APIKey) == val && strings.TrimSpace(h.cfg.InteractionsKey[i].BaseURL) == base { + matchIndex = i + matchCount++ } - out = append(out, v) } - if len(out) != len(h.cfg.InteractionsKey) { - h.cfg.InteractionsKey = out - h.cfg.SanitizeInteractionsKeys() - h.persistLocked(c) - } else { + if matchCount == 0 { c.JSON(404, gin.H{"error": "item not found"}) + return } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match; index is required"}) + return + } + h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:matchIndex], h.cfg.InteractionsKey[matchIndex+1:]...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) return } diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go index 7451ee1f..630e5345 100644 --- a/internal/api/handlers/management/config_lists_delete_keys_test.go +++ b/internal/api/handlers/management/config_lists_delete_keys_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" @@ -79,6 +80,110 @@ func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) { } } +func TestDeleteGeminiStyleKeyRejectsAmbiguousRoutingIdentity(t *testing.T) { + tests := []struct { + name string + interactions bool + }{ + {name: "Gemini"}, + {name: "Interactions", interactions: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + entries := []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: "https://shared.example.com", Prefix: "team-a"}, + {APIKey: "shared-key", BaseURL: "https://shared.example.com", Prefix: "team-b"}, + } + cfg := &config.Config{} + path := "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://shared.example.com" + if tc.interactions { + cfg.InteractionsKey = entries + path = "/v0/management/interactions-api-key?api-key=shared-key&base-url=https://shared.example.com" + } else { + cfg.GeminiKey = entries + } + handler := &Handler{cfg: cfg, configFilePath: writeTestConfigFile(t)} + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodDelete, path, nil) + + if tc.interactions { + handler.DeleteInteractionsKey(ctx) + } else { + handler.DeleteGeminiKey(ctx) + } + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) + } + remaining := cfg.GeminiKey + if tc.interactions { + remaining = cfg.InteractionsKey + } + if len(remaining) != 2 { + t.Fatalf("remaining credential count = %d, want 2", len(remaining)) + } + }) + } +} + +func TestPatchGeminiStyleKeyRoutingIdentity(t *testing.T) { + tests := []struct { + name string + interactions bool + firstBase string + wantStatus int + }{ + {name: "Gemini unique base URL", firstBase: "https://first.example.com", wantStatus: http.StatusOK}, + {name: "Gemini ambiguous base URL", firstBase: "https://shared.example.com", wantStatus: http.StatusBadRequest}, + {name: "Interactions unique base URL", interactions: true, firstBase: "https://first.example.com", wantStatus: http.StatusOK}, + {name: "Interactions ambiguous base URL", interactions: true, firstBase: "https://shared.example.com", wantStatus: http.StatusBadRequest}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + entries := []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: tc.firstBase, Prefix: "team-a"}, + {APIKey: "shared-key", BaseURL: "https://shared.example.com", Prefix: "team-b"}, + } + cfg := &config.Config{} + path := "/v0/management/gemini-api-key?base-url=https://shared.example.com" + if tc.interactions { + cfg.InteractionsKey = entries + path = "/v0/management/interactions-api-key?base-url=https://shared.example.com" + } else { + cfg.GeminiKey = entries + } + handler := &Handler{cfg: cfg, configFilePath: writeTestConfigFile(t)} + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPatch, path, strings.NewReader(`{"match":"shared-key","value":{"prefix":"updated"}}`)) + + if tc.interactions { + handler.PatchInteractionsKey(ctx) + } else { + handler.PatchGeminiKey(ctx) + } + + if recorder.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tc.wantStatus, recorder.Body.String()) + } + remaining := cfg.GeminiKey + if tc.interactions { + remaining = cfg.InteractionsKey + } + if tc.wantStatus == http.StatusOK { + if remaining[0].Prefix != "team-a" || remaining[1].Prefix != "updated" { + t.Fatalf("prefixes = %q, %q; want team-a, updated", remaining[0].Prefix, remaining[1].Prefix) + } + } else if remaining[0].Prefix != "team-a" || remaining[1].Prefix != "team-b" { + t.Fatalf("ambiguous patch changed prefixes to %q, %q", remaining[0].Prefix, remaining[1].Prefix) + } + }) + } +} + func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) { t.Parallel()