Skip to content

Commit 69aa302

Browse files
authored
Merge pull request #11 from leic4u/upstream-sync/20260501-1518
chore(upstream-sync): 2026-05-01 pull from router-for-me/CLIProxyAPI
2 parents 517fa3a + 14132d7 commit 69aa302

12 files changed

Lines changed: 470 additions & 72 deletions

File tree

.ccs-fork-upstream.env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
UPSTREAM_TAG=v6.9.45
2-
UPSTREAM_COMMIT=8b286e8fb39e1cc95dd86d8923e8baa83dec8722
1+
UPSTREAM_TAG=v6.9.46
2+
UPSTREAM_COMMIT=61879190002c267d70ad0dd3992c817ad0014b23

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/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+
}

internal/redisqueue/plugin.go

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ package redisqueue
33
import (
44
"context"
55
"encoding/json"
6-
"net/http"
76
"strings"
87
"time"
98

10-
"github.com/gin-gonic/gin"
119
internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
1210
internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
1311
coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
@@ -46,11 +44,6 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
4644
}
4745
apiKey := strings.TrimSpace(record.APIKey)
4846
requestID := strings.TrimSpace(internallogging.GetRequestID(ctx))
49-
if requestID == "" {
50-
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
51-
requestID = strings.TrimSpace(internallogging.GetGinRequestID(ginCtx))
52-
}
53-
}
5447

5548
tokens := internalusage.TokenStats{
5649
InputTokens: record.Detail.InputTokens,
@@ -106,40 +99,15 @@ type queuedUsageDetail struct {
10699
}
107100

108101
func resolveSuccess(ctx context.Context) bool {
109-
if ctx == nil {
110-
return true
111-
}
112-
ginCtx, ok := ctx.Value("gin").(*gin.Context)
113-
if !ok || ginCtx == nil {
114-
return true
115-
}
116-
status := ginCtx.Writer.Status()
102+
status := internallogging.GetResponseStatus(ctx)
117103
if status == 0 {
118104
return true
119105
}
120-
return status < http.StatusBadRequest
106+
return status < httpStatusBadRequest
121107
}
122108

123109
func resolveEndpoint(ctx context.Context) string {
124-
if ctx == nil {
125-
return ""
126-
}
127-
ginCtx, ok := ctx.Value("gin").(*gin.Context)
128-
if !ok || ginCtx == nil || ginCtx.Request == nil {
129-
return ""
130-
}
131-
132-
path := strings.TrimSpace(ginCtx.FullPath())
133-
if path == "" && ginCtx.Request.URL != nil {
134-
path = strings.TrimSpace(ginCtx.Request.URL.Path)
135-
}
136-
if path == "" {
137-
return ""
138-
}
139-
140-
method := strings.TrimSpace(ginCtx.Request.Method)
141-
if method == "" {
142-
return path
143-
}
144-
return method + " " + path
110+
return strings.TrimSpace(internallogging.GetEndpoint(ctx))
145111
}
112+
113+
const httpStatusBadRequest = 400

internal/redisqueue/plugin_test.go

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import (
1616

1717
func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
1818
withEnabledQueue(t, func() {
19-
ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK)
20-
internallogging.SetGinRequestID(ginCtx, "gin-request-id-ignored")
21-
ctx := context.WithValue(internallogging.WithRequestID(context.Background(), "ctx-request-id"), "gin", ginCtx)
19+
ctx := internallogging.WithRequestID(context.Background(), "ctx-request-id")
20+
ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
21+
ctx = internallogging.WithResponseStatusHolder(ctx)
22+
internallogging.SetResponseStatus(ctx, http.StatusOK)
2223

2324
plugin := &usageQueuePlugin{}
2425
plugin.HandleUsage(ctx, coreusage.Record{
@@ -49,9 +50,10 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
4950

5051
func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t *testing.T) {
5152
withEnabledQueue(t, func() {
52-
ginCtx := newTestGinContext(t, http.MethodGet, "/v1/responses", http.StatusInternalServerError)
53-
internallogging.SetGinRequestID(ginCtx, "gin-request-id")
54-
ctx := context.WithValue(context.Background(), "gin", ginCtx)
53+
ctx := internallogging.WithRequestID(context.Background(), "gin-request-id")
54+
ctx = internallogging.WithEndpoint(ctx, "GET /v1/responses")
55+
ctx = internallogging.WithResponseStatusHolder(ctx)
56+
internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
5557

5658
plugin := &usageQueuePlugin{}
5759
plugin.HandleUsage(ctx, coreusage.Record{
@@ -80,6 +82,47 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t
8082
})
8183
}
8284

85+
func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) {
86+
withEnabledQueue(t, func() {
87+
ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK)
88+
ctx := context.WithValue(context.Background(), "gin", ginCtx)
89+
ctx = internallogging.WithRequestID(ctx, "ctx-request-id")
90+
ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
91+
ctx = internallogging.WithResponseStatusHolder(ctx)
92+
internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
93+
94+
mgr := coreusage.NewManager(16)
95+
defer mgr.Stop()
96+
97+
mgr.Register(pluginFunc(func(_ context.Context, _ coreusage.Record) {
98+
ginCtx.Request = httptest.NewRequest(http.MethodGet, "http://example.com/v1/responses", nil)
99+
ginCtx.Status(http.StatusOK)
100+
}))
101+
mgr.Register(&usageQueuePlugin{})
102+
103+
mgr.Publish(ctx, coreusage.Record{
104+
Provider: "openai",
105+
Model: "gpt-5.4",
106+
APIKey: "test-key",
107+
AuthIndex: "0",
108+
AuthType: "apikey",
109+
Source: "user@example.com",
110+
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
111+
Latency: 1500 * time.Millisecond,
112+
Detail: coreusage.Detail{
113+
InputTokens: 10,
114+
OutputTokens: 20,
115+
TotalTokens: 30,
116+
},
117+
})
118+
119+
payload := waitForSinglePayload(t, 2*time.Second)
120+
requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
121+
requireStringField(t, payload, "request_id", "ctx-request-id")
122+
requireBoolField(t, payload, "failed", true)
123+
})
124+
}
125+
83126
func withEnabledQueue(t *testing.T, fn func()) {
84127
t.Helper()
85128

@@ -127,6 +170,29 @@ func popSinglePayload(t *testing.T) map[string]json.RawMessage {
127170
return payload
128171
}
129172

173+
func waitForSinglePayload(t *testing.T, timeout time.Duration) map[string]json.RawMessage {
174+
t.Helper()
175+
176+
deadline := time.Now().Add(timeout)
177+
for time.Now().Before(deadline) {
178+
items := PopOldest(10)
179+
if len(items) == 0 {
180+
time.Sleep(10 * time.Millisecond)
181+
continue
182+
}
183+
if len(items) != 1 {
184+
t.Fatalf("PopOldest() items = %d, want 1", len(items))
185+
}
186+
var payload map[string]json.RawMessage
187+
if err := json.Unmarshal(items[0], &payload); err != nil {
188+
t.Fatalf("unmarshal payload: %v", err)
189+
}
190+
return payload
191+
}
192+
t.Fatalf("timeout waiting for queued payload")
193+
return nil
194+
}
195+
130196
func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, want string) {
131197
t.Helper()
132198

@@ -143,6 +209,12 @@ func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, w
143209
}
144210
}
145211

212+
type pluginFunc func(context.Context, coreusage.Record)
213+
214+
func (fn pluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) {
215+
fn(ctx, record)
216+
}
217+
146218
func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) {
147219
t.Helper()
148220

0 commit comments

Comments
 (0)