diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 8f7b8c5e..2bcfaac4 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -388,6 +388,7 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { "source": "memory", "size": int64(0), } + 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 new file mode 100644 index 00000000..fd28ca1d --- /dev/null +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -0,0 +1,87 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + manager := coreauth.NewManager(nil, nil, nil) + record := &coreauth.Auth{ + ID: "runtime-only-auth-1", + Provider: "codex", + Attributes: map[string]string{ + "runtime_only": "true", + }, + Metadata: map[string]any{ + "type": "codex", + }, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("failed to register auth record: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + h.tokenStore = &memoryAuthStore{} + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil) + ginCtx.Request = req + + h.ListAuthFiles(ginCtx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var payload map[string]any + if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatalf("failed to decode list payload: %v", errUnmarshal) + } + filesRaw, ok := payload["files"].([]any) + if !ok { + t.Fatalf("expected files array, payload: %#v", payload) + } + if len(filesRaw) != 1 { + t.Fatalf("expected 1 auth entry, got %d", len(filesRaw)) + } + + fileEntry, ok := filesRaw[0].(map[string]any) + if !ok { + t.Fatalf("expected file entry object, got %#v", filesRaw[0]) + } + + recentRaw, ok := fileEntry["recent_requests"].([]any) + if !ok { + t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"]) + } + if len(recentRaw) != 20 { + t.Fatalf("expected 20 recent_requests buckets, got %d", len(recentRaw)) + } + for idx, item := range recentRaw { + bucket, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected bucket object at %d, got %#v", idx, item) + } + if _, ok := bucket["time"].(string); !ok { + t.Fatalf("expected bucket time string at %d, got %#v", idx, bucket["time"]) + } + if _, ok := bucket["success"].(float64); !ok { + t.Fatalf("expected bucket success number at %d, got %#v", idx, bucket["success"]) + } + if _, ok := bucket["failed"].(float64); !ok { + t.Fatalf("expected bucket failed number at %d, got %#v", idx, bucket["failed"]) + } + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 6571518d..61a0e413 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2021,6 +2021,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { m.mu.Lock() if auth, ok := m.auths[result.AuthID]; ok && auth != nil { now := time.Now() + auth.recordRecentRequest(now, result.Success) 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 new file mode 100644 index 00000000..3f5a7212 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_recent_requests_test.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "testing" + "time" +) + +func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { + mgr := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Attributes: map[string]string{ + "runtime_only": "true", + }, + 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}) + mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: false}) + + gotAuth, ok := mgr.GetByID("auth-1") + if !ok || gotAuth == nil { + t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) + } + + 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 != 1 { + t.Fatalf("totals = success=%d failed=%d, want 1/1", successTotal, failedTotal) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index f30f4dc0..93dd3881 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -92,7 +92,29 @@ type Auth struct { // Runtime carries non-serialisable data used during execution (in-memory only). Runtime any `json:"-"` - indexAssigned bool `json:"-"` + recentRequests recentRequestRing `json:"-"` + indexAssigned bool `json:"-"` +} + +const ( + recentRequestBucketSeconds int64 = 10 * 60 + recentRequestBucketCount = 20 +) + +type recentRequestBucket struct { + bucketID int64 + success int64 + failed int64 +} + +type recentRequestRing struct { + buckets [recentRequestBucketCount]recentRequestBucket +} + +type RecentRequestBucket struct { + Time string `json:"time"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` } // QuotaState contains limiter tracking data for a credential. @@ -125,6 +147,70 @@ type ModelState struct { UpdatedAt time.Time `json:"updated_at"` } +func recentRequestBucketID(now time.Time) int64 { + if now.IsZero() { + return 0 + } + return now.Unix() / recentRequestBucketSeconds +} + +func recentRequestBucketIndex(bucketID int64) int { + mod := bucketID % int64(recentRequestBucketCount) + if mod < 0 { + mod += int64(recentRequestBucketCount) + } + return int(mod) +} + +func formatRecentRequestBucketLabel(bucketID int64) string { + start := time.Unix(bucketID*recentRequestBucketSeconds, 0).In(time.Local) + end := start.Add(10 * time.Minute) + return start.Format("15:04") + "-" + end.Format("15:04") +} + +func (a *Auth) recordRecentRequest(now time.Time, success bool) { + if a == nil { + return + } + bucketID := recentRequestBucketID(now) + idx := recentRequestBucketIndex(bucketID) + bucket := &a.recentRequests.buckets[idx] + if bucket.bucketID != bucketID { + bucket.bucketID = bucketID + bucket.success = 0 + bucket.failed = 0 + } + if success { + bucket.success++ + return + } + bucket.failed++ +} + +func (a *Auth) RecentRequestsSnapshot(now time.Time) []RecentRequestBucket { + out := make([]RecentRequestBucket, 0, recentRequestBucketCount) + if a == nil { + return out + } + + currentBucketID := recentRequestBucketID(now) + for i := recentRequestBucketCount - 1; i >= 0; i-- { + bucketID := currentBucketID - int64(i) + idx := recentRequestBucketIndex(bucketID) + bucket := a.recentRequests.buckets[idx] + entry := RecentRequestBucket{ + Time: formatRecentRequestBucketLabel(bucketID), + } + if bucket.bucketID == bucketID { + entry.Success = bucket.success + entry.Failed = bucket.failed + } + out = append(out, entry) + } + + return out +} + // Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation. func (a *Auth) Clone() *Auth { if a == nil { diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go index e7029385..06836da1 100644 --- a/sdk/cliproxy/auth/types_test.go +++ b/sdk/cliproxy/auth/types_test.go @@ -1,6 +1,10 @@ package auth -import "testing" +import ( + "strings" + "testing" + "time" +) func TestToolPrefixDisabled(t *testing.T) { var a *Auth @@ -96,3 +100,72 @@ func TestEnsureIndexUsesCredentialIdentity(t *testing.T) { t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex) } } + +func TestRecentRequestsSnapshotEmptyReturnsTwentyBuckets(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + a := &Auth{} + + got := a.RecentRequestsSnapshot(now) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + currentBucketID := now.Unix() / recentRequestBucketSeconds + baseBucketID := currentBucketID - int64(recentRequestBucketCount-1) + for i, bucket := range got { + if bucket.Success != 0 || bucket.Failed != 0 { + t.Fatalf("bucket[%d] counts = %d/%d, want 0/0", i, bucket.Success, bucket.Failed) + } + if strings.TrimSpace(bucket.Time) == "" { + t.Fatalf("bucket[%d] time label is empty", i) + } + expectedBucketID := baseBucketID + int64(i) + start := time.Unix(expectedBucketID*recentRequestBucketSeconds, 0).In(time.Local) + end := start.Add(10 * time.Minute) + expected := start.Format("15:04") + "-" + end.Format("15:04") + if bucket.Time != expected { + t.Fatalf("bucket[%d] time = %q, want %q", i, bucket.Time, expected) + } + } +} + +func TestRecentRequestsSnapshotIncludesCounts(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + a := &Auth{} + + a.recordRecentRequest(now, true) + a.recordRecentRequest(now, false) + + got := a.RecentRequestsSnapshot(now) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + newest := got[len(got)-1] + if newest.Success != 1 || newest.Failed != 1 { + t.Fatalf("newest bucket = success=%d failed=%d, want 1/1", newest.Success, newest.Failed) + } +} + +func TestRecentRequestsSnapshotBucketAdvanceMovesCounts(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + next := now.Add(10 * time.Minute) + a := &Auth{} + + a.recordRecentRequest(now, true) + a.recordRecentRequest(next, false) + + got := a.RecentRequestsSnapshot(next) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + secondNewest := got[len(got)-2] + newest := got[len(got)-1] + if secondNewest.Success != 1 || secondNewest.Failed != 0 { + t.Fatalf("second newest bucket = success=%d failed=%d, want 1/0", secondNewest.Success, secondNewest.Failed) + } + if newest.Success != 0 || newest.Failed != 1 { + t.Fatalf("newest bucket = success=%d failed=%d, want 0/1", newest.Success, newest.Failed) + } +}