From b8bba053fcdafd80abc2152c88c78f4e7713c05a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 03:40:00 +0800 Subject: [PATCH] feat: add tracking for auth request success and failure counts - Introduced `Success` and `Failed` fields in auth records to track request outcomes. - Updated `/v0/management/auth-files` and `/v0/management/api-key-usage` responses to include success and failure counts. - Enhanced tests to validate tracking logic and API responses. --- .../api/handlers/management/api_key_usage.go | 21 ++++++-- .../handlers/management/api_key_usage_test.go | 24 +++++---- .../api/handlers/management/auth_files.go | 2 + .../auth_files_recent_requests_test.go | 7 +++ sdk/cliproxy/auth/conductor.go | 8 +++ .../auth/conductor_recent_requests_test.go | 51 +++++++++++++++++++ sdk/cliproxy/auth/types.go | 3 ++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go index 76b32bbb6..3361da5d2 100644 --- a/internal/api/handlers/management/api_key_usage.go +++ b/internal/api/handlers/management/api_key_usage.go @@ -9,6 +9,12 @@ import ( coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) +type apiKeyUsageEntry struct { + Success int64 `json:"success"` + Failed int64 `json:"failed"` + RecentRequests []coreauth.RecentRequestBucket `json:"recent_requests"` +} + func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket { if len(dst) == 0 { return src @@ -51,7 +57,7 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { } now := time.Now() - out := make(map[string]map[string][]coreauth.RecentRequestBucket) + out := make(map[string]map[string]apiKeyUsageEntry) for _, auth := range manager.List() { if auth == nil { continue @@ -80,14 +86,21 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { recent := auth.RecentRequestsSnapshot(now) providerBucket, ok := out[provider] if !ok { - providerBucket = make(map[string][]coreauth.RecentRequestBucket) + providerBucket = make(map[string]apiKeyUsageEntry) out[provider] = providerBucket } if existing, exists := providerBucket[compositeKey]; exists { - providerBucket[compositeKey] = mergeRecentRequestBuckets(existing, recent) + existing.Success += auth.Success + existing.Failed += auth.Failed + existing.RecentRequests = mergeRecentRequestBuckets(existing.RecentRequests, recent) + providerBucket[compositeKey] = existing continue } - providerBucket[compositeKey] = recent + providerBucket[compositeKey] = apiKeyUsageEntry{ + Success: auth.Success, + Failed: auth.Failed, + RecentRequests: recent, + } } c.JSON(http.StatusOK, out) diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go index 56617161c..2880567f8 100644 --- a/internal/api/handlers/management/api_key_usage_test.go +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -64,25 +64,31 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - var payload map[string]map[string][]coreauth.RecentRequestBucket + var payload map[string]map[string]apiKeyUsageEntry if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode payload: %v", err) } - codexBuckets := payload["codex"]["https://codex.example.com|codex-key"] - if len(codexBuckets) != 20 { - t.Fatalf("codex buckets len = %d, want 20", len(codexBuckets)) + codexEntry := payload["codex"]["https://codex.example.com|codex-key"] + if codexEntry.Success != 1 || codexEntry.Failed != 1 { + t.Fatalf("codex totals = %d/%d, want 1/1", codexEntry.Success, codexEntry.Failed) } - codexSuccess, codexFailed := sumRecentRequestBuckets(codexBuckets) + if len(codexEntry.RecentRequests) != 20 { + t.Fatalf("codex buckets len = %d, want 20", len(codexEntry.RecentRequests)) + } + codexSuccess, codexFailed := sumRecentRequestBuckets(codexEntry.RecentRequests) if codexSuccess != 1 || codexFailed != 1 { t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed) } - claudeBuckets := payload["claude"]["https://claude.example.com|claude-key"] - if len(claudeBuckets) != 20 { - t.Fatalf("claude buckets len = %d, want 20", len(claudeBuckets)) + claudeEntry := payload["claude"]["https://claude.example.com|claude-key"] + if claudeEntry.Success != 1 || claudeEntry.Failed != 0 { + t.Fatalf("claude totals = %d/%d, want 1/0", claudeEntry.Success, claudeEntry.Failed) } - claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeBuckets) + if len(claudeEntry.RecentRequests) != 20 { + t.Fatalf("claude buckets len = %d, want 20", len(claudeEntry.RecentRequests)) + } + claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeEntry.RecentRequests) if claudeSuccess != 1 || claudeFailed != 0 { t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed) } diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2bcfaac4e..bb94daa9a 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -388,6 +388,8 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { "source": "memory", "size": int64(0), } + entry["success"] = auth.Success + entry["failed"] = auth.Failed entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now()) if email := authEmail(auth); email != "" { entry["email"] = email diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go index fd28ca1df..979040f58 100644 --- a/internal/api/handlers/management/auth_files_recent_requests_test.go +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -62,6 +62,13 @@ func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { t.Fatalf("expected file entry object, got %#v", filesRaw[0]) } + if _, ok := fileEntry["success"].(float64); !ok { + t.Fatalf("expected success number, got %#v", fileEntry["success"]) + } + if _, ok := fileEntry["failed"].(float64); !ok { + t.Fatalf("expected failed number, got %#v", fileEntry["failed"]) + } + recentRaw, ok := fileEntry["recent_requests"].([]any) if !ok { t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"]) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 61a0e4135..d2a3db188 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1126,6 +1126,9 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { auth.Index = existing.Index auth.indexAssigned = existing.indexAssigned } + auth.Success = existing.Success + auth.Failed = existing.Failed + auth.recentRequests = existing.recentRequests if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled { if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 { auth.ModelStates = existing.ModelStates @@ -2022,6 +2025,11 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { if auth, ok := m.auths[result.AuthID]; ok && auth != nil { now := time.Now() auth.recordRecentRequest(now, result.Success) + if result.Success { + auth.Success++ + } else { + auth.Failed++ + } if result.Success { if result.Model != "" { diff --git a/sdk/cliproxy/auth/conductor_recent_requests_test.go b/sdk/cliproxy/auth/conductor_recent_requests_test.go index 3f5a72126..d2003b7cc 100644 --- a/sdk/cliproxy/auth/conductor_recent_requests_test.go +++ b/sdk/cliproxy/auth/conductor_recent_requests_test.go @@ -31,6 +31,10 @@ func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) } + if gotAuth.Success != 1 || gotAuth.Failed != 1 { + t.Fatalf("auth totals = success=%d failed=%d, want 1/1", gotAuth.Success, gotAuth.Failed) + } + snapshot := gotAuth.RecentRequestsSnapshot(time.Now()) var successTotal int64 var failedTotal int64 @@ -42,3 +46,50 @@ func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { t.Fatalf("totals = success=%d failed=%d, want 1/1", successTotal, failedTotal) } } + +func TestManagerUpdatePreservesRecentRequestsAndTotals(t *testing.T) { + mgr := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Metadata: map[string]any{ + "type": "antigravity", + }, + } + if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: true}) + + updated := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Metadata: map[string]any{ + "type": "antigravity", + "note": "updated", + }, + } + if _, err := mgr.Update(WithSkipPersist(context.Background()), updated); err != nil { + t.Fatalf("Update returned error: %v", err) + } + + gotAuth, ok := mgr.GetByID("auth-1") + if !ok || gotAuth == nil { + t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) + } + if gotAuth.Success != 1 || gotAuth.Failed != 0 { + t.Fatalf("auth totals = success=%d failed=%d, want 1/0", gotAuth.Success, gotAuth.Failed) + } + + snapshot := gotAuth.RecentRequestsSnapshot(time.Now()) + var successTotal int64 + var failedTotal int64 + for _, bucket := range snapshot { + successTotal += bucket.Success + failedTotal += bucket.Failed + } + if successTotal != 1 || failedTotal != 0 { + t.Fatalf("bucket totals = success=%d failed=%d, want 1/0", successTotal, failedTotal) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 4a394ad48..76f4c396c 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -92,6 +92,9 @@ type Auth struct { // Runtime carries non-serialisable data used during execution (in-memory only). Runtime any `json:"-"` + Success int64 `json:"-"` + Failed int64 `json:"-"` + recentRequests recentRequestRing `json:"-"` indexAssigned bool `json:"-"` }