Skip to content

Commit 57875eb

Browse files
authored
Merge pull request #54 from kaitranntt/kai/fix/52-upstream-sync-usage-stats
fix: restore usage statistics support
2 parents 3323dd5 + 4fda8ca commit 57875eb

27 files changed

Lines changed: 1494 additions & 393 deletions

.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.10.0
2+
UPSTREAM_COMMIT=18bb9c315fced2c428f57b4a0e66b06183c46c06

.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

cmd/server/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
2626
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
2727
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
28+
"github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue"
2829
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
2930
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
3031
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
@@ -470,6 +471,7 @@ func main() {
470471
}
471472
}
472473
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
474+
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
473475
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
474476

475477
if err = logging.ConfigureLogOutput(cfg); err != nil {

docker-build.sh

Lines changed: 5 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -5,123 +5,13 @@
55
# This script automates the process of building and running the Docker container
66
# with version information dynamically injected at build time.
77

8-
# Hidden feature: Preserve usage statistics across rebuilds
9-
# Usage: ./docker-build.sh --with-usage
10-
# First run prompts for management API key, saved to temp/stats/.api_secret
11-
128
set -euo pipefail
139

14-
STATS_DIR="temp/stats"
15-
STATS_FILE="${STATS_DIR}/.usage_backup.json"
16-
SECRET_FILE="${STATS_DIR}/.api_secret"
17-
WITH_USAGE=false
18-
19-
get_port() {
20-
if [[ -f "config.yaml" ]]; then
21-
grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
22-
else
23-
echo "8317"
24-
fi
25-
}
26-
27-
export_stats_api_secret() {
28-
if [[ -f "${SECRET_FILE}" ]]; then
29-
API_SECRET=$(cat "${SECRET_FILE}")
30-
else
31-
if [[ ! -d "${STATS_DIR}" ]]; then
32-
mkdir -p "${STATS_DIR}"
33-
fi
34-
echo "First time using --with-usage. Management API key required."
35-
read -r -p "Enter management key: " -s API_SECRET
36-
echo
37-
echo "${API_SECRET}" > "${SECRET_FILE}"
38-
chmod 600 "${SECRET_FILE}"
39-
fi
40-
}
41-
42-
check_container_running() {
43-
local port
44-
port=$(get_port)
45-
46-
if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
47-
echo "Error: cli-proxy-api service is not responding at localhost:${port}"
48-
echo "Please start the container first or use without --with-usage flag."
49-
exit 1
50-
fi
51-
}
52-
53-
export_stats() {
54-
local port
55-
port=$(get_port)
56-
57-
if [[ ! -d "${STATS_DIR}" ]]; then
58-
mkdir -p "${STATS_DIR}"
59-
fi
60-
check_container_running
61-
echo "Exporting usage statistics..."
62-
EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
63-
"http://localhost:${port}/v0/management/usage/export")
64-
HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
65-
RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
66-
67-
if [[ "${HTTP_CODE}" != "200" ]]; then
68-
echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
69-
exit 1
70-
fi
71-
72-
echo "${RESPONSE_BODY}" > "${STATS_FILE}"
73-
echo "Statistics exported to ${STATS_FILE}"
74-
}
75-
76-
import_stats() {
77-
local port
78-
port=$(get_port)
79-
80-
echo "Importing usage statistics..."
81-
IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
82-
-H "X-Management-Key: ${API_SECRET}" \
83-
-H "Content-Type: application/json" \
84-
-d @"${STATS_FILE}" \
85-
"http://localhost:${port}/v0/management/usage/import")
86-
IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
87-
IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
88-
89-
if [[ "${IMPORT_CODE}" == "200" ]]; then
90-
echo "Statistics imported successfully"
91-
else
92-
echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
93-
fi
94-
95-
rm -f "${STATS_FILE}"
96-
}
97-
98-
wait_for_service() {
99-
local port
100-
port=$(get_port)
101-
102-
echo "Waiting for service to be ready..."
103-
for i in {1..30}; do
104-
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
105-
break
106-
fi
107-
sleep 1
108-
done
109-
sleep 2
110-
}
111-
112-
case "${1:-}" in
113-
"")
114-
;;
115-
"--with-usage")
116-
WITH_USAGE=true
117-
export_stats_api_secret
118-
;;
119-
*)
120-
echo "Error: unknown option '${1}'. Did you mean '--with-usage'?"
121-
echo "Usage: ./docker-build.sh [--with-usage]"
122-
exit 1
123-
;;
124-
esac
10+
if [[ "${1:-}" != "" ]]; then
11+
echo "Error: unknown option '${1}'."
12+
echo "Usage: ./docker-build.sh"
13+
exit 1
14+
fi
12515

12616
# --- Step 1: Choose Environment ---
12717
echo "Please select an option:"
@@ -133,14 +23,7 @@ read -r -p "Enter choice [1-2]: " choice
13323
case "$choice" in
13424
1)
13525
echo "--- Running with Pre-built Image ---"
136-
if [[ "${WITH_USAGE}" == "true" ]]; then
137-
export_stats
138-
fi
13926
docker compose up -d --remove-orphans --no-build
140-
if [[ "${WITH_USAGE}" == "true" ]]; then
141-
wait_for_service
142-
import_stats
143-
fi
14427
echo "Services are starting from remote image."
14528
echo "Run 'docker compose logs -f' to see the logs."
14629
;;
@@ -167,18 +50,9 @@ case "$choice" in
16750
--build-arg COMMIT="${COMMIT}" \
16851
--build-arg BUILD_DATE="${BUILD_DATE}"
16952

170-
if [[ "${WITH_USAGE}" == "true" ]]; then
171-
export_stats
172-
fi
173-
17453
echo "Starting the services..."
17554
docker compose up -d --remove-orphans --pull never
17655

177-
if [[ "${WITH_USAGE}" == "true" ]]; then
178-
wait_for_service
179-
import_stats
180-
fi
181-
18256
echo "Build complete. Services are starting."
18357
echo "Run 'docker compose logs -f' to see the logs."
18458
;;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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+
type apiKeyUsageEntry struct {
13+
Success int64 `json:"success"`
14+
Failed int64 `json:"failed"`
15+
RecentRequests []coreauth.RecentRequestBucket `json:"recent_requests"`
16+
}
17+
18+
func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket {
19+
if len(dst) == 0 {
20+
return src
21+
}
22+
if len(src) == 0 {
23+
return dst
24+
}
25+
if len(dst) != len(src) {
26+
n := len(dst)
27+
if len(src) < n {
28+
n = len(src)
29+
}
30+
for i := 0; i < n; i++ {
31+
dst[i].Success += src[i].Success
32+
dst[i].Failed += src[i].Failed
33+
}
34+
return dst
35+
}
36+
for i := range dst {
37+
dst[i].Success += src[i].Success
38+
dst[i].Failed += src[i].Failed
39+
}
40+
return dst
41+
}
42+
43+
// GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths,
44+
// grouped by provider and keyed by "base_url|api_key".
45+
func (h *Handler) GetAPIKeyUsage(c *gin.Context) {
46+
if h == nil {
47+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"})
48+
return
49+
}
50+
51+
h.mu.Lock()
52+
manager := h.authManager
53+
h.mu.Unlock()
54+
if manager == nil {
55+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
56+
return
57+
}
58+
59+
now := time.Now()
60+
out := make(map[string]map[string]apiKeyUsageEntry)
61+
for _, auth := range manager.List() {
62+
if auth == nil {
63+
continue
64+
}
65+
kind, apiKey := auth.AccountInfo()
66+
if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
67+
continue
68+
}
69+
apiKey = strings.TrimSpace(apiKey)
70+
if apiKey == "" {
71+
continue
72+
}
73+
baseURL := ""
74+
if auth.Attributes != nil {
75+
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
76+
if baseURL == "" {
77+
baseURL = strings.TrimSpace(auth.Attributes["base-url"])
78+
}
79+
}
80+
compositeKey := baseURL + "|" + apiKey
81+
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
82+
if provider == "" {
83+
provider = "unknown"
84+
}
85+
86+
recent := auth.RecentRequestsSnapshot(now)
87+
providerBucket, ok := out[provider]
88+
if !ok {
89+
providerBucket = make(map[string]apiKeyUsageEntry)
90+
out[provider] = providerBucket
91+
}
92+
if existing, exists := providerBucket[compositeKey]; exists {
93+
existing.Success += auth.Success
94+
existing.Failed += auth.Failed
95+
existing.RecentRequests = mergeRecentRequestBuckets(existing.RecentRequests, recent)
96+
providerBucket[compositeKey] = existing
97+
continue
98+
}
99+
providerBucket[compositeKey] = apiKeyUsageEntry{
100+
Success: auth.Success,
101+
Failed: auth.Failed,
102+
RecentRequests: recent,
103+
}
104+
}
105+
106+
c.JSON(http.StatusOK, out)
107+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
"base_url": "https://codex.example.com",
36+
},
37+
}); err != nil {
38+
t.Fatalf("register codex auth: %v", err)
39+
}
40+
if _, err := manager.Register(context.Background(), &coreauth.Auth{
41+
ID: "claude-auth",
42+
Provider: "claude",
43+
Attributes: map[string]string{
44+
"api_key": "claude-key",
45+
"base_url": "https://claude.example.com",
46+
},
47+
}); err != nil {
48+
t.Fatalf("register claude auth: %v", err)
49+
}
50+
51+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: true})
52+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: false})
53+
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "claude-auth", Provider: "claude", Model: "claude-4", Success: true})
54+
55+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
56+
57+
rec := httptest.NewRecorder()
58+
ginCtx, _ := gin.CreateTestContext(rec)
59+
req := httptest.NewRequest(http.MethodGet, "/v0/management/api-key-usage", nil)
60+
ginCtx.Request = req
61+
h.GetAPIKeyUsage(ginCtx)
62+
63+
if rec.Code != http.StatusOK {
64+
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
65+
}
66+
67+
var payload map[string]map[string]apiKeyUsageEntry
68+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
69+
t.Fatalf("decode payload: %v", err)
70+
}
71+
72+
codexEntry := payload["codex"]["https://codex.example.com|codex-key"]
73+
if codexEntry.Success != 1 || codexEntry.Failed != 1 {
74+
t.Fatalf("codex totals = %d/%d, want 1/1", codexEntry.Success, codexEntry.Failed)
75+
}
76+
if len(codexEntry.RecentRequests) != 20 {
77+
t.Fatalf("codex buckets len = %d, want 20", len(codexEntry.RecentRequests))
78+
}
79+
codexSuccess, codexFailed := sumRecentRequestBuckets(codexEntry.RecentRequests)
80+
if codexSuccess != 1 || codexFailed != 1 {
81+
t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed)
82+
}
83+
84+
claudeEntry := payload["claude"]["https://claude.example.com|claude-key"]
85+
if claudeEntry.Success != 1 || claudeEntry.Failed != 0 {
86+
t.Fatalf("claude totals = %d/%d, want 1/0", claudeEntry.Success, claudeEntry.Failed)
87+
}
88+
if len(claudeEntry.RecentRequests) != 20 {
89+
t.Fatalf("claude buckets len = %d, want 20", len(claudeEntry.RecentRequests))
90+
}
91+
claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeEntry.RecentRequests)
92+
if claudeSuccess != 1 || claudeFailed != 0 {
93+
t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed)
94+
}
95+
}

0 commit comments

Comments
 (0)