diff --git a/internal/api/relay_usage.go b/internal/api/relay_usage.go new file mode 100644 index 00000000..a93e5f63 --- /dev/null +++ b/internal/api/relay_usage.go @@ -0,0 +1,105 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "cpa-usage-keeper/internal/relayusage" + "github.com/gin-gonic/gin" +) + +// RelayUsageProvider 是中转商用量查询服务在 API 层的抽象,便于测试注入。 +type RelayUsageProvider interface { + GetUsage(context.Context, relayusage.UsageRequest) (relayusage.UsageResponse, error) + GetPlatformAssignments(context.Context, []string) ([]relayusage.PlatformAssignment, error) + GetPlatformOverrides(context.Context) (map[string]string, error) + UpdatePlatformOverrides(context.Context, relayusage.PlatformOverridesRequest) (map[string]string, error) +} + +func registerRelayUsageRoutes(router gin.IRoutes, provider RelayUsageProvider) { + router.POST("/usage/relay-provider/usage", func(c *gin.Context) { + if provider == nil { + writeInternalError(c, "relay usage provider is not configured", nil) + return + } + var request relayusage.UsageRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"}) + return + } + if len(request.IdentityIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"}) + return + } + response, err := provider.GetUsage(c.Request.Context(), request) + if err != nil { + if errors.Is(err, relayusage.ErrValidation) { + c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"}) + return + } + writeInternalError(c, "relay usage lookup failed", err) + return + } + c.JSON(http.StatusOK, response) + }) + + router.POST("/usage/relay-provider/assignments", func(c *gin.Context) { + if provider == nil { + writeInternalError(c, "relay usage provider is not configured", nil) + return + } + var request relayusage.UsageRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"}) + return + } + if len(request.IdentityIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"}) + return + } + assignments, err := provider.GetPlatformAssignments(c.Request.Context(), request.IdentityIDs) + if err != nil { + writeInternalError(c, "relay platform assignment lookup failed", err) + return + } + c.JSON(http.StatusOK, gin.H{"assignments": assignments}) + }) + + router.GET("/usage/relay-provider/platform-overrides", func(c *gin.Context) { + if provider == nil { + writeInternalError(c, "relay usage provider is not configured", nil) + return + } + overrides, err := provider.GetPlatformOverrides(c.Request.Context()) + if err != nil { + writeInternalError(c, "relay platform overrides lookup failed", err) + return + } + if overrides == nil { + overrides = map[string]string{} + } + c.JSON(http.StatusOK, gin.H{"overrides": overrides}) + }) + + router.PUT("/usage/relay-provider/platform-overrides", func(c *gin.Context) { + if provider == nil { + writeInternalError(c, "relay usage provider is not configured", nil) + return + } + var request relayusage.PlatformOverridesRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "overrides are required"}) + return + } + overrides, err := provider.UpdatePlatformOverrides(c.Request.Context(), request) + if err != nil { + writeInternalError(c, "relay platform overrides update failed", err) + return + } + if overrides == nil { + overrides = map[string]string{} + } + c.JSON(http.StatusOK, gin.H{"overrides": overrides}) + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 974cf38b..6d157c9a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -47,6 +47,7 @@ type StatusRouteConfig struct { type OptionalProviders struct { UsageIdentity service.UsageIdentityProvider Quota QuotaProvider + RelayUsage RelayUsageProvider CPAAPIKeys service.CPAAPIKeyProvider AuthFiles service.AuthFilesManagementProvider RequestLogs service.RequestLogProvider @@ -84,6 +85,7 @@ func NewRouter( var usageIdentityProvider service.UsageIdentityProvider var quotaProvider QuotaProvider + var relayUsageProvider RelayUsageProvider var cpaAPIKeyProvider service.CPAAPIKeyProvider var authFilesProvider service.AuthFilesManagementProvider var requestLogProvider service.RequestLogProvider @@ -91,6 +93,7 @@ func NewRouter( if len(optionalProviders) > 0 { usageIdentityProvider = optionalProviders[0].UsageIdentity quotaProvider = optionalProviders[0].Quota + relayUsageProvider = optionalProviders[0].RelayUsage cpaAPIKeyProvider = optionalProviders[0].CPAAPIKeys authFilesProvider = optionalProviders[0].AuthFiles requestLogProvider = optionalProviders[0].RequestLogs @@ -118,6 +121,7 @@ func NewRouter( registerCPAAPIKeyRoutes(adminProtected, cpaAPIKeyProvider) registerPricingRoutes(adminProtected, pricingProvider) registerQuotaRoutes(adminProtected, quotaProvider) + registerRelayUsageRoutes(adminProtected, relayUsageProvider) keyViewerProtected := apiV1.Group("") keyViewerProtected.Use(authHandler.apiKeyViewerMiddleware()) diff --git a/internal/app/app.go b/internal/app/app.go index ba403491..4f1ce474 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,6 +18,7 @@ import ( "cpa-usage-keeper/internal/logging" "cpa-usage-keeper/internal/poller" "cpa-usage-keeper/internal/quota" + "cpa-usage-keeper/internal/relayusage" "cpa-usage-keeper/internal/repository" "cpa-usage-keeper/internal/service" webui "cpa-usage-keeper/web" @@ -112,6 +113,9 @@ func NewWithConfig(cfg config.Config) (*App, error) { cpaClient := cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout, cfg.TLSSkipVerify) quotaService := quota.NewServiceWithOptions(db, cpaClient, quota.ServiceOptions{RefreshWorkerLimit: cfg.QuotaRefreshWorkerLimit}) + // relayUsageService 直连中转商用量接口(GLM/MiniMax/Kimi/DeepSeek),与 OAuth quota 链路隔离。 + relayHTTPClient := relayusage.NewHTTPClient(cfg.RequestTimeout, cfg.TLSSkipVerify) + relayUsageService := relayusage.NewService(db, relayusage.NewDefaultAdapterRegistry(relayHTTPClient), relayusage.ServiceOptions{WorkerLimit: cfg.QuotaRefreshWorkerLimit}) // syncService 仍然是 metadata 和 usage 处理共享的业务服务入口。 syncService := service.NewSyncServiceWithOptions(db, service.SyncServiceOptions{ BaseURL: cfg.CPABaseURL, @@ -226,6 +230,7 @@ func NewWithConfig(cfg config.Config) (*App, error) { api.OptionalProviders{ UsageIdentity: usageIdentityService, Quota: quotaService, + RelayUsage: relayUsageService, CPAAPIKeys: cpaAPIKeyService, AuthFiles: authFilesManagementService, RequestLogs: requestLogService, diff --git a/internal/relayusage/adapter.go b/internal/relayusage/adapter.go new file mode 100644 index 00000000..95721bf5 --- /dev/null +++ b/internal/relayusage/adapter.go @@ -0,0 +1,22 @@ +package relayusage + +import ( + "context" +) + +// Adapter 查询单个中转商平台的用量。Fetch 接收明文 API key(来自 UsageIdentity.LookupKey) +// 与 base-url;多数平台的用量接口 URL 是硬编码的,base-url 仅用于个别平台按区域选择入口。 +type Adapter interface { + Platform() string + Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error) +} + +// NewDefaultAdapterRegistry 构造内置四家中转商的 adapter 集合,key 为平台标识。 +func NewDefaultAdapterRegistry(client HTTPDoer) map[string]Adapter { + return map[string]Adapter{ + "glm": newGLMAdapter(client), + "minimax": newMiniMaxAdapter(client), + "kimi": newKimiAdapter(client), + "deepseek": newDeepSeekAdapter(client), + } +} diff --git a/internal/relayusage/adapter_test.go b/internal/relayusage/adapter_test.go new file mode 100644 index 00000000..c62f4615 --- /dev/null +++ b/internal/relayusage/adapter_test.go @@ -0,0 +1,268 @@ +package relayusage + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGLMAdapterParsesLimits(t *testing.T) { + body := `{"code":200,"data":{"level":"plus","limits":[` + + `{"type":"TOKENS_LIMIT","percentage":40,"usage":100000,"currentValue":40000,"remaining":60000,"nextResetTime":1750000000000,"unit":3,"number":5},` + + `{"type":"TIME_LIMIT","percentage":10,"usage":0,"currentValue":0,"remaining":0,"nextResetTime":"","unit":0,"number":0}` + + `]}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GLM 直接把 token 放 Authorization,不带 Bearer。 + if got := r.Header.Get("Authorization"); got != "test-key" { + t.Errorf("Authorization header = %q, want test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newGLMAdapter(server.Client()) + a.url = server.URL + result, err := a.Fetch(context.Background(), "test-key", "") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if result.Platform != "glm" { + t.Errorf("Platform = %q, want glm", result.Platform) + } + if len(result.Rows) != 2 { + t.Fatalf("Rows count = %d, want 2", len(result.Rows)) + } + first := result.Rows[0] + if first.Key != "5hour_tokens" { + t.Errorf("first Key = %q, want 5hour_tokens", first.Key) + } + if first.Used == nil || *first.Used != 40000 { + t.Errorf("first Used = %v, want 40000", first.Used) + } + if first.Limit == nil || *first.Limit != 100000 { + t.Errorf("first Limit = %v, want 100000", first.Limit) + } + if first.UsedPercent == nil || *first.UsedPercent != 40 { + t.Errorf("first UsedPercent = %v, want 40", first.UsedPercent) + } + if first.ResetAt == "" { + t.Errorf("first ResetAt should not be empty") + } +} + +func TestGLMAdapterRejectsNon200Code(t *testing.T) { + body := `{"code":500,"data":{}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newGLMAdapter(server.Client()) + a.url = server.URL + if _, err := a.Fetch(context.Background(), "k", ""); err == nil { + t.Fatal("expected error for non-200 code") + } +} + +func TestGLMAdapterPrefersPercentage(t *testing.T) { + // percentage 与 currentValue/usage 不一致时,优先采用 GLM 直接返回的 percentage。 + body := `{"code":200,"data":{"level":"plus","limits":[` + + `{"type":"TOKENS_LIMIT","percentage":70,"usage":100000,"currentValue":40000,"remaining":60000,"nextResetTime":1750000000000,"unit":3,"number":5}]}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newGLMAdapter(server.Client()) + a.url = server.URL + result, err := a.Fetch(context.Background(), "test-key", "") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + first := result.Rows[0] + // currentValue/usage = 40%,但 GLM 的 percentage = 70,应取 70。 + if first.UsedPercent == nil || *first.UsedPercent != 70 { + t.Errorf("UsedPercent = %v, want 70 (from percentage, not currentValue/usage=40)", first.UsedPercent) + } +} + +func TestMiniMaxAdapterParsesWindows(t *testing.T) { + body := `{"base_resp":{"status_code":0},"model_remains":[` + + `{"model_name":"abab6.5s","start_time":1000000,"end_time":19000000,"remains_time":5000000,` + + `"weekly_start_time":1000000,"weekly_end_time":169000000,"weekly_remains_time":84000000,` + + `"current_interval_remaining_percent":70,"current_weekly_remaining_percent":50,` + + `"current_interval_status":1,"current_weekly_status":1}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q, want Bearer test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newMiniMaxAdapter(server.Client()) + a.cnURL = server.URL + a.globalURL = server.URL + result, err := a.Fetch(context.Background(), "test-key", "https://api.minimaxi.com/v1") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if len(result.Rows) != 2 { + t.Fatalf("Rows count = %d, want 2 (interval + weekly)", len(result.Rows)) + } + // interval: used_percent = 100 - 70 = 30 + interval := result.Rows[0] + if interval.UsedPercent == nil || *interval.UsedPercent != 30 { + t.Errorf("interval UsedPercent = %v, want 30", interval.UsedPercent) + } + if interval.Key != "interval_abab6.5s" { + t.Errorf("interval Key = %q, want interval_abab6.5s", interval.Key) + } + // weekly: used_percent = 100 - 50 = 50 + weekly := result.Rows[1] + if weekly.UsedPercent == nil || *weekly.UsedPercent != 50 { + t.Errorf("weekly UsedPercent = %v, want 50", weekly.UsedPercent) + } +} + +func TestMiniMaxAdapterSkipsUnlimitedStatus(t *testing.T) { + // status=3 表示不限量,对应窗口应被跳过。 + body := `{"base_resp":{"status_code":0},"model_remains":[` + + `{"model_name":"m1","start_time":1000,"end_time":2000,"remains_time":500,` + + `"weekly_start_time":1000,"weekly_end_time":2000,"weekly_remains_time":500,` + + `"current_interval_remaining_percent":100,"current_weekly_remaining_percent":100,` + + `"current_interval_status":3,"current_weekly_status":3}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newMiniMaxAdapter(server.Client()) + a.cnURL = server.URL + a.globalURL = server.URL + result, err := a.Fetch(context.Background(), "k", "") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if len(result.Rows) != 0 { + t.Errorf("Rows count = %d, want 0 (unlimited skipped)", len(result.Rows)) + } +} + +func TestKimiAdapterParsesUsageAndWindows(t *testing.T) { + body := `{"usage":{"limit":"100000","used":"40000","remaining":"60000","resetTime":"2026-07-20T00:00:00Z"},` + + `"limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"},"detail":{"limit":"50000","used":"10000","remaining":"40000","resetTime":"2026-07-17T12:00:00Z"}}],` + + `"parallel":{"limit":"10"}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q, want Bearer test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newKimiAdapter(server.Client()) + a.url = server.URL + result, err := a.Fetch(context.Background(), "test-key", "") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + // weekly_tokens + window + parallel = 3 rows + if len(result.Rows) != 3 { + t.Fatalf("Rows count = %d, want 3", len(result.Rows)) + } + if result.Rows[0].Key != "weekly_tokens" { + t.Errorf("first Key = %q, want weekly_tokens", result.Rows[0].Key) + } + if result.Rows[0].UsedPercent == nil || *result.Rows[0].UsedPercent != 40 { + t.Errorf("weekly_tokens UsedPercent = %v, want 40", result.Rows[0].UsedPercent) + } + // parallel: used=0, limit=10 + parallel := result.Rows[2] + if parallel.Key != "parallel_requests" { + t.Errorf("third Key = %q, want parallel_requests", parallel.Key) + } + if parallel.Limit == nil || *parallel.Limit != 10 { + t.Errorf("parallel Limit = %v, want 10", parallel.Limit) + } +} + +func TestKimiAdapterBearerPrefixTolerated(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer already-bearer" { + t.Errorf("Authorization = %q, want Bearer already-bearer", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"usage":{"limit":"1","used":"0","remaining":"1","resetTime":""}}`)) + })) + defer server.Close() + + a := newKimiAdapter(server.Client()) + a.url = server.URL + if _, err := a.Fetch(context.Background(), "Bearer already-bearer", ""); err != nil { + t.Fatalf("Fetch failed: %v", err) + } +} + +func TestDeepSeekAdapterParsesBalance(t *testing.T) { + body := `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"123.45","granted_balance":"50.00","topped_up_balance":"73.45"}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q, want Bearer test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + a := newDeepSeekAdapter(server.Client()) + a.url = server.URL + result, err := a.Fetch(context.Background(), "test-key", "") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if result.Balance == nil { + t.Fatal("Balance is nil") + } + if result.Balance.Available != 123.45 { + t.Errorf("Available = %v, want 123.45", result.Balance.Available) + } + if result.Balance.Granted != 50 { + t.Errorf("Granted = %v, want 50", result.Balance.Granted) + } + if result.Balance.ToppedUp != 73.45 { + t.Errorf("ToppedUp = %v, want 73.45", result.Balance.ToppedUp) + } + if result.Balance.Currency != "CNY" { + t.Errorf("Currency = %q, want CNY", result.Balance.Currency) + } + if len(result.Rows) != 0 { + t.Errorf("Rows count = %d, want 0 (DeepSeek only has balance)", len(result.Rows)) + } +} + +func TestDeepSeekAdapterEmptyBalanceError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"is_available":true,"balance_infos":[]}`)) + })) + defer server.Close() + + a := newDeepSeekAdapter(server.Client()) + a.url = server.URL + if _, err := a.Fetch(context.Background(), "k", ""); err == nil { + t.Fatal("expected error for empty balance_infos") + } +} diff --git a/internal/relayusage/deepseek.go b/internal/relayusage/deepseek.go new file mode 100644 index 00000000..8c781141 --- /dev/null +++ b/internal/relayusage/deepseek.go @@ -0,0 +1,85 @@ +package relayusage + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// deepSeekAdapter 查询 DeepSeek 开放平台余额。 +// DeepSeek 只有余额接口,没有用量/额度窗口,因此结果只填 Balance。 +// 文档:https://api-docs.deepseek.com/zh-cn/api/get-user-balance +type deepSeekAdapter struct { + client HTTPDoer + url string +} + +func newDeepSeekAdapter(client HTTPDoer) *deepSeekAdapter { + return &deepSeekAdapter{client: client, url: deepSeekBalanceURL} +} + +func (a *deepSeekAdapter) Platform() string { return "deepseek" } + +const deepSeekBalanceURL = "https://api.deepseek.com/user/balance" + +type deepSeekBalanceInfo struct { + Currency string `json:"currency"` + TotalBalance string `json:"total_balance"` + GrantedBalance string `json:"granted_balance"` + ToppedUpBalance string `json:"topped_up_balance"` +} + +type deepSeekBalanceResponse struct { + IsAvailable bool `json:"is_available"` + BalanceInfos []deepSeekBalanceInfo `json:"balance_infos"` +} + +func (a *deepSeekAdapter) Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.url, nil) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("build deepseek request: %w", err) + } + req.Header.Set("Authorization", bearerToken(apiKey)) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("deepseek request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("read deepseek response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return RelayUsageResult{}, fmt.Errorf("deepseek api error: status %d, body %s", resp.StatusCode, truncateBody(body)) + } + + var payload deepSeekBalanceResponse + if err := json.Unmarshal(body, &payload); err != nil { + return RelayUsageResult{}, fmt.Errorf("decode deepseek response: %w", err) + } + if len(payload.BalanceInfos) == 0 { + return RelayUsageResult{}, fmt.Errorf("deepseek api error: balance_infos is empty") + } + info := payload.BalanceInfos[0] + balance := &RelayBalance{ + Available: parseFloatString(info.TotalBalance), + Granted: parseFloatString(info.GrantedBalance), + ToppedUp: parseFloatString(info.ToppedUpBalance), + Currency: info.Currency, + } + if balance.Currency == "" { + balance.Currency = "CNY" + } + return RelayUsageResult{Platform: "deepseek", Balance: balance}, nil +} + +func truncateBody(body []byte) string { + if len(body) > 300 { + return string(body[:300]) + } + return string(body) +} diff --git a/internal/relayusage/errors.go b/internal/relayusage/errors.go new file mode 100644 index 00000000..175b64d5 --- /dev/null +++ b/internal/relayusage/errors.go @@ -0,0 +1,14 @@ +package relayusage + +import "errors" + +// SkipReason 标识某条 identity 未进入用量查询的原因,供前端区分展示。 +const ( + SkipNotAIProvider = "not_ai_provider" // 非 API Key 身份(如 OAuth AuthFile) + SkipNoAPIKey = "no_api_key" // LookupKey 为空 + SkipUnsupported = "unsupported_platform" // base-url 无法识别为中转商,或平台未内置 + SkipIdentityNotFound = "identity_not_found" +) + +// ErrValidation 表示入参校验失败。 +var ErrValidation = errors.New("relay usage validation error") diff --git a/internal/relayusage/glm.go b/internal/relayusage/glm.go new file mode 100644 index 00000000..c90ad32d --- /dev/null +++ b/internal/relayusage/glm.go @@ -0,0 +1,121 @@ +package relayusage + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "cpa-usage-keeper/internal/quota" +) + +// glmAdapter 查询智谱 GLM Coding Plan 用量。 +// 文档接口:https://open.bigmodel.cn/api/monitor/usage/quota/limit +// 注意:GLM 返回字段名与含义相反——usage 是总限额,currentValue 是已用量。 +type glmAdapter struct { + client HTTPDoer + url string +} + +func newGLMAdapter(client HTTPDoer) *glmAdapter { + return &glmAdapter{client: client, url: glmUsageURL} +} + +func (a *glmAdapter) Platform() string { return "glm" } + +const glmUsageURL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit" + +type glmLimitItem struct { + Type string `json:"type"` + Percentage float64 `json:"percentage"` + Usage float64 `json:"usage"` + CurrentValue float64 `json:"currentValue"` + Remaining float64 `json:"remaining"` + NextResetTime any `json:"nextResetTime"` + Unit int `json:"unit"` + Number int `json:"number"` +} + +type glmResponse struct { + Code int `json:"code"` + Data struct { + Level string `json:"level"` + Limits []glmLimitItem `json:"limits"` + } `json:"data"` +} + +func (a *glmAdapter) Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.url, nil) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("build glm request: %w", err) + } + // GLM 的 Authorization 直接放 token,不带 Bearer 前缀。 + req.Header.Set("Authorization", apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("glm request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("read glm response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return RelayUsageResult{}, fmt.Errorf("glm api error: status %d, body %s", resp.StatusCode, truncateBody(body)) + } + + var payload glmResponse + if err := json.Unmarshal(body, &payload); err != nil { + return RelayUsageResult{}, fmt.Errorf("decode glm response: %w", err) + } + if payload.Code != 200 { + return RelayUsageResult{}, fmt.Errorf("glm api error: code %d", payload.Code) + } + + rows := make([]quota.QuotaRow, 0, len(payload.Data.Limits)) + for _, item := range payload.Data.Limits { + used := item.CurrentValue + limit := item.Usage + if limit == 0 && item.CurrentValue != 0 && item.Remaining != 0 { + limit = item.CurrentValue + item.Remaining + } + remaining := item.Remaining + id, label := glmLimitLabel(item) + // GLM 返回的 percentage 是已用百分比,比 currentValue/usage 推导更权威(部分窗口两者不一致)。 + usedPercent := percent(used, limit) + if item.Percentage > 0 { + clamped := clampPercent(item.Percentage) + usedPercent = &clamped + } + rows = append(rows, quota.QuotaRow{ + Key: id, + Label: label, + Used: floatPtr(used), + Limit: floatPtr(limit), + Remaining: floatPtr(remaining), + UsedPercent: usedPercent, + RemainingFraction: remainingFraction(remaining, limit), + ResetAt: resetTimeFromAny(item.NextResetTime), + }) + } + return RelayUsageResult{Platform: "glm", Rows: rows}, nil +} + +func glmLimitLabel(item glmLimitItem) (id, label string) { + if item.Type == "TIME_LIMIT" { + return "monthly_mcp", "Monthly MCP" + } + if item.Type == "TOKENS_LIMIT" { + switch item.Unit { + case 3: + return "5hour_tokens", "5h Tokens" + case 6: + return "weekly_tokens", "Weekly Tokens" + } + return fmt.Sprintf("tokens_%dx%d", item.Unit, item.Number), fmt.Sprintf("%dh Tokens", item.Number) + } + return fmt.Sprintf("limit_%dx%d", item.Unit, item.Number), fmt.Sprintf("Limit (%dx%d)", item.Unit, item.Number) +} diff --git a/internal/relayusage/httpclient.go b/internal/relayusage/httpclient.go new file mode 100644 index 00000000..ea953acf --- /dev/null +++ b/internal/relayusage/httpclient.go @@ -0,0 +1,31 @@ +package relayusage + +import ( + "crypto/tls" + "net/http" + "time" +) + +// HTTPDoer 是 adapter 发起出站请求的抽象,便于单测注入桩 client。 +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +// NewHTTPClient 构造直连中转商用量接口的 *http.Client, +// 复用 keeper 全局的 REQUEST_TIMEOUT 与 TLS_SKIP_VERIFY 语义(与 internal/cpa/client.go 对齐)。 +func NewHTTPClient(timeout time.Duration, tlsSkipVerify bool) HTTPDoer { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + if tlsSkipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + } +} diff --git a/internal/relayusage/kimi.go b/internal/relayusage/kimi.go new file mode 100644 index 00000000..ce0f558b --- /dev/null +++ b/internal/relayusage/kimi.go @@ -0,0 +1,128 @@ +package relayusage + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "cpa-usage-keeper/internal/quota" +) + +// kimiAdapter 查询月之暗面 Kimi Coding Plan 用量。 +// 用量接口:https://api.kimi.com/coding/v1/usages(仅 Coding API endpoint 接受此 key 类型)。 +type kimiAdapter struct { + client HTTPDoer + url string +} + +func newKimiAdapter(client HTTPDoer) *kimiAdapter { + return &kimiAdapter{client: client, url: kimiUsageURL} +} + +func (a *kimiAdapter) Platform() string { return "kimi" } + +const kimiUsageURL = "https://api.kimi.com/coding/v1/usages" + +type kimiUsageDetail struct { + Limit string `json:"limit"` + Used string `json:"used"` + Remaining string `json:"remaining"` + ResetTime string `json:"resetTime"` +} + +type kimiWindowLimit struct { + Window struct { + Duration int `json:"duration"` + TimeUnit string `json:"timeUnit"` + } `json:"window"` + Detail kimiUsageDetail `json:"detail"` +} + +type kimiUsageResponse struct { + Usage *kimiUsageDetail `json:"usage"` + Limits []kimiWindowLimit `json:"limits"` + Parallel *struct { + Limit string `json:"limit"` + } `json:"parallel"` +} + +func (a *kimiAdapter) Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.url, nil) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("build kimi request: %w", err) + } + req.Header.Set("Authorization", bearerToken(apiKey)) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("kimi request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return RelayUsageResult{}, fmt.Errorf("read kimi response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return RelayUsageResult{}, fmt.Errorf("kimi api error: status %d, body %s", resp.StatusCode, truncateBody(body)) + } + + var payload kimiUsageResponse + if err := json.Unmarshal(body, &payload); err != nil { + return RelayUsageResult{}, fmt.Errorf("decode kimi response: %w", err) + } + + var rows []quota.QuotaRow + if payload.Usage != nil { + rows = append(rows, kimiDetailRow("weekly_tokens", "Weekly Tokens", *payload.Usage)) + } + for _, item := range payload.Limits { + id := fmt.Sprintf("window_%d_%s", item.Window.Duration, item.Window.TimeUnit) + label := kimiTimeUnitLabel(item.Window.TimeUnit) + rows = append(rows, kimiDetailRow(id, label, item.Detail)) + } + if payload.Parallel != nil && strings.TrimSpace(payload.Parallel.Limit) != "" { + limit := parseFloatString(payload.Parallel.Limit) + rows = append(rows, quota.QuotaRow{ + Key: "parallel_requests", + Label: "Parallel Requests", + Used: floatPtr(0), + Limit: floatPtr(limit), + Remaining: floatPtr(limit), + ResetAt: "", + }) + } + return RelayUsageResult{Platform: "kimi", Rows: rows}, nil +} + +func kimiDetailRow(id, label string, detail kimiUsageDetail) quota.QuotaRow { + used := parseFloatString(detail.Used) + limit := parseFloatString(detail.Limit) + remaining := parseFloatString(detail.Remaining) + return quota.QuotaRow{ + Key: id, + Label: label, + Used: floatPtr(used), + Limit: floatPtr(limit), + Remaining: floatPtr(remaining), + UsedPercent: percent(used, limit), + RemainingFraction: remainingFraction(remaining, limit), + ResetAt: parseResetTime(detail.ResetTime), + } +} + +func kimiTimeUnitLabel(timeUnit string) string { + switch timeUnit { + case "TIME_UNIT_MINUTE": + return "5h Tokens" + case "TIME_UNIT_HOUR": + return "Hourly" + case "TIME_UNIT_DAY": + return "Daily" + default: + return strings.ToLower(strings.TrimPrefix(timeUnit, "TIME_UNIT_")) + } +} diff --git a/internal/relayusage/matcher.go b/internal/relayusage/matcher.go new file mode 100644 index 00000000..cbb9ea29 --- /dev/null +++ b/internal/relayusage/matcher.go @@ -0,0 +1,64 @@ +package relayusage + +import ( + "net" + "net/url" + "strconv" + "strings" + + "cpa-usage-keeper/internal/entities" +) + +// domainToPlatform 把中转商 base-url 的 host 映射到平台标识。 +// 新增平台时在此追加域名,并在 NewDefaultAdapterRegistry 注册 adapter。 +var domainToPlatform = map[string]string{ + "open.bigmodel.cn": "glm", + "api.minimaxi.com": "minimax", + "www.minimax.io": "minimax", + "api.kimi.com": "kimi", + "api.moonshot.cn": "kimi", + "platform.moonshot.cn": "kimi", + "api.deepseek.com": "deepseek", +} + +// Match 返回 identity 对应的中转商平台标识;空字符串表示不支持或官方端点。 +// 优先级:手动覆盖 > base-url 域名匹配。手动覆盖值为 "none" 时强制跳过查询。 +// overrides 的 key 是 identity ID 的字符串形式(与 UsageIdentity.id 一致)。 +func Match(identity entities.UsageIdentity, overrides map[string]string) string { + if overrides != nil { + if platform, ok := overrides[strconv.FormatInt(identity.ID, 10)]; ok { + normalized := strings.TrimSpace(strings.ToLower(platform)) + if normalized == "none" { + return "" + } + if normalized != "" { + return normalized + } + } + } + return MatchByBaseURL(identity.BaseURL) +} + +// MatchByBaseURL 仅按 base-url 域名匹配平台,不考虑手动覆盖。 +func MatchByBaseURL(baseURL string) string { + host := hostFromBaseURL(baseURL) + if host == "" { + return "" + } + return domainToPlatform[host] +} + +func hostFromBaseURL(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return "" + } + host := baseURL + if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" { + host = strings.ToLower(parsed.Host) + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host +} diff --git a/internal/relayusage/matcher_test.go b/internal/relayusage/matcher_test.go new file mode 100644 index 00000000..d048830b --- /dev/null +++ b/internal/relayusage/matcher_test.go @@ -0,0 +1,56 @@ +package relayusage + +import ( + "testing" + + "cpa-usage-keeper/internal/entities" +) + +func TestMatchByBaseURL(t *testing.T) { + cases := []struct { + name string + baseURL string + want string + }{ + {"glm", "https://open.bigmodel.cn/api/paas/v4", "glm"}, + {"minimax CN", "https://api.minimaxi.com/v1", "minimax"}, + {"minimax global", "https://www.minimax.io/v1", "minimax"}, + {"kimi", "https://api.kimi.com/v1", "kimi"}, + {"moonshot", "https://api.moonshot.cn/v1", "kimi"}, + {"deepseek", "https://api.deepseek.com", "deepseek"}, + {"unknown relay", "https://openrouter.ai/api/v1", ""}, + {"empty", "", ""}, + {"with port", "https://open.bigmodel.cn:443/api", "glm"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := MatchByBaseURL(c.baseURL); got != c.want { + t.Errorf("MatchByBaseURL(%q) = %q, want %q", c.baseURL, got, c.want) + } + }) + } +} + +func TestMatchOverrideTakesPrecedence(t *testing.T) { + identity := entities.UsageIdentity{ID: 7, BaseURL: "https://open.bigmodel.cn/api/paas/v4"} + + if got := Match(identity, nil); got != "glm" { + t.Errorf("Match without override = %q, want glm", got) + } + + overrides := map[string]string{"7": "deepseek"} + if got := Match(identity, overrides); got != "deepseek" { + t.Errorf("Match with override = %q, want deepseek", got) + } + + overrides["7"] = "none" + if got := Match(identity, overrides); got != "" { + t.Errorf("Match with none override = %q, want empty", got) + } + + // override 只影响指定 identity,不影响其他条目按域名匹配。 + other := entities.UsageIdentity{ID: 8, BaseURL: "https://api.deepseek.com"} + if got := Match(other, overrides); got != "deepseek" { + t.Errorf("Match other identity = %q, want deepseek", got) + } +} diff --git a/internal/relayusage/minimax.go b/internal/relayusage/minimax.go new file mode 100644 index 00000000..e9e75b27 --- /dev/null +++ b/internal/relayusage/minimax.go @@ -0,0 +1,150 @@ +package relayusage + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "cpa-usage-keeper/internal/quota" +) + +// minimaxAdapter 查询 MiniMax Token Plan 用量。 +// CN 与国际账号互相隔离:首选端点报鉴权错误(1004/2049)时回退另一端点。 +type minimaxAdapter struct { + client HTTPDoer + cnURL string + globalURL string +} + +func newMiniMaxAdapter(client HTTPDoer) *minimaxAdapter { + return &minimaxAdapter{client: client, cnURL: minimaxEndpoints.CN, globalURL: minimaxEndpoints.Global} +} + +func (a *minimaxAdapter) Platform() string { return "minimax" } + +var minimaxEndpoints = struct { + CN, Global string +}{ + CN: "https://api.minimaxi.com/v1/token_plan/remains", + Global: "https://www.minimax.io/v1/token_plan/remains", +} + +// minimaxAuthFailureCodes 表示端点不认这个 key(CN/国际账号隔离)。 +var minimaxAuthFailureCodes = map[int]struct{}{1004: {}, 2049: {}} + +type minimaxModelRemains struct { + ModelName string `json:"model_name"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + RemainsTime int64 `json:"remains_time"` + WeeklyStartTime int64 `json:"weekly_start_time"` + WeeklyEndTime int64 `json:"weekly_end_time"` + WeeklyRemainsTime int64 `json:"weekly_remains_time"` + CurrentIntervalRemainingPercent int `json:"current_interval_remaining_percent"` + CurrentWeeklyRemainingPercent int `json:"current_weekly_remaining_percent"` + CurrentIntervalStatus int `json:"current_interval_status"` + CurrentWeeklyStatus int `json:"current_weekly_status"` +} + +type minimaxRemainsResponse struct { + BaseResp *struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` + } `json:"base_resp,omitempty"` + ModelRemains []minimaxModelRemains `json:"model_remains"` +} + +func (a *minimaxAdapter) Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error) { + auth := bearerToken(apiKey) + primary, fallback := a.cnURL, a.globalURL + // 若 base-url 明确指向国际站,则优先国际端点。 + if strings.Contains(strings.ToLower(baseURL), "minimax.io") { + primary, fallback = minimaxEndpoints.Global, minimaxEndpoints.CN + } + + data, err := a.fetchRemains(ctx, primary, auth) + if err != nil { + return RelayUsageResult{}, err + } + if data.BaseResp != nil { + if _, authFailed := minimaxAuthFailureCodes[data.BaseResp.StatusCode]; authFailed { + fallbackData, ferr := a.fetchRemains(ctx, fallback, auth) + if ferr != nil { + return RelayUsageResult{}, ferr + } + data = fallbackData + } + } + if data.BaseResp != nil && data.BaseResp.StatusCode != 0 { + return RelayUsageResult{}, fmt.Errorf("minimax api error: %s", data.BaseResp.StatusMsg) + } + + rows := make([]quota.QuotaRow, 0, len(data.ModelRemains)*2) + for _, m := range data.ModelRemains { + // status=3 表示该模态不限量,跳过避免显示 100% 剩余的误导进度条。 + if m.CurrentIntervalStatus != 3 && m.EndTime > m.StartTime { + intervalTotalMs := float64(m.EndTime - m.StartTime) + intervalUsedMs := intervalTotalMs - float64(m.RemainsTime) + if intervalUsedMs < 0 { + intervalUsedMs = 0 + } + rows = append(rows, quota.QuotaRow{ + Key: fmt.Sprintf("interval_%s", m.ModelName), + Label: fmt.Sprintf("%s 5h", m.ModelName), + Used: floatPtr(intervalUsedMs / 1000), + Limit: floatPtr(intervalTotalMs / 1000), + Remaining: floatPtr(float64(m.RemainsTime) / 1000), + UsedPercent: floatPtr(clampPercent(float64(100 - m.CurrentIntervalRemainingPercent))), + ResetAt: millisToRFC3339(m.EndTime), + }) + } + if m.CurrentWeeklyStatus != 3 && m.WeeklyEndTime > m.WeeklyStartTime { + weeklyTotalMs := float64(m.WeeklyEndTime - m.WeeklyStartTime) + weeklyUsedMs := weeklyTotalMs - float64(m.WeeklyRemainsTime) + if weeklyUsedMs < 0 { + weeklyUsedMs = 0 + } + rows = append(rows, quota.QuotaRow{ + Key: fmt.Sprintf("weekly_%s", m.ModelName), + Label: fmt.Sprintf("%s Weekly", m.ModelName), + Used: floatPtr(weeklyUsedMs / 1000), + Limit: floatPtr(weeklyTotalMs / 1000), + Remaining: floatPtr(float64(m.WeeklyRemainsTime) / 1000), + UsedPercent: floatPtr(clampPercent(float64(100 - m.CurrentWeeklyRemainingPercent))), + ResetAt: millisToRFC3339(m.WeeklyEndTime), + }) + } + } + return RelayUsageResult{Platform: "minimax", Rows: rows}, nil +} + +func (a *minimaxAdapter) fetchRemains(ctx context.Context, endpoint, auth string) (minimaxRemainsResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return minimaxRemainsResponse{}, fmt.Errorf("build minimax request: %w", err) + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return minimaxRemainsResponse{}, fmt.Errorf("minimax request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return minimaxRemainsResponse{}, fmt.Errorf("read minimax response: %w", err) + } + // MiniMax 的 remains 端点永远返回 HTTP 200,真实状态在 body 的 base_resp.status_code 里。 + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return minimaxRemainsResponse{}, fmt.Errorf("minimax api error: status %d, body %s", resp.StatusCode, truncateBody(body)) + } + var payload minimaxRemainsResponse + if err := json.Unmarshal(body, &payload); err != nil { + return minimaxRemainsResponse{}, fmt.Errorf("decode minimax response: %w", err) + } + return payload, nil +} diff --git a/internal/relayusage/normalize.go b/internal/relayusage/normalize.go new file mode 100644 index 00000000..daa8c72d --- /dev/null +++ b/internal/relayusage/normalize.go @@ -0,0 +1,133 @@ +package relayusage + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +func floatPtr(v float64) *float64 { return &v } + +// percent 返回 used/limit*100(0-100),limit<=0 时返回 nil。 +func percent(used, limit float64) *float64 { + if limit <= 0 { + return nil + } + v := used / limit * 100 + if v < 0 { + v = 0 + } else if v > 100 { + v = 100 + } + return &v +} + +// remainingFraction 返回 remaining/limit(0-1),limit<=0 时返回 nil。 +func remainingFraction(remaining, limit float64) *float64 { + if limit <= 0 { + return nil + } + v := remaining / limit + if v < 0 { + v = 0 + } else if v > 1 { + v = 1 + } + return &v +} + +// bearerToken 规范化鉴权头值:已带 Bearer 前缀则原样返回,否则补上。 +// DeepSeek/Kimi 等平台的 key 既可能裸填也可能带 Bearer,统一处理。 +func bearerToken(apiKey string) string { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(apiKey), "bearer ") { + return apiKey + } + return "Bearer " + apiKey +} + +// parseFloatString 解析中转商返回的字符串型数值(如 "12345"),失败返回 0。 +func parseFloatString(raw string) float64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0 + } + return v +} + +// parseResetTime 把中转商返回的重置时间统一成 RFC3339 字符串。 +// 支持:毫秒时间戳(number)、Unix 秒、ISO 字符串。 +func parseResetTime(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + // 纯数字:按毫秒或秒解析 + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + var t time.Time + if n > 1e12 { + t = time.UnixMilli(n) + } else { + t = time.Unix(n, 0) + } + return t.UTC().Format(time.RFC3339) + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t.UTC().Format(time.RFC3339) + } + // 兜底尝试常见格式 + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04:05Z", "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC().Format(time.RFC3339) + } + } + return raw +} + +// clampPercent 把百分比限制在 [0,100]。 +func clampPercent(v float64) float64 { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +} + +// millisToRFC3339 把毫秒时间戳转成 RFC3339 字符串。 +func millisToRFC3339(ms int64) string { + if ms <= 0 { + return "" + } + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} + +// resetTimeFromAny 处理中转商返回的重置时间字段(可能是 number、string 或 null)。 +// GLM 的 nextResetTime 在 percentage=0 时为空字符串,正常时为毫秒时间戳。 +func resetTimeFromAny(v any) string { + if v == nil { + return "" + } + switch t := v.(type) { + case string: + return parseResetTime(t) + case float64: + n := int64(t) + if t > 1e12 { + return time.UnixMilli(n).UTC().Format(time.RFC3339) + } + return time.Unix(n, 0).UTC().Format(time.RFC3339) + case json.Number: + return parseResetTime(t.String()) + } + return "" +} diff --git a/internal/relayusage/service.go b/internal/relayusage/service.go new file mode 100644 index 00000000..a1f2315a --- /dev/null +++ b/internal/relayusage/service.go @@ -0,0 +1,227 @@ +package relayusage + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "cpa-usage-keeper/internal/entities" + "cpa-usage-keeper/internal/repository" + "gorm.io/gorm" +) + +const platformOverridesSettingKey = "relay_usage.platform_overrides" + +// ServiceOptions 配置 Service 行为。 +type ServiceOptions struct { + WorkerLimit int +} + +// Service 编排中转商用量查询:取 identity -> 匹配平台 -> adapter 直连。 +// 与 internal/quota.Service 隔离:后者只服务 OAuth AuthFile 身份,本服务只服务 AIProvider 身份。 +type Service struct { + db *gorm.DB + registry map[string]Adapter + workerSem chan struct{} +} + +// NewService 构造 Service。registry 应已注入 HTTP client。 +func NewService(db *gorm.DB, registry map[string]Adapter, options ServiceOptions) *Service { + workerLimit := options.WorkerLimit + if workerLimit <= 0 { + workerLimit = 10 + } + if workerLimit > 100 { + workerLimit = 100 + } + return &Service{ + db: db, + registry: registry, + workerSem: make(chan struct{}, workerLimit), + } +} + +// UsageRequest 是批量用量查询入参。identity_id 与 UsageIdentity.id 一致为 string。 +type UsageRequest struct { + IdentityIDs []string `json:"identity_ids"` +} + +// UsageItem 是单条 identity 的查询结果。 +type UsageItem struct { + IdentityID string `json:"identity_id"` + Platform string `json:"platform,omitempty"` + Result *RelayUsageResult `json:"result,omitempty"` + Skipped string `json:"skipped,omitempty"` +} + +// UsageResponse 是批量用量查询响应。 +type UsageResponse struct { + Items []UsageItem `json:"items"` +} + +// GetUsage 并发查询多条 identity 的中转商用量。 +func (s *Service) GetUsage(ctx context.Context, request UsageRequest) (UsageResponse, error) { + if len(request.IdentityIDs) == 0 { + return UsageResponse{}, fmt.Errorf("%w: identity_ids is required", ErrValidation) + } + overrides, err := s.GetPlatformOverrides(ctx) + if err != nil { + return UsageResponse{}, err + } + items := make([]UsageItem, len(request.IdentityIDs)) + var wg sync.WaitGroup + for idx, id := range request.IdentityIDs { + items[idx] = UsageItem{IdentityID: id} + wg.Add(1) + go func(i int, identityID string) { + defer wg.Done() + select { + case s.workerSem <- struct{}{}: + defer func() { <-s.workerSem }() + items[i] = s.fetchOne(ctx, identityID, overrides) + case <-ctx.Done(): + items[i] = UsageItem{IdentityID: identityID, Skipped: ctx.Err().Error()} + } + }(idx, id) + } + wg.Wait() + return UsageResponse{Items: items}, nil +} + +func (s *Service) fetchOne(ctx context.Context, identityID string, overrides map[string]string) UsageItem { + id, err := strconv.ParseInt(strings.TrimSpace(identityID), 10, 64) + if err != nil { + return UsageItem{IdentityID: identityID, Skipped: SkipIdentityNotFound} + } + identity, err := repository.FindUsageIdentityByID(ctx, s.db, id) + if err != nil { + return UsageItem{IdentityID: identityID, Skipped: SkipIdentityNotFound} + } + if identity.AuthType != entities.UsageIdentityAuthTypeAIProvider { + return UsageItem{IdentityID: identityID, Skipped: SkipNotAIProvider} + } + if strings.TrimSpace(identity.LookupKey) == "" { + return UsageItem{IdentityID: identityID, Skipped: SkipNoAPIKey} + } + platform := Match(identity, overrides) + if platform == "" { + return UsageItem{IdentityID: identityID, Skipped: SkipUnsupported} + } + adapter, ok := s.registry[platform] + if !ok { + return UsageItem{IdentityID: identityID, Skipped: SkipUnsupported} + } + result, err := adapter.Fetch(ctx, identity.LookupKey, identity.BaseURL) + if err != nil { + return UsageItem{ + IdentityID: identityID, + Platform: platform, + Result: &RelayUsageResult{Platform: platform, FetchedAt: time.Now(), Error: err.Error()}, + } + } + result.Platform = platform + if result.FetchedAt.IsZero() { + result.FetchedAt = time.Now() + } + return UsageItem{IdentityID: identityID, Platform: platform, Result: &result} +} + +// PlatformAssignment 描述单条 identity 的平台判定,供前端展示「自动识别 / 手动覆盖」与下拉默认值。 +type PlatformAssignment struct { + IdentityID string `json:"identity_id"` + Platform string `json:"platform"` // 最终生效平台(空=不支持/官方) + Source string `json:"source"` // "override" | "auto" | "none" +} + +// GetPlatformAssignments 批量返回 identity 的平台判定,只做匹配不调用中转商接口。 +func (s *Service) GetPlatformAssignments(ctx context.Context, identityIDs []string) ([]PlatformAssignment, error) { + overrides, err := s.GetPlatformOverrides(ctx) + if err != nil { + return nil, err + } + out := make([]PlatformAssignment, 0, len(identityIDs)) + for _, identityID := range identityIDs { + id, err := strconv.ParseInt(strings.TrimSpace(identityID), 10, 64) + if err != nil { + out = append(out, PlatformAssignment{IdentityID: identityID, Source: "none"}) + continue + } + identity, err := repository.FindUsageIdentityByID(ctx, s.db, id) + if err != nil { + out = append(out, PlatformAssignment{IdentityID: identityID, Source: "none"}) + continue + } + assignment := PlatformAssignment{IdentityID: identityID, Source: "auto"} + if _, ok := overrides[strconv.FormatInt(identity.ID, 10)]; ok { + assignment.Source = "override" + } + assignment.Platform = Match(identity, overrides) + out = append(out, assignment) + } + return out, nil +} + +// GetPlatformOverrides 读取 identity_id -> 平台 的手动覆盖映射。 +func (s *Service) GetPlatformOverrides(ctx context.Context) (map[string]string, error) { + if s.db == nil { + return nil, nil + } + setting, found, err := repository.GetAppSetting(ctx, s.db, platformOverridesSettingKey) + if err != nil { + return nil, err + } + if !found || setting.Value == nil || strings.TrimSpace(*setting.Value) == "" { + return nil, nil + } + var raw map[string]string + if err := json.Unmarshal([]byte(*setting.Value), &raw); err != nil { + return nil, fmt.Errorf("decode platform overrides: %w", err) + } + out := make(map[string]string, len(raw)) + for key, platform := range raw { + normalized := strings.TrimSpace(strings.ToLower(platform)) + if normalized == "" { + continue + } + out[strings.TrimSpace(key)] = normalized + } + return out, nil +} + +// PlatformOverridesRequest 是更新手动覆盖的入参。 +// 平台值取 glm/minimax/kimi/deepseek/none;空值表示删除该条覆盖。 +type PlatformOverridesRequest struct { + Overrides map[string]string `json:"overrides"` +} + +// UpdatePlatformOverrides 全量替换手动覆盖映射。 +func (s *Service) UpdatePlatformOverrides(ctx context.Context, request PlatformOverridesRequest) (map[string]string, error) { + if s.db == nil { + return nil, fmt.Errorf("database is nil") + } + raw := make(map[string]string, len(request.Overrides)) + for key, platform := range request.Overrides { + normalized := strings.TrimSpace(strings.ToLower(platform)) + if normalized == "" { + continue + } + raw[strings.TrimSpace(key)] = normalized + } + payload, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshal platform overrides: %w", err) + } + value := string(payload) + if _, err := repository.UpsertAppSetting(ctx, s.db, entities.AppSetting{ + SettingKey: platformOverridesSettingKey, + Value: &value, + ValueType: entities.AppSettingValueTypeJSON, + }); err != nil { + return nil, err + } + return s.GetPlatformOverrides(ctx) +} diff --git a/internal/relayusage/types.go b/internal/relayusage/types.go new file mode 100644 index 00000000..bed3e3a9 --- /dev/null +++ b/internal/relayusage/types.go @@ -0,0 +1,26 @@ +package relayusage + +import ( + "time" + + "cpa-usage-keeper/internal/quota" +) + +// RelayBalance 描述中转商账户余额。DeepSeek 等只返回余额的平台使用, +// GLM/MiniMax/Kimi 等返回用量窗口的平台该字段为空。 +type RelayBalance struct { + Available float64 `json:"available"` + Granted float64 `json:"granted,omitempty"` + ToppedUp float64 `json:"toppedUp,omitempty"` + Currency string `json:"currency,omitempty"` +} + +// RelayUsageResult 是单个中转商凭据的用量查询结果。 +// Rows 复用 quota.QuotaRow,前端可直接复用 UsageQuotaRow 的渲染逻辑。 +type RelayUsageResult struct { + Platform string `json:"platform"` + Balance *RelayBalance `json:"balance,omitempty"` + Rows []quota.QuotaRow `json:"rows,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + Error string `json:"error,omitempty"` +} diff --git a/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx b/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx index 2c06ca88..d773fd57 100644 --- a/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx +++ b/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx @@ -5,6 +5,7 @@ import type { UsageIdentityPageSort } from '@/lib/api' import { CredentialAliasEditor, isCredentialAliasEditorDisabled } from './CredentialAliasEditor' import { CredentialHealthPanel } from './CredentialHealthPanel' import { CredentialBadge, CredentialPriorityBadge, CredentialRowShell, CredentialSectionShell, CredentialTableHeader, CredentialsPagination, MetricPill, RequestMetric, TonePercent, cacheReadRateTone, formatCredentialNumber, successRateTone } from './CredentialSectionShell' +import { AiProviderUsagePanel } from './AiProviderUsagePanel' interface AiProviderCredentialsSectionProps { rows: AiProviderCredentialRow[] @@ -19,10 +20,17 @@ interface AiProviderCredentialsSectionProps { onPageChange: (page: number) => void onPageSizeChange: (pageSize: number) => void onSortChange: (sort: UsageIdentityPageSort) => void + // 中转商用量平台覆盖(可选)。传入后每行在"用量"列展示中转用量;未识别/不支持的行显示提示文案。 + onSetRelayPlatform?: (identityId: string, platform: string) => void } -export function AiProviderCredentialsSection({ rows, total, page, totalPages, pageSize, sort, loading, aliasSavingId, onSaveAlias, onPageChange, onPageSizeChange, onSortChange }: AiProviderCredentialsSectionProps) { +export function AiProviderCredentialsSection({ rows, total, page, totalPages, pageSize, sort, loading, aliasSavingId, onSaveAlias, onPageChange, onPageSizeChange, onSortChange, onSetRelayPlatform }: AiProviderCredentialsSectionProps) { const { t } = useTranslation() + const relayEnabled = Boolean(onSetRelayPlatform) + // 中继用量启用时行结构多一列"用量",与"健康"分开;未启用时保持原三列。 + const relayRowClassName = relayEnabled + ? `${styles.aiProviderCredentialRow} ${styles.aiProviderCredentialRowRelay}` + : styles.aiProviderCredentialRow return ( {t('usage_stats.credentials_ai_providers_empty')}} {rows.length > 0 && ( )} {rows.map((row) => ( @@ -71,8 +80,15 @@ export function AiProviderCredentialsSection({ rows, total, page, totalPages, pa } /> )} - side={} - rowClassName={styles.aiProviderCredentialRow} + side={ + + } + usage={ + relayEnabled ? ( + + ) : null + } + rowClassName={relayRowClassName} /> ))} = [ + { value: 'auto', labelKey: 'usage_stats.relay_platform_auto' }, + { value: 'glm', labelKey: 'usage_stats.relay_platform_glm' }, + { value: 'minimax', labelKey: 'usage_stats.relay_platform_minimax' }, + { value: 'kimi', labelKey: 'usage_stats.relay_platform_kimi' }, + { value: 'deepseek', labelKey: 'usage_stats.relay_platform_deepseek' }, + { value: 'none', labelKey: 'usage_stats.relay_platform_none' }, +] + +interface AiProviderUsagePanelProps { + row: AiProviderCredentialRow + onSetPlatform: (identityId: string, platform: string) => void +} + +export function AiProviderUsagePanel({ row, onSetPlatform }: AiProviderUsagePanelProps) { + const { t } = useTranslation() + const item = row.relayUsageResult + const result = item?.result + const platform = row.relayPlatform + const quotas = useMemo(() => (result ? buildRelayDisplayQuotas(result) : []), [result]) + const balance = result?.balance + + return ( +
+
+ onSetPlatform(row.identity.id, value)} /> +
+ + {result?.error && ( +
+ {result.error} +
+ )} + + {balance && } + + {quotas.length > 0 && ( +
+ {quotas.map((quota) => ( + + ))} +
+ )} + + {!result && ( +
{t('usage_stats.relay_usage_unsupported')}
+ )} +
+ ) +} + +// PlatformSwitcher 把平台标识做成可点击徽标,点击弹出菜单覆盖平台(auto = 按域名自动识别)。 +function PlatformSwitcher({ platform, onSelect }: { platform?: string; onSelect: (value: string) => void }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const ref = useRef(null) + useEffect(() => { + if (!open) return + const handleOutsideClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + const currentLabel = platform && platform !== 'none' + ? t(`usage_stats.relay_platform_${platform}`) + : t('usage_stats.relay_platform_unknown') + return ( +
+ + {open && ( +
+ {PLATFORM_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ) +} + +// RelayQuotaCells 把一个窗口的 4 个单元格作为 Fragment 直接放进共享 grid, +// 多个窗口的进度条列宽由同一个 grid 统一分配,天然对齐。 +function RelayQuotaCells({ quota }: { quota: DisplayQuota }) { + const { t } = useTranslation() + const percent = quota.percent + const width = `${Math.max(0, Math.min(100, quota.barPercent ?? 0))}%` + const rawPercent = quota.percent !== null && quota.percent !== undefined + ? (quota.percentKind === 'used' ? quota.percent : 100 - quota.percent) + : null + const percentLabel = rawPercent === null ? '—' : `${Math.round(rawPercent)}%` + const usageLabel = formatUsedLimit(quota) + const resetLabel = quota.resetText ? formatQuotaResetDuration(quota.resetText, t) : '—' + const fillClassName = `${styles.credentialQuotaFill} ${credentialToneClassName('credentialQuotaFill', quota.status)}`.trim() + // 窗口名优先取 i18n;未配置的 key 回退到后端给的原 label(多为英文)。 + const labelText = quota.translationKey + ? t(`usage_stats.relay_window_${quota.translationKey}`) + : quota.label + return ( + + {labelText} + {usageLabel} + + + + + {percentLabel} + + {resetLabel} + + ) +} + +function RelayBalanceBlock({ balance }: { balance: RelayBalance }) { + const { t } = useTranslation() + const currency = balance.currency || 'CNY' + const details: Array<{ key: string; label: string; value: string }> = [] + if (balance.granted && balance.granted > 0) { + details.push({ key: 'granted', label: t('usage_stats.relay_usage_balance_granted'), value: formatRelayAmount(balance.granted, currency) }) + } + if (balance.toppedUp && balance.toppedUp > 0) { + details.push({ key: 'toppedUp', label: t('usage_stats.relay_usage_balance_topped_up'), value: formatRelayAmount(balance.toppedUp, currency) }) + } + return ( +
+
+ {t('usage_stats.relay_usage_balance')} + {formatRelayAmount(balance.available, currency)} +
+ {details.length > 0 && ( +
+ {details.map((detail) => ( + + {detail.label} + {detail.value} + + ))} +
+ )} +
+ ) +} + +function formatUsedLimit(quota: DisplayQuota): string { + if (quota.used != null && quota.limit != null && quota.limit > 0) { + return `${formatCompactQuantity(quota.used)}/${formatCompactQuantity(quota.limit)}` + } + // GLM 的 5h/weekly 只回 percentage、不回绝对值(used=0,limit=0),此时显示 — 而非误导性的 0。 + if (quota.used != null && quota.used > 0) return formatCompactQuantity(quota.used) + if (quota.remaining != null && quota.remaining > 0) return formatCompactQuantity(quota.remaining) + return '—' +} + +// formatCompactQuantity 用 Intl 紧凑记数法:120000 -> "120K",1234567 -> "1.2M"。 +const compactQuantityFormatter = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }) +function formatCompactQuantity(value: number): string { + return Number.isFinite(value) ? compactQuantityFormatter.format(value) : '0' +} + +const relayAmountFormatters = new Map() +function getRelayAmountFormatter(currency: string): Intl.NumberFormat { + let formatter = relayAmountFormatters.get(currency) + if (!formatter) { + formatter = new Intl.NumberFormat('zh-CN', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }) + relayAmountFormatters.set(currency, formatter) + } + return formatter +} + +function formatRelayAmount(value: number, currency: string): string { + const amount = Number.isFinite(value) ? value : 0 + try { + return getRelayAmountFormatter(currency).format(amount) + } catch { + return `${amount.toFixed(2)} ${currency}` + } +} diff --git a/web/src/components/usage/credentials/AuthFileCredentialsSection.tsx b/web/src/components/usage/credentials/AuthFileCredentialsSection.tsx index 8714d4bf..92ab7bd1 100644 --- a/web/src/components/usage/credentials/AuthFileCredentialsSection.tsx +++ b/web/src/components/usage/credentials/AuthFileCredentialsSection.tsx @@ -12,7 +12,7 @@ import type { QuotaAutoRefreshScheduleUnit, QuotaAutoRefreshSettings, UsageQuota import { CredentialAliasEditor, isCredentialAliasEditorDisabled } from './CredentialAliasEditor' import { CredentialHealthPanel } from './CredentialHealthPanel' import { CredentialProviderFilterIcon } from './CredentialProviderFilterBar' -import { CredentialBadge, CredentialPriorityBadge, CredentialRowShell, CredentialSectionShell, CredentialTableHeader, CredentialsPagination, MetricPill, RequestMetric, TonePercent, cacheReadRateTone, capitalize, credentialToneClassName, formatCredentialNumber, successRateTone } from './CredentialSectionShell' +import { CredentialBadge, CredentialPriorityBadge, CredentialRowShell, CredentialSectionShell, CredentialTableHeader, CredentialsPagination, MetricPill, RequestMetric, TonePercent, cacheReadRateTone, capitalize, credentialToneClassName, formatCredentialNumber, formatQuotaResetDuration, successRateTone } from './CredentialSectionShell' type Translate = (key: string, options?: Record) => string type InspectionIndicatorTone = 'idle' | 'running' | 'completed' @@ -1703,18 +1703,6 @@ export function formatQuotaResetLabel(resetAt: string): string { return `${month}/${day} ${hour}:${minute}` } -export function formatQuotaResetDuration(resetAt: string): string { - const resetMs = new Date(resetAt).getTime() - if (!Number.isFinite(resetMs)) { - return '' - } - const remainingMinutes = Math.max(0, Math.ceil((resetMs - Date.now()) / 60_000)) - const days = Math.floor(remainingMinutes / 1_440) - const hours = Math.floor((remainingMinutes % 1_440) / 60) - const minutes = remainingMinutes % 60 - return days > 0 ? `${days}d${hours}h${minutes}m` : `${hours}h${minutes}m` -} - export function formatQuotaWindowUsageAriaLabel(t: Translate, windowUsage: NonNullable): string { return t('usage_stats.credentials_quota_window_usage_aria', { tokens: windowUsage.tokens, @@ -1738,7 +1726,7 @@ function QuotaBar({ quota, quotaUsageMode }: { quota: DisplayQuota; quotaUsageMo const width = `${Math.max(0, Math.min(100, percent))}%` const percentLabel = quota.barPercent === null ? '' : `${Math.round(quota.barPercent)}%` const resetLabel = quota.resetText ? formatQuotaResetLabel(quota.resetText) : '' - const resetDuration = quota.resetText ? formatQuotaResetDuration(quota.resetText) : '' + const resetDuration = quota.resetText ? formatQuotaResetDuration(quota.resetText, t) : '' const billingUsage = quota.billingUsage const windowUsage = billingUsage ? undefined : quotaWindowUsageForMode(quota, quotaUsageMode) const hasGroupDescription = Boolean(quota.groupDescription?.trim()) diff --git a/web/src/components/usage/credentials/CredentialSectionShell.tsx b/web/src/components/usage/credentials/CredentialSectionShell.tsx index fe634e5e..f93eb087 100644 --- a/web/src/components/usage/credentials/CredentialSectionShell.tsx +++ b/web/src/components/usage/credentials/CredentialSectionShell.tsx @@ -2,6 +2,8 @@ import type { CSSProperties, ReactNode } from 'react' import styles from './CredentialSections.module.scss' import { formatCompactNumber } from '@/utils/usage' +export type Translate = (key: string, options?: Record) => string + type CredentialSectionStyle = CSSProperties interface CredentialSectionShellProps { @@ -20,7 +22,9 @@ interface CredentialRowShellProps { badges: ReactNode metrics: ReactNode side: ReactNode + usage?: ReactNode rowClassName?: string + footer?: ReactNode } interface CredentialTableHeaderProps { @@ -30,6 +34,7 @@ interface CredentialTableHeaderProps { totalTokensLabel: string cacheReadRateLabel: string sideLabel: string + usageLabel?: string rowClassName?: string } @@ -52,8 +57,9 @@ export function CredentialSectionShell({ title, subtitle, countLabel, titleExtra ) } -export function CredentialRowShell({ title, subtitle, badges, metrics, side, rowClassName }: CredentialRowShellProps) { - // 统一三段式行结构:左侧身份信息、中间指标、右侧 quota/状态区域。 +export function CredentialRowShell({ title, subtitle, badges, metrics, side, usage, rowClassName, footer }: CredentialRowShellProps) { + // 统一行结构:左侧身份信息、中间指标、右侧 side(健康/quota),可选 usage 列让中继用量单独成列。 + // footer 是可选的全宽副行(跨所有列),用于追加展开信息。 return (
@@ -65,11 +71,13 @@ export function CredentialRowShell({ title, subtitle, badges, metrics, side, row
{metrics}
{side}
+ {usage !== undefined &&
{usage}
} + {footer &&
{footer}
}
) } -export function CredentialTableHeader({ nameLabel, totalRequestsLabel, successRateLabel, totalTokensLabel, cacheReadRateLabel, sideLabel, rowClassName }: CredentialTableHeaderProps) { +export function CredentialTableHeader({ nameLabel, totalRequestsLabel, successRateLabel, totalTokensLabel, cacheReadRateLabel, sideLabel, usageLabel, rowClassName }: CredentialTableHeaderProps) { return (
{nameLabel} @@ -80,6 +88,7 @@ export function CredentialTableHeader({ nameLabel, totalRequestsLabel, successRa {cacheReadRateLabel}
{sideLabel} + {usageLabel && {usageLabel}} ) } @@ -220,3 +229,25 @@ export function credentialToneClassName(prefix: string, tone: string): string { export function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1) } + +export function formatQuotaResetDuration(resetAt: string, t: Translate): string { + const resetMs = new Date(resetAt).getTime() + if (!Number.isFinite(resetMs)) { + return '' + } + const remainingMinutes = Math.max(0, Math.ceil((resetMs - Date.now()) / 60_000)) + const days = Math.floor(remainingMinutes / 1_440) + const hours = Math.floor((remainingMinutes % 1_440) / 60) + const minutes = remainingMinutes % 60 + const segments: string[] = [] + if (days > 0) { + segments.push(t('usage_stats.duration_days_short', { value: String(days).padStart(2, '0') })) + } + if (hours > 0) { + segments.push(t('usage_stats.duration_hours_short', { value: String(hours).padStart(2, '0') })) + } + if (minutes > 0) { + segments.push(t('usage_stats.duration_minutes_short', { value: String(minutes).padStart(2, '0') })) + } + return segments.slice(0, 2).join('') +} diff --git a/web/src/components/usage/credentials/CredentialSections.module.scss b/web/src/components/usage/credentials/CredentialSections.module.scss index 80039a32..078593b1 100644 --- a/web/src/components/usage/credentials/CredentialSections.module.scss +++ b/web/src/components/usage/credentials/CredentialSections.module.scss @@ -352,7 +352,7 @@ .credentialHealthPanel { display: grid; - grid-template-columns: max-content minmax(160px, 1fr); + grid-template-columns: minmax(211px, max-content) minmax(120px, 1fr); align-items: center; column-gap: 40px; min-width: 0; @@ -364,7 +364,7 @@ display: flex; flex-direction: column; gap: 6px; - width: max-content; + width: 100%; min-width: 0; } @@ -391,7 +391,7 @@ grid-template-columns: repeat(30, 4px); gap: 4px; align-items: end; - width: max-content; + width: 100%; min-width: 0; padding: 3px 2px 1px; overflow: visible; @@ -964,7 +964,7 @@ display: block; height: 100%; border-radius: inherit; - background: linear-gradient(90deg, #15803d, #22c55e); + background: #22c55e; transition: width 0.2s ease; } @@ -1596,6 +1596,16 @@ font-size: 10px; font-weight: 800; line-height: 1.18; + + &.authFileCredentialRow, + &.aiProviderCredentialRow { + grid-template-columns: 236px minmax(0, 448px) minmax(250px, 1fr); + } + + // 中继用量启用时表格头部也需要四列布局,与行结构保持一致 + &.aiProviderCredentialRowRelay { + grid-template-columns: 236px minmax(360px, 420px) minmax(360px, 1fr) minmax(200px, 0.85fr); + } } @include tablet { @@ -1664,6 +1674,19 @@ } } +// 中继用量启用时追加第四列“用量”,与健康列分开;同时给指标列和健康列足够空间,避免表头重叠。 +.aiProviderCredentialRowRelay { + grid-template-columns: 236px minmax(360px, 420px) minmax(360px, 1fr) minmax(200px, 0.85fr); + + @include tablet { + grid-template-columns: 1fr; + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .credentialNameRow { display: flex; align-items: center; @@ -1828,20 +1851,20 @@ } .credentialBadgeSuccess { - border-color: color-mix(in srgb, #16a34a 38%, var(--border-color)); - background: color-mix(in srgb, #16a34a 10%, var(--bg-primary)); + border-color: color-mix(in srgb, #22c55e 38%, var(--border-color)); + background: color-mix(in srgb, #22c55e 10%, var(--bg-primary)); color: #15803d; } .credentialBadgeWarning { - border-color: color-mix(in srgb, #d97706 38%, var(--border-color)); - background: color-mix(in srgb, #d97706 10%, var(--bg-primary)); - color: #b45309; + border-color: color-mix(in srgb, #facc15 48%, var(--border-color)); + background: color-mix(in srgb, #facc15 14%, var(--bg-primary)); + color: #854d0e; } .credentialBadgeDanger { - border-color: color-mix(in srgb, #dc2626 38%, var(--border-color)); - background: color-mix(in srgb, #dc2626 10%, var(--bg-primary)); + border-color: color-mix(in srgb, #ef4444 38%, var(--border-color)); + background: color-mix(in srgb, #ef4444 10%, var(--bg-primary)); color: #b91c1c; } @@ -1938,6 +1961,12 @@ justify-self: stretch; } +.credentialTableHeaderUsage { + min-width: 0; + justify-self: stretch; + overflow-wrap: break-word; +} + .credentialMetricHeaderCell { letter-spacing: 0; white-space: nowrap; @@ -1975,7 +2004,7 @@ } .credentialMetricValueWarning { - color: #d97706; + color: #ca8a04; } .credentialMetricValueDanger { @@ -1992,6 +2021,12 @@ justify-self: stretch; } +.credentialUsagePanel { + min-width: 0; + width: 100%; + justify-self: stretch; +} + .credentialQuotaSideWithAction { display: grid; grid-template-columns: minmax(350px, 1fr) 30px; @@ -2484,21 +2519,241 @@ } .credentialQuotaFillOk { - background: linear-gradient(90deg, #15803d, #22c55e); + background: #22c55e; } .credentialQuotaFillWarning { - background: linear-gradient(90deg, #ca8a04, #facc15); + background: #facc15; } .credentialQuotaFillDanger { - background: linear-gradient(90deg, #b91c1c, #ef4444); + background: #ef4444; } .credentialQuotaFillUnknown { background: var(--text-tertiary); } +// AiProviderUsagePanel:用量列内容容器。不带独立边框/圆角,融入"用量"列。 +.relayUsagePanel { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + width: 100%; +} + +// 未识别/不支持平台的提示文案。 +.relayUsageState { + color: var(--text-tertiary); + font-size: 11px; + line-height: 1.45; +} + +.relayUsageHeader { + display: flex; + align-items: center; + justify-content: flex-start; + min-width: 0; +} + +// 平台徽标切换器:点击徽标弹菜单覆盖平台(替代旧的下拉框)。 +.relayPlatformSwitcher { + position: relative; + display: inline-flex; +} + +// 平台徽标切换器:样式与行首别名编辑按钮一致(小方块、淡边框、hover 转 primary)。 +.relayPlatformBadge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + height: 24px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + color: var(--text-tertiary); + font-size: 11px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease, background-color 0.16s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary-color) 36%, var(--border-color)); + background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary)); + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.relayPlatformBadgeCaret { + font-size: 9px; + opacity: 0.7; +} + +.relayPlatformMenu { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + display: flex; + flex-direction: column; + min-width: 120px; + padding: 4px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); + + button { + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 11px; + font-weight: 700; + text-align: left; + padding: 6px 9px; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-primary)); + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + } +} + +.relayPlatformMenuItemActive { + background: color-mix(in srgb, var(--primary-color) 14%, var(--bg-primary)); + color: var(--text-primary); +} + +// 用量表格:所有窗口的单元格共享同一个 grid,进度条列由 grid 统一分宽,多窗口天然对齐。 +.relayUsageTable { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(40px, 0.8fr) auto; + align-items: center; + column-gap: 6px; + row-gap: 2px; + min-width: 0; + font-size: 10.5px; + line-height: 1.35; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.relayUsageCellLabel { + color: var(--text-primary); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relayUsageCellUsage { + color: var(--text-secondary); + white-space: nowrap; +} + +.relayUsageCellBar { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + + // 进度条缩短变细(复用 credentialQuotaTrack / credentialQuotaFill 配色)。 + .credentialQuotaTrack { + flex: 0 1 40px; + min-width: 24px; + max-width: 48px; + height: 6px; + } +} + +.relayUsageCellPercent { + flex: 0 0 auto; + color: var(--text-primary); + font-weight: 700; + white-space: nowrap; +} + +.relayUsageCellReset { + color: var(--text-tertiary); + white-space: nowrap; + text-align: right; +} + +// DeepSeek 余额块。 +.relayBalanceBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.relayBalanceHead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + + span { + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + } + + strong { + color: var(--text-primary); + font-size: 16px; + font-weight: 800; + font-variant-numeric: tabular-nums; + } +} + +.relayBalanceDetails { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; +} + +.relayBalanceDetailItem { + display: inline-flex; + align-items: baseline; + gap: 4px; + color: var(--text-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; + + strong { + color: var(--text-secondary); + font-weight: 700; + } +} + +@include mobile { + .relayUsageTable { + // 窄屏隐藏重置列,保留窗口/用量/进度。 + grid-template-columns: minmax(54px, 1fr) minmax(52px, 0.9fr) minmax(72px, 1.4fr); + } + + .relayUsageCellReset { + display: none; + } +} + .credentialQuotaState, .credentialQuotaStateError, .credentialQuotaRefreshStatus, diff --git a/web/src/components/usage/credentials/credentialViewModels.ts b/web/src/components/usage/credentials/credentialViewModels.ts index f3b0ab62..2013c15b 100644 --- a/web/src/components/usage/credentials/credentialViewModels.ts +++ b/web/src/components/usage/credentials/credentialViewModels.ts @@ -1,4 +1,4 @@ -import type { UsageCredentialHealth, UsageIdentity, UsageQuotaCheckResponse, UsageQuotaRow } from '@/lib/types' +import type { RelayPlatformAssignment, RelayUsageItem, RelayUsageResult, UsageCredentialHealth, UsageIdentity, UsageQuotaCheckResponse, UsageQuotaRow } from '@/lib/types' import { calculateCacheReadRate, formatCompactTokenValue } from '@/utils/usage' export const CREDENTIALS_PAGE_SIZE = 10 @@ -24,6 +24,8 @@ export interface QuotaBillingUsageDisplay { export interface DisplayQuota { key: string label: string + /** 用于 i18n 的固定 key;后端 key 可能是动态生成的(如 window_5_MINUTE)。 */ + translationKey?: string scope?: string groupKey?: string groupLabel?: string @@ -86,6 +88,10 @@ export interface AiProviderCredentialRow { lastUsedText?: string statsUpdatedText?: string credentialHealth?: UsageCredentialHealth + // 中转商用量(claude-api-key/openai-compatibility 指向中转商时) + relayUsageResult?: RelayUsageItem + relayPlatform?: string + relayPlatformSource?: string } export interface CredentialIdentityGroups { @@ -175,28 +181,58 @@ export function buildAuthFileCredentialRows( }) } -export function buildAiProviderCredentialRows(identities: UsageIdentity[]): AiProviderCredentialRow[] { - return identities.map((identity) => ({ - identity, - displayName: credentialDisplayName(identity), - maskedIdentity: identity.identity, - providerLabel: credentialProviderLabel(identity), - typeLabel: credentialTypeLabel(identity), - authTypeLabel: credentialAuthTypeLabel(identity), - priorityLabel: credentialPriorityLabel(identity.priority), - totalRequests: safeNumber(identity.total_requests), - successCount: safeNumber(identity.success_count), - failureCount: safeNumber(identity.failure_count), - successRate: successRate(identity), - totalTokens: safeNumber(identity.total_tokens), - cacheReadRate: cacheReadRate(identity), - lastUsedText: identity.last_used_at, - statsUpdatedText: identity.stats_updated_at, - credentialHealth: identity.credential_health, - })) -} - -function toDisplayQuota(row: UsageQuotaRow): DisplayQuota | undefined { +export function buildAiProviderCredentialRows( + identities: UsageIdentity[], + relayUsage: Map = new Map(), + relayAssignments: Map = new Map(), +): AiProviderCredentialRow[] { + return identities.map((identity) => { + const assignment = relayAssignments.get(identity.id) + const usageItem = relayUsage.get(identity.id) + return { + identity, + displayName: credentialDisplayName(identity), + maskedIdentity: identity.identity, + providerLabel: credentialProviderLabel(identity), + typeLabel: credentialTypeLabel(identity), + authTypeLabel: credentialAuthTypeLabel(identity), + priorityLabel: credentialPriorityLabel(identity.priority), + totalRequests: safeNumber(identity.total_requests), + successCount: safeNumber(identity.success_count), + failureCount: safeNumber(identity.failure_count), + successRate: successRate(identity), + totalTokens: safeNumber(identity.total_tokens), + cacheReadRate: cacheReadRate(identity), + lastUsedText: identity.last_used_at, + statsUpdatedText: identity.stats_updated_at, + credentialHealth: identity.credential_health, + relayUsageResult: usageItem, + relayPlatform: assignment?.platform ?? usageItem?.platform, + relayPlatformSource: assignment?.source, + } + }) +} + +// buildRelayDisplayQuotas 把中转商 rows 复用 quota 进度条渲染。 +// 这里只过滤没有 label 的项——只要后端返回了窗口就展示,进度推导不出来时空轨道 + "—", +// 避免 GLM 的多个窗口因 usedPercent 缺失被整体隐藏(后端 limit<=0 时 percent 为 nil)。 +export function buildRelayDisplayQuotas(result: RelayUsageResult): DisplayQuota[] { + return (result.rows ?? []) + .map(toDisplayQuota) + .filter((quota): quota is DisplayQuota => quota !== undefined) + // 进度条表达"已用比例"(条越长用得越多),与 GLM percentage 语义一致: + // used kind 直接取 percent;remaining kind 取 100-percent;推不出但已用为 0 时按 0%,否则留 null 显示 —。 + .map((quota) => { + if (quota.percent != null) { + const usedPct = quota.percentKind === 'used' ? quota.percent : 100 - quota.percent + return { ...quota, barPercent: clampPercent(usedPct) } + } + if (quota.used === 0) return { ...quota, barPercent: 0 } + return quota + }) +} + +export function toDisplayQuota(row: UsageQuotaRow): DisplayQuota | undefined { // 后端 quota row 可能是 used、remaining 或 remainingFraction,这里统一成展示进度。 const used = finiteNumber(row.used) const limit = finiteNumber(row.limit) @@ -212,6 +248,7 @@ function toDisplayQuota(row: UsageQuotaRow): DisplayQuota | undefined { return { key: row.key, label, + translationKey: quotaTranslationKey(row, windowSeconds), scope: row.scope, groupKey: row.groupKey, groupLabel: row.groupLabel, @@ -292,6 +329,38 @@ function formatQuotaWindowCost(cost: number): string { }).format(cost || 0).replace(/^US\$/, '$') } +function quotaTranslationKey(row: UsageQuotaRow, windowSeconds?: number): string | undefined { + // 优先按后端给出的英文 label 匹配固定翻译 key,因为部分中转商(如 Kimi)的窗口 key/seconds 不稳定。 + const label = row.label?.trim() + if (label) { + if (label === '5h Tokens') return '5hour_tokens' + if (label === 'Weekly Tokens') return 'weekly_tokens' + if (label === 'Monthly MCP') return 'monthly_mcp' + if (label === 'Hourly') return 'hourly_tokens' + if (label === 'Daily') return 'daily_tokens' + if (label === 'Parallel Requests') return 'parallel_requests' + } + if (row.key === 'parallel_requests') { + return 'parallel_requests' + } + if (windowSeconds === FIVE_HOUR_WINDOW_SECONDS) { + return '5hour_tokens' + } + if (windowSeconds === WEEKLY_WINDOW_SECONDS) { + return 'weekly_tokens' + } + if (windowSeconds === THIRTY_DAY_WINDOW_SECONDS || windowSeconds === AVERAGE_MONTH_WINDOW_SECONDS) { + return 'monthly_mcp' + } + if (windowSeconds === 60 * 60) { + return 'hourly_tokens' + } + if (windowSeconds === 24 * 60 * 60) { + return 'daily_tokens' + } + return undefined +} + function quotaLabel(row: UsageQuotaRow, windowSeconds?: number): string | undefined { // 对已知窗口按秒数纠正标签;未知窗口不展示 Window 占位,避免误导用户。 const label = row.label || row.metric || row.scope || row.key @@ -431,7 +500,7 @@ function quotaUsedPercent(percentDisplay: { percent: number | null; kind: Displa return undefined } -function isDisplayableQuota(quota: DisplayQuota | undefined): quota is DisplayQuota { +export function isDisplayableQuota(quota: DisplayQuota | undefined): quota is DisplayQuota { return quota !== undefined && quota.barPercent !== null } diff --git a/web/src/components/usage/credentials/test/AuthFileCredentialsSection.test.ts b/web/src/components/usage/credentials/test/AuthFileCredentialsSection.test.ts index 7c3863ed..b1e8367c 100644 --- a/web/src/components/usage/credentials/test/AuthFileCredentialsSection.test.ts +++ b/web/src/components/usage/credentials/test/AuthFileCredentialsSection.test.ts @@ -1,7 +1,8 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { AuthFileCredentialsSection, AuthFileQuotaPanel, INSPECTION_RESULT_PAGE_SIZE_OPTIONS, QuotaAutoRefreshSettingsModal, QuotaInspectionModal, buildInspectionResultsPage, buildInvalidInspectionAccountFileNames, buildQuotaAutoRefreshSettings, formatInspectionCompletedAt, formatInspectionProgressPercent, formatQuotaErrorDisplay, formatQuotaResetDuration, formatQuotaResetLabel, formatQuotaWindowUsageAriaLabel, inspectionIndicatorTone, invertInvalidInspectionAccountFileNames, isAutoRefreshSettingsControlDisabled, isAutoRefreshSettingsSaveDisabled, isInspectionStartDisabled, isQuotaInspectionCloseDisabled, isSelectableInspectionStatusFilter, nextInspectionResultStatusFilter, persistAuthFileDisplayMode, readStoredAuthFileDisplayMode, resolveQuotaAutoRefreshSettingsLoadFailure, selectAllInvalidInspectionAccountFileNames } from '../AuthFileCredentialsSection' +import { AuthFileCredentialsSection, AuthFileQuotaPanel, INSPECTION_RESULT_PAGE_SIZE_OPTIONS, QuotaAutoRefreshSettingsModal, QuotaInspectionModal, buildInspectionResultsPage, buildInvalidInspectionAccountFileNames, buildQuotaAutoRefreshSettings, formatInspectionCompletedAt, formatInspectionProgressPercent, formatQuotaErrorDisplay, formatQuotaResetLabel, formatQuotaWindowUsageAriaLabel, inspectionIndicatorTone, invertInvalidInspectionAccountFileNames, isAutoRefreshSettingsControlDisabled, isAutoRefreshSettingsSaveDisabled, isInspectionStartDisabled, isQuotaInspectionCloseDisabled, isSelectableInspectionStatusFilter, nextInspectionResultStatusFilter, persistAuthFileDisplayMode, readStoredAuthFileDisplayMode, resolveQuotaAutoRefreshSettingsLoadFailure, selectAllInvalidInspectionAccountFileNames } from '../AuthFileCredentialsSection' +import { formatQuotaResetDuration } from '../CredentialSectionShell' import type { AuthFileCredentialRow, DisplayQuota } from '../credentialViewModels' import type { UsageQuotaInspectionResult, UsageQuotaInspectionResultStatus } from '@/lib/types' @@ -49,13 +50,21 @@ const formatLocalResetTime = (resetAt: string) => { return `${month}/${day} ${hour}:${minute}` } +const translateDurationShort = (key: string, params?: Record): string => { + const value = (params?.value as string | undefined) ?? '' + if (key === 'usage_stats.duration_days_short') return `${value}d` + if (key === 'usage_stats.duration_hours_short') return `${value}h` + if (key === 'usage_stats.duration_minutes_short') return `${value}m` + return key +} + describe('AuthFileCredentialsSection quota reset formatting', () => { it('formats reset labels with days when remaining time exceeds 24 hours', () => { vi.setSystemTime(new Date('2026-05-10T10:00:00Z')) try { const resetAt = '2026-05-12T10:15:00Z' expect(formatQuotaResetLabel(resetAt)).toBe(formatLocalResetTime(resetAt)) - expect(formatQuotaResetDuration(resetAt)).toBe('2d0h15m') + expect(formatQuotaResetDuration(resetAt, translateDurationShort)).toBe('02d15m') } finally { vi.useRealTimers() } @@ -66,7 +75,7 @@ describe('AuthFileCredentialsSection quota reset formatting', () => { try { const resetAt = '2026-05-10T14:15:00Z' expect(formatQuotaResetLabel(resetAt)).toBe(formatLocalResetTime(resetAt)) - expect(formatQuotaResetDuration(resetAt)).toBe('4h15m') + expect(formatQuotaResetDuration(resetAt, translateDurationShort)).toBe('04h15m') } finally { vi.useRealTimers() } diff --git a/web/src/components/usage/credentials/test/CredentialSections.styles.test.ts b/web/src/components/usage/credentials/test/CredentialSections.styles.test.ts index 59c2461f..2d76e422 100644 --- a/web/src/components/usage/credentials/test/CredentialSections.styles.test.ts +++ b/web/src/components/usage/credentials/test/CredentialSections.styles.test.ts @@ -26,7 +26,8 @@ describe('Credential section styles', () => { expect(credentialStyles).toMatch(/\.aiProviderCredentialRow\s*\{[\s\S]*?@include tablet\s*\{[\s\S]*?grid-template-columns:\s*1fr;/) expect(credentialStyles).toMatch(/\.aiProviderCredentialRow\s*\{[\s\S]*?@include mobile\s*\{[\s\S]*?grid-template-columns:\s*1fr;/) expect(credentialShellSource).toContain('rowClassName?: string') - expect(aiProviderSectionSource).toContain('rowClassName={styles.aiProviderCredentialRow}') + expect(aiProviderSectionSource).toContain('rowClassName={relayRowClassName}') + expect(aiProviderSectionSource).toContain('aiProviderCredentialRowRelay') expect(authFileSectionSource).toContain('rowClassName={styles.authFileCredentialRow}') expect(authFileSectionSource).toContain(' { expect(credentialStyles).not.toContain('credentialQuotaSidePanel') expect(credentialStyles).not.toContain('credentialQuotaRow') expect(credentialStyles).toMatch(/\.credentialHealthPanel\s*\{[\s\S]*?display:\s*grid;/) - expect(credentialStyles).toMatch(/\.credentialHealthPanel\s*\{[\s\S]*?grid-template-columns:\s*max-content minmax\(160px, 1fr\);/) + expect(credentialStyles).toMatch(/\.credentialHealthPanel\s*\{[\s\S]*?grid-template-columns:\s*minmax\(211px, max-content\) minmax\(120px, 1fr\);/) expect(credentialStyles).toMatch(/\.credentialHealthPanel\s*\{[\s\S]*?column-gap:\s*40px;/) expect(credentialStyles).toMatch(/\.credentialHealthChart\s*\{[\s\S]*?width:\s*max-content;/) expect(credentialStyles).toMatch(/\.credentialHealthMeta\s*\{[\s\S]*?flex-direction:\s*column;/) @@ -251,7 +252,7 @@ describe('Credential section styles', () => { expect(credentialStyles).toMatch(/\.credentialInspectionProgressTrack\s*\{[\s\S]*?height:\s*8px;/) expect(credentialStyles).toMatch(/\.credentialInspectionProgressTrack\s*\{[\s\S]*?background:\s*var\(--bg-tertiary\);/) expect(credentialStyles).toMatch(/\.credentialInspectionProgressTrack\s*\{[\s\S]*?border:\s*1px solid var\(--border-color\);/) - expect(credentialStyles).toMatch(/\.credentialInspectionProgressFill\s*\{[^}]*background:\s*linear-gradient\(90deg, #15803d, #22c55e\);/) + expect(credentialStyles).toMatch(/\.credentialInspectionProgressFill\s*\{[^}]*background:\s*#22c55e;/) expect(credentialStyles).not.toMatch(/\.credentialInspectionProgressFill\s*\{[^}]*box-shadow:/) expect(credentialStyles).not.toContain('.credentialInspectionProgressFill::after') expect(credentialStyles).toMatch(/\.credentialInspectionSummary\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 80px\) minmax\(260px, 1fr\) auto;/) diff --git a/web/src/components/usage/credentials/useCredentialsTabData.ts b/web/src/components/usage/credentials/useCredentialsTabData.ts index be2e44dc..7bade964 100644 --- a/web/src/components/usage/credentials/useCredentialsTabData.ts +++ b/web/src/components/usage/credentials/useCredentialsTabData.ts @@ -9,6 +9,7 @@ import { import { useCredentialPages } from './useCredentialPages' import { useQuotaCache } from './useQuotaCache' import { useQuotaInspection } from './useQuotaInspection' +import { useRelayProviderUsage } from './useRelayProviderUsage' import { ApiError, resetUsageQuota, updateUsageIdentityAlias, type UsageIdentityPageSort } from '@/lib/api' import i18n from '@/i18n' import type { UsageIdentityTypeCount, UsageQuotaCheckResponse, UsageQuotaInspectionStatusResponse } from '@/lib/types' @@ -71,10 +72,19 @@ export interface CredentialsTabData { resetQuotaForAuthIndex: (authIndex: string) => Promise refreshQuotaInspectionStatus: () => Promise startQuotaInspection: () => Promise + relayRefreshing: boolean + relayError?: string + refreshRelayUsage: (identityIds?: string[]) => Promise + setRelayPlatformOverride: (identityId: string, platform: string) => Promise } export function useCredentialsTabData({ enabledAuthFiles, enabledAiProviders, onAuthRequired, onNotice }: UseCredentialsTabDataOptions): CredentialsTabData { const credentialPages = useCredentialPages({ enabledAuthFiles, enabledAiProviders, onAuthRequired }) + const aiProviderIds = useMemo( + () => credentialPages.aiProviderIdentities.map((identity) => identity.id), + [credentialPages.aiProviderIdentities], + ) + const relayUsage = useRelayProviderUsage(aiProviderIds) const currentAuthIndexes = useMemo( () => selectQuotaEligibleAuthIndexes(credentialPages.authFileIdentities), [credentialPages.authFileIdentities], @@ -110,8 +120,12 @@ export function useCredentialsTabData({ enabledAuthFiles, enabledAiProviders, on [credentialPages.authFileIdentities, quotaResponsesByAuthIndex, quotaStates], ) const aiProviderRows = useMemo( - () => buildAiProviderCredentialRows(credentialPages.aiProviderIdentities), - [credentialPages.aiProviderIdentities], + () => buildAiProviderCredentialRows( + credentialPages.aiProviderIdentities, + relayUsage.usage, + relayUsage.assignments, + ), + [credentialPages.aiProviderIdentities, relayUsage.usage, relayUsage.assignments], ) const refreshCredentialPages = credentialPages.refresh const refresh = useCallback(async () => { @@ -206,6 +220,10 @@ export function useCredentialsTabData({ enabledAuthFiles, enabledAiProviders, on resetQuotaForAuthIndex, refreshQuotaInspectionStatus: quotaInspection.refreshQuotaInspectionStatus, startQuotaInspection: quotaInspection.startQuotaInspection, + relayRefreshing: relayUsage.loadingUsage, + relayError: relayUsage.error, + refreshRelayUsage: relayUsage.refreshUsage, + setRelayPlatformOverride: relayUsage.setPlatformOverride, } } diff --git a/web/src/components/usage/credentials/useRelayProviderUsage.ts b/web/src/components/usage/credentials/useRelayProviderUsage.ts new file mode 100644 index 00000000..bb99bc7a --- /dev/null +++ b/web/src/components/usage/credentials/useRelayProviderUsage.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + fetchRelayPlatformAssignments, + fetchRelayPlatformOverrides, + fetchRelayProviderUsage, + updateRelayPlatformOverrides, +} from '@/lib/api' +import type { RelayPlatformAssignment, RelayUsageItem } from '@/lib/types' + +// useRelayProviderUsage 管理 AI Provider 凭证的中转商用量查询状态。 +// assignments 是轻量的平台判定(只匹配不查接口),usage 是实际调用中转商接口的结果。 +// 两者分离:进入页面只拉 assignments 显示平台 badge,点刷新才触发 usage 查询(避免无谓的外部请求)。 + +export interface UseRelayProviderUsage { + assignments: Map + usage: Map + loadingUsage: boolean + error?: string + refreshUsage: (identityIds?: string[]) => Promise + setPlatformOverride: (identityId: string, platform: string) => Promise +} + +export function useRelayProviderUsage(identityIds: string[]): UseRelayProviderUsage { + const [assignments, setAssignments] = useState>(new Map()) + const [usage, setUsage] = useState>(new Map()) + const [overrides, setOverrides] = useState>({}) + const [loadingUsage, setLoadingUsage] = useState(false) + const [error, setError] = useState() + + const seqRef = useRef(0) + const idsRef = useRef(identityIds) + idsRef.current = identityIds + + const loadAssignments = useCallback(async (ids: string[]) => { + if (ids.length === 0) return + const seq = ++seqRef.current + try { + const [overridesResp, assignmentsResp] = await Promise.all([ + fetchRelayPlatformOverrides(), + fetchRelayPlatformAssignments(ids), + ]) + if (seq !== seqRef.current) return + setOverrides(overridesResp.overrides ?? {}) + const map = new Map() + for (const item of assignmentsResp.assignments ?? []) { + map.set(item.identity_id, item) + } + setAssignments(map) + setError(undefined) + } catch (err) { + if (seq !== seqRef.current) return + setError(err instanceof Error ? err.message : 'Failed to load relay platform info') + } + }, []) + + const refreshUsage = useCallback(async (ids?: string[]) => { + const targetIds = ids ?? idsRef.current + if (targetIds.length === 0) return + setLoadingUsage(true) + try { + const resp = await fetchRelayProviderUsage(targetIds) + setUsage((prev) => { + const map = new Map(prev) + for (const item of resp.items ?? []) { + map.set(item.identity_id, item) + } + return map + }) + setError(undefined) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load relay usage') + } finally { + setLoadingUsage(false) + } + }, []) + + const setPlatformOverride = useCallback(async (identityId: string, platform: string) => { + const previous = overrides + // 'auto' 表示删除手动覆盖,回退到按域名自动识别。 + const next: Record = { ...overrides } + if (platform === 'auto') { + delete next[identityId] + } else { + next[identityId] = platform + } + setOverrides(next) // 乐观更新 + try { + const resp = await updateRelayPlatformOverrides(next) + setOverrides(resp.overrides ?? {}) + // 覆盖变更后平台判定可能变化,重新拉 assignments。 + await loadAssignments(idsRef.current) + } catch (err) { + setOverrides(previous) // 回滚 + setError(err instanceof Error ? err.message : 'Failed to update platform override') + } + }, [overrides, loadAssignments]) + + // identityIds 变化时加载 assignments + overrides,并默认拉一次用量,让数据进页面即展示。 + // 用 join 串作依赖,避免数组引用抖动导致重复请求;usage 只对匹配中转商的凭证真正请求外部接口。 + const idsKey = identityIds.join(',') + useEffect(() => { + const ids = idsKey ? idsKey.split(',') : [] + void loadAssignments(ids) + void refreshUsage(ids) + }, [idsKey, loadAssignments, refreshUsage]) + + return { + assignments, + usage, + loadingUsage, + error, + refreshUsage, + setPlatformOverride, + } +} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 8e7fdb00..89d9a2b6 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -223,6 +223,9 @@ const resources = { duration_unit_m: 'm', duration_unit_h: 'h', duration_unit_d: 'd', + duration_days_short: '{{value}}d', + duration_hours_short: '{{value}}h', + duration_minutes_short: '{{value}}m', filter_all: 'All', clear_filters: 'Clear Filters', export_csv: 'Export CSV', @@ -314,6 +317,7 @@ const resources = { credentials_column_name: 'Name', credentials_column_quota: 'Quota', credentials_column_health: 'Health', + credentials_column_usage: 'Usage', credentials_column_activity: 'Activity', credentials_sort_label: 'Sort', credentials_sort_priority: 'Priority', @@ -323,6 +327,30 @@ const resources = { credentials_ai_providers_title: 'AI Provider', credentials_ai_providers_subtitle: 'View request activity for AI providers.', credentials_ai_providers_empty: 'No AI provider credentials on this page.', + relay_usage_refresh: 'Refresh usage', + relay_usage_balance: 'Balance', + relay_usage_balance_granted: 'Granted', + relay_usage_balance_topped_up: 'Topped up', + relay_usage_col_window: 'Window', + relay_usage_col_usage: 'Usage', + relay_usage_col_progress: 'Progress', + relay_usage_col_reset: 'Reset', + relay_usage_empty: 'No usage data yet. Click refresh to query the relay provider.', + relay_usage_unsupported: 'The AI provider has not exposed a usage endpoint yet. Stay tuned.', + relay_window_5hour_tokens: '5h Tokens', + relay_window_weekly_tokens: 'Weekly Tokens', + relay_window_monthly_mcp: 'Monthly MCP', + relay_window_hourly_tokens: 'Hourly Tokens', + relay_window_daily_tokens: 'Daily Tokens', + relay_window_parallel_requests: 'Parallel Requests', + relay_platform_label: 'Platform', + relay_platform_auto: 'Auto', + relay_platform_glm: 'GLM', + relay_platform_minimax: 'MiniMax', + relay_platform_kimi: 'Kimi', + relay_platform_deepseek: 'DeepSeek', + relay_platform_none: 'None', + relay_platform_unknown: 'Unknown', credentials_quota_refresh_current_page: 'Update Quotas', credentials_quota_reset_button: 'Reset quota, {{count}} available', credentials_quota_reset_title: 'Reset Codex quota', @@ -789,6 +817,9 @@ const resources = { duration_unit_m: '分钟', duration_unit_h: '小时', duration_unit_d: '天', + duration_days_short: '{{value}}天', + duration_hours_short: '{{value}}时', + duration_minutes_short: '{{value}}分', filter_all: '全部', clear_filters: '清除筛选', export_csv: '导出 CSV', @@ -880,6 +911,7 @@ const resources = { credentials_column_name: '名称', credentials_column_quota: '限额', credentials_column_health: '健康', + credentials_column_usage: '用量', credentials_column_activity: '活动', credentials_sort_label: '排序', credentials_sort_priority: '优先级', @@ -889,6 +921,30 @@ const resources = { credentials_ai_providers_title: 'AI 供应商', credentials_ai_providers_subtitle: '查看 AI 供应商的请求活动。', credentials_ai_providers_empty: '当前页没有 AI 供应商凭证。', + relay_usage_refresh: '刷新用量', + relay_usage_balance: '余额', + relay_usage_balance_granted: '赠送', + relay_usage_balance_topped_up: '充值', + relay_usage_col_window: '窗口', + relay_usage_col_usage: '用量', + relay_usage_col_progress: '进度', + relay_usage_col_reset: '重置', + relay_usage_empty: '暂无用量数据,点击刷新查询中转商用量。', + relay_usage_unsupported: 'AI 供应商未提供查询端点,敬请期待~', + relay_window_5hour_tokens: '5 小时令牌', + relay_window_weekly_tokens: '每周令牌', + relay_window_monthly_mcp: '每月 MCP', + relay_window_hourly_tokens: '每小时令牌', + relay_window_daily_tokens: '每日令牌', + relay_window_parallel_requests: '并行请求', + relay_platform_label: '平台', + relay_platform_auto: '自动', + relay_platform_glm: 'GLM', + relay_platform_minimax: 'MiniMax', + relay_platform_kimi: 'Kimi', + relay_platform_deepseek: 'DeepSeek', + relay_platform_none: '无', + relay_platform_unknown: '未知', credentials_quota_refresh_current_page: '更新限额', credentials_quota_reset_button: '重置限额,可用 {{count}} 次', credentials_quota_reset_title: '重置 Codex 限额', @@ -1355,6 +1411,9 @@ const resources = { duration_unit_m: '分鐘', duration_unit_h: '小時', duration_unit_d: '天', + duration_days_short: '{{value}}天', + duration_hours_short: '{{value}}時', + duration_minutes_short: '{{value}}分', filter_all: '全部', clear_filters: '清除篩選', export_csv: '匯出 CSV', @@ -1446,6 +1505,7 @@ const resources = { credentials_column_name: '名稱', credentials_column_quota: '限額', credentials_column_health: '健康', + credentials_column_usage: '用量', credentials_column_activity: '活動', credentials_sort_label: '排序', credentials_sort_priority: '優先級', @@ -1455,6 +1515,30 @@ const resources = { credentials_ai_providers_title: 'AI 供應商', credentials_ai_providers_subtitle: '查看 AI 供應商的請求活動。', credentials_ai_providers_empty: '目前頁面沒有 AI 供應商憑證。', + relay_usage_refresh: '刷新用量', + relay_usage_balance: '餘額', + relay_usage_balance_granted: '贈送', + relay_usage_balance_topped_up: '儲值', + relay_usage_col_window: '窗口', + relay_usage_col_usage: '用量', + relay_usage_col_progress: '進度', + relay_usage_col_reset: '重置', + relay_usage_empty: '暫無用量資料,點擊刷新查詢中轉商用量。', + relay_usage_unsupported: 'AI 供應商未提供查詢端點,敬請期待~', + relay_window_5hour_tokens: '5 小時令牌', + relay_window_weekly_tokens: '每週令牌', + relay_window_monthly_mcp: '每月 MCP', + relay_window_hourly_tokens: '每小時令牌', + relay_window_daily_tokens: '每日令牌', + relay_window_parallel_requests: '並行請求', + relay_platform_label: '平台', + relay_platform_auto: '自動', + relay_platform_glm: 'GLM', + relay_platform_minimax: 'MiniMax', + relay_platform_kimi: 'Kimi', + relay_platform_deepseek: 'DeepSeek', + relay_platform_none: '無', + relay_platform_unknown: '未知', credentials_quota_refresh_current_page: '更新限額', credentials_quota_reset_button: '重置限額,可用 {{count}} 次', credentials_quota_reset_title: '重置 Codex 限額', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7ce4a67e..ea6d03ea 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import { type AnalysisResponse, type AuthFilesManagementResponse, type AuthManagedSessionsResponse, type AuthSessionResponse, type CpaApiKeyDisplayItem, type CpaApiKeyOptionsResponse, type CpaApiKeySettingsResponse, type CpaApiKeysResponse, type OverviewRealtimeBlock, type OverviewRealtimeWindow, type PricingEntry, type PricingResponse, type PricingSyncPreviewResponse, type QuotaAutoRefreshSettings, type StatusResponse, type UpdateCheckResponse, type UsageEventModelFilterOptionsResponse, type UsageEventRequestLogResponse, type UsageEventSourceFilterOptionsResponse, type UsageRangeRequest, type UsedModelsResponse, type UsageIdentitiesPageResponse, type UsageIdentitiesResponse, type UsageEventsResponse, type UsageIdentity, type UsageIdentityAuthType, type UsageOverviewResponse, type UsageQuotaCacheResponse, type UsageQuotaInspectionStatusResponse, type UsageQuotaRefreshResponse, type UsageQuotaRefreshTaskResponse, type UsageQuotaResetCreditsResponse, type UsageQuotaResetResponse, type VersionResponse } from './types' +import { type AnalysisResponse, type AuthFilesManagementResponse, type AuthManagedSessionsResponse, type AuthSessionResponse, type CpaApiKeyDisplayItem, type CpaApiKeyOptionsResponse, type CpaApiKeySettingsResponse, type CpaApiKeysResponse, type KeyOverviewTimeRange, type OverviewRealtimeBlock, type OverviewRealtimeWindow, type PricingEntry, type PricingResponse, type PricingSyncPreviewResponse, type QuotaAutoRefreshSettings, type StatusResponse, type UpdateCheckResponse, type UsageEventModelFilterOptionsResponse, type UsageEventRequestLogResponse, type UsageEventSourceFilterOptionsResponse, type UsageRangeRequest, type UsedModelsResponse, type RelayPlatformAssignmentsResponse, type RelayPlatformOverridesResponse, type RelayUsageResponse, type UsageIdentitiesPageResponse, type UsageIdentitiesResponse, type UsageEventsResponse, type UsageIdentity, type UsageIdentityAuthType, type UsageOverviewResponse, type UsageQuotaCacheResponse, type UsageQuotaInspectionStatusResponse, type UsageQuotaRefreshResponse, type UsageQuotaRefreshTaskResponse, type UsageQuotaResetCreditsResponse, type UsageQuotaResetResponse, type VersionResponse } from './types' import { isCPAMCEmbed } from '@/embed/cpamcEmbed' import { resolveUsageRequestRange } from '@/utils/usage/rangeQuery' @@ -106,7 +106,7 @@ export function appPath(path: string): string { export function apiPath(path: string): string { const normalizedPath = path.startsWith('/') ? path : `/${path}` - return `${normalizeBasePath(window.__APP_BASE_PATH__)}/api/v1${normalizedPath}` + return `${normalizeBasePath(window.__APP_BASE_PATH__)}api/v1${normalizedPath}` } async function parseApiError(response: Response, fallback: string): Promise { @@ -530,9 +530,7 @@ export async function fetchUsageIdentitiesPage(signal?: AbortSignal, options?: F export async function updateUsageIdentityAlias(id: string, alias: string | null): Promise { const response = await apiFetch(apiPath(`/usage/identities/${encodeURIComponent(id)}`), { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ alias }), }) if (!response.ok) { @@ -545,9 +543,7 @@ export async function fetchUsageQuotaCache(authIndexes: string[], signal?: Abort // cache 只读后端已有结果,不携带刷新 limit,避免把缓存读取误当队列提交。 const response = await apiFetch(apiPath('/quota/cache'), { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_indexes: authIndexes }), signal, }) @@ -561,9 +557,7 @@ export async function refreshUsageQuotas(authIndexes: string[], signal?: AbortSi // refresh 会创建后台任务,前端提交当前页所有 auth_index。 const response = await apiFetch(apiPath('/quota/refresh'), { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_indexes: authIndexes }), signal, }) @@ -582,23 +576,17 @@ export async function fetchUsageQuotaInspectionStatus(signal?: AbortSignal): Pro } export async function startUsageQuotaInspection(signal?: AbortSignal): Promise { - const response = await apiFetch(apiPath('/quota/inspection'), { - method: 'POST', - signal, - }) + const response = await apiFetch(apiPath('/quota/inspection'), { method: 'POST', signal }) if (!response.ok) { - await parseApiError(response, `Failed to start quota inspection: ${response.status}`) + await parseApiError(response, `Failed to start quota inspection status: ${response.status}`) } return response.json() } - export async function resetUsageQuota(authIndex: string, signal?: AbortSignal): Promise { const response = await apiFetch(apiPath('/quota/reset'), { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_index: authIndex }), signal, }) @@ -627,9 +615,7 @@ export async function fetchUsageQuotaRefreshTask(authIndex: string, signal?: Abo export async function setAuthFilesDisabled(names: string[], disabled: boolean): Promise { const response = await apiFetch(apiPath('/auth-files/status'), { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names, disabled }), }) if (!response.ok) { @@ -641,9 +627,7 @@ export async function setAuthFilesDisabled(names: string[], disabled: boolean): export async function deleteAuthFiles(names: string[]): Promise { const response = await apiFetch(apiPath('/auth-files'), { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names }), }) if (!response.ok) { @@ -666,7 +650,6 @@ export async function fetchAnalysis(request: UsageRangeRequest, signal?: AbortSi return response.json() } - export async function fetchCpaApiKeyOptions(signal?: AbortSignal): Promise { const response = await apiFetch(apiPath('/usage/api-keys/options'), { signal, cache: 'no-store' }) if (!response.ok) { @@ -694,9 +677,7 @@ export async function fetchCpaApiKeySettings(signal?: AbortSignal): Promise { const response = await apiFetch(apiPath(`/usage/api-keys/${encodeURIComponent(id)}`), { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyAlias }), }) if (!response.ok) { @@ -740,9 +721,7 @@ export async function fetchQuotaAutoRefreshSettings(signal?: AbortSignal): Promi export async function updateQuotaAutoRefreshSettings(settings: QuotaAutoRefreshSettings): Promise { const response = await apiFetch(apiPath('/quota/auto-refresh/settings'), { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings), }) if (!response.ok) { @@ -778,9 +757,7 @@ export async function fetchPricingSyncPreview(signal?: AbortSignal): Promise): Promise { const response = await apiFetch(apiPath('/pricing'), { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, ...pricing }), }) if (!response.ok) { @@ -798,3 +775,52 @@ export async function deletePricing(model: string): Promise { await parseApiError(response, `Failed to delete pricing: ${response.status}`) } } + +// === Relay provider usage(中转商用量查询)=== + +export async function fetchRelayProviderUsage(identityIds: string[], signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/relay-provider/usage'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity_ids: identityIds }), + signal, + }) + if (!response.ok) { + await parseApiError(response, `Failed to load relay provider usage: ${response.status}`) + } + return response.json() +} + +export async function fetchRelayPlatformAssignments(identityIds: string[], signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/relay-provider/assignments'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity_ids: identityIds }), + signal, + }) + if (!response.ok) { + await parseApiError(response, `Failed to load relay platform assignments: ${response.status}`) + } + return response.json() +} + +export async function fetchRelayPlatformOverrides(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/relay-provider/platform-overrides'), { signal, cache: 'no-store' }) + if (!response.ok) { + await parseApiError(response, `Failed to load relay platform overrides: ${response.status}`) + } + return response.json() +} + +export async function updateRelayPlatformOverrides(overrides: Record, signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/relay-provider/platform-overrides'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overrides }), + signal, + }) + if (!response.ok) { + await parseApiError(response, `Failed to update relay platform overrides: ${response.status}`) + } + return response.json() +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6e0d2583..1ddd1504 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -733,3 +733,46 @@ export interface UsageFilterWindow { endMs?: number windowMinutes?: number } + +// === Relay provider usage(中转商用量查询)=== +// 当 claude-api-key / openai-compatibility 指向中转商(GLM/MiniMax/Kimi/DeepSeek)时, +// 后端直连中转商用量接口返回的结果。rows 复用 UsageQuotaRow,便于前端复用 quota 渲染逻辑。 + +export interface RelayBalance { + available: number + granted?: number + toppedUp?: number + currency?: string +} + +export interface RelayUsageResult { + platform: string + balance?: RelayBalance + rows?: UsageQuotaRow[] + fetchedAt: string + error?: string +} + +export interface RelayUsageItem { + identity_id: string + platform?: string + result?: RelayUsageResult +} + +export interface RelayUsageResponse { + items: RelayUsageItem[] +} + +export interface RelayPlatformAssignment { + identity_id: string + platform: string + source: 'override' | 'auto' | 'none' +} + +export interface RelayPlatformAssignmentsResponse { + assignments: RelayPlatformAssignment[] +} + +export interface RelayPlatformOverridesResponse { + overrides: Record +} diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx index 97d662d0..a651e63b 100644 --- a/web/src/pages/UsagePage.tsx +++ b/web/src/pages/UsagePage.tsx @@ -860,6 +860,7 @@ export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { onNotice: showTopNotice, }); const refreshCredentials = credentialsData.refresh; + const refreshRelayUsage = credentialsData.refreshRelayUsage; const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisError, setAnalysisError] = useState(''); const [analysisData, setAnalysisData] = useState(null); @@ -1420,7 +1421,11 @@ export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { return; } if (credentialSectionVisibility.enabled) { - await refreshCredentials(); + // relay usage 跟随顶部全局刷新一起拉取(仅 AI Provider 可见时),不再有单行刷新按钮。 + await Promise.all([ + refreshCredentials(), + credentialSectionVisibility.showAiProvider ? refreshRelayUsage() : Promise.resolve(), + ]); return; } if (activeTab === 'analysis') { @@ -1432,7 +1437,7 @@ export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { return; } await Promise.all([loadUsage(), loadRealtime()]); - }, [activeTab, credentialSectionVisibility.enabled, loadAnalysis, loadApiKeySettings, loadAuthSessions, loadEventFilterOptions, loadEvents, loadPricing, loadRealtime, loadUsage, refreshCredentials]); + }, [activeTab, credentialSectionVisibility.enabled, credentialSectionVisibility.showAiProvider, loadAnalysis, loadApiKeySettings, loadAuthSessions, loadEventFilterOptions, loadEvents, loadPricing, loadRealtime, loadUsage, refreshCredentials, refreshRelayUsage]); const refreshAutoRefreshTab = useCallback(async () => { if (activeTab === 'events') { @@ -1985,6 +1990,7 @@ export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { onPageChange={credentialsData.setAiProviderPage} onPageSizeChange={credentialsData.setAiProviderPageSize} onSortChange={credentialsData.setAiProviderSort} + onSetRelayPlatform={(id, platform) => { void credentialsData.setRelayPlatformOverride(id, platform) }} /> )}