Skip to content

Commit b487199

Browse files
committed
Merge remote-tracking branch 'upstream/main' into remote-fix
2 parents 9a28cd5 + e37f3be commit b487199

15 files changed

Lines changed: 644 additions & 70 deletions

File tree

.goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ builds:
1919
archives:
2020
- id: "cli-proxy-api-plus"
2121
format: tar.gz
22+
name_template: >-
23+
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{- if eq .Arch "arm64" -}}aarch64{{- else -}}{{ .Arch }}{{- end -}}
2224
format_overrides:
2325
- goos: windows
2426
format: zip
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package management
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"time"
7+
8+
"github.com/gin-gonic/gin"
9+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
10+
)
11+
12+
func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket {
13+
if len(dst) == 0 {
14+
return src
15+
}
16+
if len(src) == 0 {
17+
return dst
18+
}
19+
if len(dst) != len(src) {
20+
n := len(dst)
21+
if len(src) < n {
22+
n = len(src)
23+
}
24+
for i := 0; i < n; i++ {
25+
dst[i].Success += src[i].Success
26+
dst[i].Failed += src[i].Failed
27+
}
28+
return dst
29+
}
30+
for i := range dst {
31+
dst[i].Success += src[i].Success
32+
dst[i].Failed += src[i].Failed
33+
}
34+
return dst
35+
}
36+
37+
// GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths,
38+
// grouped by provider and keyed by the raw api-key value.
39+
func (h *Handler) GetAPIKeyUsage(c *gin.Context) {
40+
if h == nil {
41+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"})
42+
return
43+
}
44+
45+
h.mu.Lock()
46+
manager := h.authManager
47+
h.mu.Unlock()
48+
if manager == nil {
49+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
50+
return
51+
}
52+
53+
now := time.Now()
54+
out := make(map[string]map[string][]coreauth.RecentRequestBucket)
55+
for _, auth := range manager.List() {
56+
if auth == nil {
57+
continue
58+
}
59+
kind, apiKey := auth.AccountInfo()
60+
if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
61+
continue
62+
}
63+
apiKey = strings.TrimSpace(apiKey)
64+
if apiKey == "" {
65+
continue
66+
}
67+
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
68+
if provider == "" {
69+
provider = "unknown"
70+
}
71+
72+
recent := auth.RecentRequestsSnapshot(now)
73+
providerBucket, ok := out[provider]
74+
if !ok {
75+
providerBucket = make(map[string][]coreauth.RecentRequestBucket)
76+
out[provider] = providerBucket
77+
}
78+
if existing, exists := providerBucket[apiKey]; exists {
79+
providerBucket[apiKey] = mergeRecentRequestBuckets(existing, recent)
80+
continue
81+
}
82+
providerBucket[apiKey] = recent
83+
}
84+
85+
c.JSON(http.StatusOK, out)
86+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
12+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
13+
)
14+
15+
func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int64) {
16+
var success int64
17+
var failed int64
18+
for _, bucket := range buckets {
19+
success += bucket.Success
20+
failed += bucket.Failed
21+
}
22+
return success, failed
23+
}
24+
25+
func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) {
26+
t.Setenv("MANAGEMENT_PASSWORD", "")
27+
gin.SetMode(gin.TestMode)
28+
29+
manager := coreauth.NewManager(nil, nil, nil)
30+
if _, err := manager.Register(context.Background(), &coreauth.Auth{
31+
ID: "codex-auth",
32+
Provider: "codex",
33+
Attributes: map[string]string{
34+
"api_key": "codex-key",
35+
},
36+
}); err != nil {
37+
t.Fatalf("register codex auth: %v", err)
38+
}
39+
if _, err := manager.Register(context.Background(), &coreauth.Auth{
40+
ID: "claude-auth",
41+
Provider: "claude",
42+
Attributes: map[string]string{
43+
"api_key": "claude-key",
44+
},
45+
}); err != nil {
46+
t.Fatalf("register claude auth: %v", err)
47+
}
48+
49+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: true})
50+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: false})
51+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "claude-auth", Provider: "claude", Model: "claude-4", Success: true})
52+
53+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
54+
55+
rec := httptest.NewRecorder()
56+
ginCtx, _ := gin.CreateTestContext(rec)
57+
req := httptest.NewRequest(http.MethodGet, "/v0/management/api-key-usage", nil)
58+
ginCtx.Request = req
59+
h.GetAPIKeyUsage(ginCtx)
60+
61+
if rec.Code != http.StatusOK {
62+
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
63+
}
64+
65+
var payload map[string]map[string][]coreauth.RecentRequestBucket
66+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
67+
t.Fatalf("decode payload: %v", err)
68+
}
69+
70+
codexBuckets := payload["codex"]["codex-key"]
71+
if len(codexBuckets) != 20 {
72+
t.Fatalf("codex buckets len = %d, want 20", len(codexBuckets))
73+
}
74+
codexSuccess, codexFailed := sumRecentRequestBuckets(codexBuckets)
75+
if codexSuccess != 1 || codexFailed != 1 {
76+
t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed)
77+
}
78+
79+
claudeBuckets := payload["claude"]["claude-key"]
80+
if len(claudeBuckets) != 20 {
81+
t.Fatalf("claude buckets len = %d, want 20", len(claudeBuckets))
82+
}
83+
claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeBuckets)
84+
if claudeSuccess != 1 || claudeFailed != 0 {
85+
t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed)
86+
}
87+
}

internal/api/handlers/management/auth_files.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
399399
"source": "memory",
400400
"size": int64(0),
401401
}
402+
entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now())
402403
if email := authEmail(auth); email != "" {
403404
entry["email"] = email
404405
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
12+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
13+
)
14+
15+
func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) {
16+
t.Setenv("MANAGEMENT_PASSWORD", "")
17+
gin.SetMode(gin.TestMode)
18+
19+
manager := coreauth.NewManager(nil, nil, nil)
20+
record := &coreauth.Auth{
21+
ID: "runtime-only-auth-1",
22+
Provider: "codex",
23+
Attributes: map[string]string{
24+
"runtime_only": "true",
25+
},
26+
Metadata: map[string]any{
27+
"type": "codex",
28+
},
29+
}
30+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
31+
t.Fatalf("failed to register auth record: %v", errRegister)
32+
}
33+
34+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
35+
h.tokenStore = &memoryAuthStore{}
36+
37+
rec := httptest.NewRecorder()
38+
ginCtx, _ := gin.CreateTestContext(rec)
39+
req := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
40+
ginCtx.Request = req
41+
42+
h.ListAuthFiles(ginCtx)
43+
44+
if rec.Code != http.StatusOK {
45+
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
46+
}
47+
48+
var payload map[string]any
49+
if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
50+
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
51+
}
52+
filesRaw, ok := payload["files"].([]any)
53+
if !ok {
54+
t.Fatalf("expected files array, payload: %#v", payload)
55+
}
56+
if len(filesRaw) != 1 {
57+
t.Fatalf("expected 1 auth entry, got %d", len(filesRaw))
58+
}
59+
60+
fileEntry, ok := filesRaw[0].(map[string]any)
61+
if !ok {
62+
t.Fatalf("expected file entry object, got %#v", filesRaw[0])
63+
}
64+
65+
recentRaw, ok := fileEntry["recent_requests"].([]any)
66+
if !ok {
67+
t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"])
68+
}
69+
if len(recentRaw) != 20 {
70+
t.Fatalf("expected 20 recent_requests buckets, got %d", len(recentRaw))
71+
}
72+
for idx, item := range recentRaw {
73+
bucket, ok := item.(map[string]any)
74+
if !ok {
75+
t.Fatalf("expected bucket object at %d, got %#v", idx, item)
76+
}
77+
if _, ok := bucket["time"].(string); !ok {
78+
t.Fatalf("expected bucket time string at %d, got %#v", idx, bucket["time"])
79+
}
80+
if _, ok := bucket["success"].(float64); !ok {
81+
t.Fatalf("expected bucket success number at %d, got %#v", idx, bucket["success"])
82+
}
83+
if _, ok := bucket["failed"].(float64); !ok {
84+
t.Fatalf("expected bucket failed number at %d, got %#v", idx, bucket["failed"])
85+
}
86+
}
87+
}

internal/api/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ func (s *Server) registerManagementRoutes() {
596596
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
597597
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
598598
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
599+
mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
599600

600601
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
601602
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)

internal/logging/requestmeta.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package logging
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
)
7+
8+
type endpointKey struct{}
9+
type responseStatusKey struct{}
10+
11+
type responseStatusHolder struct {
12+
status atomic.Int32
13+
}
14+
15+
func WithEndpoint(ctx context.Context, endpoint string) context.Context {
16+
if ctx == nil {
17+
ctx = context.Background()
18+
}
19+
return context.WithValue(ctx, endpointKey{}, endpoint)
20+
}
21+
22+
func GetEndpoint(ctx context.Context) string {
23+
if ctx == nil {
24+
return ""
25+
}
26+
if endpoint, ok := ctx.Value(endpointKey{}).(string); ok {
27+
return endpoint
28+
}
29+
return ""
30+
}
31+
32+
func WithResponseStatusHolder(ctx context.Context) context.Context {
33+
if ctx == nil {
34+
ctx = context.Background()
35+
}
36+
if holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder); ok && holder != nil {
37+
return ctx
38+
}
39+
return context.WithValue(ctx, responseStatusKey{}, &responseStatusHolder{})
40+
}
41+
42+
func SetResponseStatus(ctx context.Context, status int) {
43+
if ctx == nil || status <= 0 {
44+
return
45+
}
46+
holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder)
47+
if !ok || holder == nil {
48+
return
49+
}
50+
holder.status.Store(int32(status))
51+
}
52+
53+
func GetResponseStatus(ctx context.Context) int {
54+
if ctx == nil {
55+
return 0
56+
}
57+
holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder)
58+
if !ok || holder == nil {
59+
return 0
60+
}
61+
return int(holder.status.Load())
62+
}

0 commit comments

Comments
 (0)