Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	frontend/src/components/account/AccountUsageCell.vue
This commit is contained in:
erio
2026-03-16 18:18:24 +08:00
45 changed files with 1228 additions and 277 deletions
+7
View File
@@ -4,6 +4,13 @@ backend/migrations/*.sql text eol=lf
# Go 源代码文件
*.go text eol=lf
# 前端 源代码文件
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.vue text eol=lf
# Shell 脚本
*.sh text eol=lf
+24 -6
View File
@@ -8,27 +8,31 @@
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
**AI API Gateway Platform for Subscription Quota Distribution**
English | [中文](README_CN.md)
</div>
> **Sub2API officially uses only the domains `sub2api.org` and `pincc.ai`. Other websites using the Sub2API name may be third-party deployments or services and are not affiliated with this project. Please verify and exercise your own judgment.**
---
## Demo
Try Sub2API online: **https://demo.sub2api.org/**
Try Sub2API online: **[https://demo.sub2api.org/](https://demo.sub2api.org/)**
Demo credentials (shared demo environment; **not** created automatically for self-hosted installs):
| Email | Password |
|-------|----------|
| admin@sub2api.com | admin123 |
| admin@sub2api.org | admin123 |
## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions (like Claude Code $200/month). Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
## Features
@@ -41,6 +45,15 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. payment, ticketing) via iframe to extend the admin dashboard
## Don't Want to Self-Host?
<table>
<tr>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> is the official relay service built on Sub2API, offering stable access to Claude Code, Codex, Gemini and other popular models — ready to use, no deployment or maintenance required.</td>
</tr>
</table>
## Ecosystem
Community projects that extend or integrate with Sub2API:
@@ -61,10 +74,15 @@ Community projects that extend or integrate with Sub2API:
---
## Documentation
## Nginx Reverse Proxy Note
- Dependency Security: `docs/dependency-security.md`
- Admin Payment Integration API: `docs/ADMIN_PAYMENT_INTEGRATION_API.md`
When using Nginx as a reverse proxy for Sub2API (or CRS) with Codex CLI, add the following to the `http` block in your Nginx configuration:
```nginx
underscores_in_headers on;
```
Nginx drops headers containing underscores by default (e.g. `session_id`), which breaks sticky session routing in multi-account setups.
---
+23 -10
View File
@@ -8,27 +8,30 @@
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
**AI API 网关平台 - 订阅配额分发管理**
[English](README.md) | 中文
</div>
> **Sub2API 官方仅使用 `sub2api.org` 与 `pincc.ai` 两个域名。其他使用 Sub2API 名义的网站可能为第三方部署或服务,与本项目无关,请自行甄别。**
---
## 在线体验
体验地址:**https://v2.pincc.ai/**
体验地址:**[https://demo.sub2api.org/](https://demo.sub2api.org/)**
演示账号(共享演示环境;自建部署不会自动创建该账号):
| 邮箱 | 密码 |
|------|------|
| admin@sub2api.com | admin123 |
| admin@sub2api.org | admin123 |
## 项目概述
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(如 Claude Code $200/月)的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
## 核心功能
@@ -41,6 +44,15 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
- **管理后台** - Web 界面进行监控和管理
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如支付、工单等),扩展管理后台功能
## 不想自建?试试官方中转
<table>
<tr>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> 是基于 Sub2API 搭建的官方中转服务,提供 Claude Code、Codex、Gemini 等主流模型的稳定中转,开箱即用,免去自建部署与运维烦恼。</td>
</tr>
</table>
## 生态项目
围绕 Sub2API 的社区扩展与集成项目:
@@ -61,17 +73,18 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
---
## 文档
## Nginx 反向代理注意事项
- 依赖安全:`docs/dependency-security.md`
通过 Nginx 反向代理 Sub2API(或 CRS 服务)并搭配 Codex CLI 使用时,需要在 Nginx 配置的 `http` 块中添加:
```nginx
underscores_in_headers on;
```
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
---
## OpenAI Responses 兼容注意事项
- 当请求包含 `function_call_output` 时,需要携带 `previous_response_id`,或在 `input` 中包含带 `call_id` 的 `tool_call`/`function_call`,或带非空 `id` 且与 `function_call_output.call_id` 匹配的 `item_reference`。
- 若依赖上游历史记录,网关会强制 `store=true` 并需要复用 `previous_response_id`,以避免出现 “No tool call found for function call output” 错误。
## 部署方式
### 方式一:脚本安装(推荐)
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

@@ -512,6 +512,8 @@ func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
payload := gin.H{
"ranking": ranking.Ranking,
"total_actual_cost": ranking.TotalActualCost,
"total_requests": ranking.TotalRequests,
"total_tokens": ranking.TotalTokens,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
}
@@ -61,6 +61,8 @@ func (s *dashboardUsageRepoCapture) GetUserSpendingRanking(
return &usagestats.UserSpendingRankingResponse{
Ranking: s.ranking,
TotalActualCost: s.rankingTotal,
TotalRequests: 44,
TotalTokens: 1234,
}, nil
}
@@ -164,6 +166,8 @@ func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 50, repo.rankingLimit)
require.Contains(t, rec.Body.String(), "\"total_actual_cost\":88.8")
require.Contains(t, rec.Body.String(), "\"total_requests\":44")
require.Contains(t, rec.Body.String(), "\"total_tokens\":1234")
require.Equal(t, "miss", rec.Header().Get("X-Snapshot-Cache"))
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil)
@@ -159,8 +159,8 @@ func (h *UsageHandler) List(c *gin.Context) {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// Set end time to end of day
t = t.Add(24*time.Hour - time.Nanosecond)
// Use half-open range [start, end), move to next calendar day start (DST-safe).
t = t.AddDate(0, 0, 1)
endTime = &t
}
@@ -285,7 +285,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
endTime = endTime.Add(24*time.Hour - time.Nanosecond)
// 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
endTime = endTime.AddDate(0, 0, 1)
} else {
period := c.DefaultQuery("period", "today")
switch period {
+174
View File
@@ -0,0 +1,174 @@
package handler
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ──────────────────────────────────────────────────────────
// Canonical inbound / upstream endpoint paths.
// All normalization and derivation reference this single set
// of constants — add new paths HERE when a new API surface
// is introduced.
// ──────────────────────────────────────────────────────────
const (
EndpointMessages = "/v1/messages"
EndpointChatCompletions = "/v1/chat/completions"
EndpointResponses = "/v1/responses"
EndpointGeminiModels = "/v1beta/models"
)
// gin.Context keys used by the middleware and helpers below.
const (
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
)
// ──────────────────────────────────────────────────────────
// Normalization functions
// ──────────────────────────────────────────────────────────
// NormalizeInboundEndpoint maps a raw request path (which may carry
// prefixes like /antigravity, /openai, /sora) to its canonical form.
//
// "/antigravity/v1/messages" → "/v1/messages"
// "/v1/chat/completions" → "/v1/chat/completions"
// "/openai/v1/responses/foo" → "/v1/responses"
// "/v1beta/models/gemini:gen" → "/v1beta/models"
func NormalizeInboundEndpoint(path string) string {
path = strings.TrimSpace(path)
switch {
case strings.Contains(path, EndpointChatCompletions):
return EndpointChatCompletions
case strings.Contains(path, EndpointMessages):
return EndpointMessages
case strings.Contains(path, EndpointResponses):
return EndpointResponses
case strings.Contains(path, EndpointGeminiModels):
return EndpointGeminiModels
default:
return path
}
}
// DeriveUpstreamEndpoint determines the upstream endpoint from the
// account platform and the normalized inbound endpoint.
//
// Platform-specific rules:
// - OpenAI always forwards to /v1/responses (with optional subpath
// such as /v1/responses/compact preserved from the raw URL).
// - Anthropic → /v1/messages
// - Gemini → /v1beta/models
// - Sora → /v1/chat/completions
// - Antigravity routes may target either Claude or Gemini, so the
// inbound endpoint is used to distinguish.
func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
inbound = strings.TrimSpace(inbound)
switch platform {
case service.PlatformOpenAI:
// OpenAI forwards everything to the Responses API.
// Preserve subresource suffix (e.g. /v1/responses/compact).
if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
return EndpointResponses + suffix
}
return EndpointResponses
case service.PlatformAnthropic:
return EndpointMessages
case service.PlatformGemini:
return EndpointGeminiModels
case service.PlatformSora:
return EndpointChatCompletions
case service.PlatformAntigravity:
// Antigravity accounts serve both Claude and Gemini.
if inbound == EndpointGeminiModels {
return EndpointGeminiModels
}
return EndpointMessages
}
// Unknown platform — fall back to inbound.
return inbound
}
// responsesSubpathSuffix extracts the part after "/responses" in a raw
// request path, e.g. "/openai/v1/responses/compact" → "/compact".
// Returns "" when there is no meaningful suffix.
func responsesSubpathSuffix(rawPath string) string {
trimmed := strings.TrimRight(strings.TrimSpace(rawPath), "/")
idx := strings.LastIndex(trimmed, "/responses")
if idx < 0 {
return ""
}
suffix := trimmed[idx+len("/responses"):]
if suffix == "" || suffix == "/" {
return ""
}
if !strings.HasPrefix(suffix, "/") {
return ""
}
return suffix
}
// ──────────────────────────────────────────────────────────
// Middleware
// ──────────────────────────────────────────────────────────
// InboundEndpointMiddleware normalizes the request path and stores the
// canonical inbound endpoint in gin.Context so that every handler in
// the chain can read it via GetInboundEndpoint.
//
// Apply this middleware to all gateway route groups.
func InboundEndpointMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.FullPath()
if path == "" && c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.Path
}
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
c.Next()
}
}
// ──────────────────────────────────────────────────────────
// Context helpers — used by handlers before building
// RecordUsageInput / RecordUsageLongContextInput.
// ──────────────────────────────────────────────────────────
// GetInboundEndpoint returns the canonical inbound endpoint stored by
// InboundEndpointMiddleware. If the middleware did not run (e.g. in
// tests), it falls back to normalizing c.FullPath() on the fly.
func GetInboundEndpoint(c *gin.Context) string {
if v, ok := c.Get(ctxKeyInboundEndpoint); ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
// Fallback: normalize on the fly.
path := ""
if c != nil {
path = c.FullPath()
if path == "" && c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.Path
}
}
return NormalizeInboundEndpoint(path)
}
// GetUpstreamEndpoint derives the upstream endpoint from the context
// and the account platform. Handlers call this after scheduling an
// account, passing account.Platform.
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
inbound := GetInboundEndpoint(c)
rawPath := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
rawPath = c.Request.URL.Path
}
return DeriveUpstreamEndpoint(inbound, rawPath, platform)
}
+159
View File
@@ -0,0 +1,159 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func init() { gin.SetMode(gin.TestMode) }
// ──────────────────────────────────────────────────────────
// NormalizeInboundEndpoint
// ──────────────────────────────────────────────────────────
func TestNormalizeInboundEndpoint(t *testing.T) {
tests := []struct {
path string
want string
}{
// Direct canonical paths.
{"/v1/messages", EndpointMessages},
{"/v1/chat/completions", EndpointChatCompletions},
{"/v1/responses", EndpointResponses},
{"/v1beta/models", EndpointGeminiModels},
// Prefixed paths (antigravity, openai, sora).
{"/antigravity/v1/messages", EndpointMessages},
{"/openai/v1/responses", EndpointResponses},
{"/openai/v1/responses/compact", EndpointResponses},
{"/sora/v1/chat/completions", EndpointChatCompletions},
{"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels},
// Gin route patterns with wildcards.
{"/v1beta/models/*modelAction", EndpointGeminiModels},
{"/v1/responses/*subpath", EndpointResponses},
// Unknown path is returned as-is.
{"/v1/embeddings", "/v1/embeddings"},
{"", ""},
{" /v1/messages ", EndpointMessages},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
require.Equal(t, tt.want, NormalizeInboundEndpoint(tt.path))
})
}
}
// ──────────────────────────────────────────────────────────
// DeriveUpstreamEndpoint
// ──────────────────────────────────────────────────────────
func TestDeriveUpstreamEndpoint(t *testing.T) {
tests := []struct {
name string
inbound string
rawPath string
platform string
want string
}{
// Anthropic.
{"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages},
// Gemini.
{"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
// Sora.
{"sora completions", EndpointChatCompletions, "/sora/v1/chat/completions", service.PlatformSora, EndpointChatCompletions},
// OpenAI — always /v1/responses.
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
{"openai responses compact", EndpointResponses, "/openai/v1/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
{"openai responses nested", EndpointResponses, "/openai/v1/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
{"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
{"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
{"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
{"antigravity gemini", EndpointGeminiModels, "/antigravity/v1beta/models", service.PlatformAntigravity, EndpointGeminiModels},
// Unknown platform — passthrough.
{"unknown platform", "/v1/embeddings", "/v1/embeddings", "unknown", "/v1/embeddings"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, DeriveUpstreamEndpoint(tt.inbound, tt.rawPath, tt.platform))
})
}
}
// ──────────────────────────────────────────────────────────
// responsesSubpathSuffix
// ──────────────────────────────────────────────────────────
func TestResponsesSubpathSuffix(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"/v1/responses", ""},
{"/v1/responses/", ""},
{"/v1/responses/compact", "/compact"},
{"/openai/v1/responses/compact/detail", "/compact/detail"},
{"/v1/messages", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
require.Equal(t, tt.want, responsesSubpathSuffix(tt.raw))
})
}
}
// ──────────────────────────────────────────────────────────
// InboundEndpointMiddleware + context helpers
// ──────────────────────────────────────────────────────────
func TestInboundEndpointMiddleware(t *testing.T) {
router := gin.New()
router.Use(InboundEndpointMiddleware())
var captured string
router.POST("/v1/messages", func(c *gin.Context) {
captured = GetInboundEndpoint(c)
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, EndpointMessages, captured)
}
func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1/messages", nil)
// Middleware did not run — fallback to normalizing c.Request.URL.Path.
got := GetInboundEndpoint(c)
require.Equal(t, EndpointMessages, got)
}
func TestGetUpstreamEndpoint_FullFlow(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
// Simulate middleware.
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(c.Request.URL.Path))
got := GetUpstreamEndpoint(c, service.PlatformOpenAI)
require.Equal(t, "/v1/responses/compact", got)
}
+9 -1
View File
@@ -442,6 +442,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
@@ -456,6 +458,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
@@ -759,6 +763,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
@@ -773,6 +779,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
User: currentAPIKey.User,
Account: account,
Subscription: currentSubscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
@@ -938,7 +946,7 @@ func (h *GatewayHandler) parseUsageDateRange(c *gin.Context) (time.Time, time.Ti
}
if s := c.Query("end_date"); s != "" {
if t, err := timezone.ParseInLocation("2006-01-02", s); err == nil {
endTime = t.Add(24*time.Hour - time.Second) // end of day
endTime = t.AddDate(0, 0, 1) // half-open range upper bound
}
}
return startTime, endTime
@@ -504,6 +504,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
h.submitUsageRecordTask(func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
@@ -511,6 +513,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
@@ -261,8 +261,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointChatCompletions),
UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses),
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
@@ -5,42 +5,41 @@ import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestNormalizedOpenAIUpstreamEndpoint(t *testing.T) {
// TestOpenAIUpstreamEndpoint_ViaGetUpstreamEndpoint verifies that the
// unified GetUpstreamEndpoint helper produces the same results as the
// former normalizedOpenAIUpstreamEndpoint for OpenAI platform requests.
func TestOpenAIUpstreamEndpoint_ViaGetUpstreamEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
path string
fallback string
want string
name string
path string
want string
}{
{
name: "responses root maps to responses upstream",
path: "/v1/responses",
fallback: openAIUpstreamEndpointResponses,
want: "/v1/responses",
name: "responses root maps to responses upstream",
path: "/v1/responses",
want: EndpointResponses,
},
{
name: "responses compact keeps compact suffix",
path: "/openai/v1/responses/compact",
fallback: openAIUpstreamEndpointResponses,
want: "/v1/responses/compact",
name: "responses compact keeps compact suffix",
path: "/openai/v1/responses/compact",
want: "/v1/responses/compact",
},
{
name: "responses nested suffix preserved",
path: "/openai/v1/responses/compact/detail",
fallback: openAIUpstreamEndpointResponses,
want: "/v1/responses/compact/detail",
name: "responses nested suffix preserved",
path: "/openai/v1/responses/compact/detail",
want: "/v1/responses/compact/detail",
},
{
name: "non responses path uses fallback",
path: "/v1/messages",
fallback: openAIUpstreamEndpointResponses,
want: "/v1/responses",
name: "non responses path uses platform fallback",
path: "/v1/messages",
want: EndpointResponses,
},
}
@@ -50,7 +49,7 @@ func TestNormalizedOpenAIUpstreamEndpoint(t *testing.T) {
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil)
got := normalizedOpenAIUpstreamEndpoint(c, tt.fallback)
got := GetUpstreamEndpoint(c, service.PlatformOpenAI)
require.Equal(t, tt.want, got)
})
}
@@ -37,13 +37,6 @@ type OpenAIGatewayHandler struct {
cfg *config.Config
}
const (
openAIInboundEndpointResponses = "/v1/responses"
openAIInboundEndpointMessages = "/v1/messages"
openAIInboundEndpointChatCompletions = "/v1/chat/completions"
openAIUpstreamEndpointResponses = "/v1/responses"
)
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
@@ -369,8 +362,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointResponses),
UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses),
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
@@ -747,8 +740,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointMessages),
UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses),
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
@@ -1246,8 +1239,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointResponses),
UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses),
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: service.HashUsageRequestPayload(firstMessage),
@@ -1543,62 +1536,6 @@ func openAIWSIngressFallbackSessionSeed(userID, apiKeyID int64, groupID *int64)
return fmt.Sprintf("openai_ws_ingress:%d:%d:%d", gid, userID, apiKeyID)
}
func normalizedOpenAIInboundEndpoint(c *gin.Context, fallback string) string {
path := strings.TrimSpace(fallback)
if c != nil {
if fullPath := strings.TrimSpace(c.FullPath()); fullPath != "" {
path = fullPath
} else if c.Request != nil && c.Request.URL != nil {
if requestPath := strings.TrimSpace(c.Request.URL.Path); requestPath != "" {
path = requestPath
}
}
}
switch {
case strings.Contains(path, openAIInboundEndpointChatCompletions):
return openAIInboundEndpointChatCompletions
case strings.Contains(path, openAIInboundEndpointMessages):
return openAIInboundEndpointMessages
case strings.Contains(path, openAIInboundEndpointResponses):
return openAIInboundEndpointResponses
default:
return path
}
}
func normalizedOpenAIUpstreamEndpoint(c *gin.Context, fallback string) string {
base := strings.TrimSpace(fallback)
if base == "" {
base = openAIUpstreamEndpointResponses
}
base = strings.TrimRight(base, "/")
if c == nil || c.Request == nil || c.Request.URL == nil {
return base
}
path := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/")
if path == "" {
return base
}
idx := strings.LastIndex(path, "/responses")
if idx < 0 {
return base
}
suffix := strings.TrimSpace(path[idx+len("/responses"):])
if suffix == "" || suffix == "/" {
return base
}
if !strings.HasPrefix(suffix, "/") {
return base
}
return base + suffix
}
func isOpenAIWSUpgradeRequest(r *http.Request) bool {
if r == nil {
return false
@@ -400,6 +400,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
h.submitUsageRecordTask(func(ctx context.Context) {
@@ -409,6 +411,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
+4 -4
View File
@@ -114,8 +114,8 @@ func (h *UsageHandler) List(c *gin.Context) {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// Set end time to end of day
t = t.Add(24*time.Hour - time.Nanosecond)
// Use half-open range [start, end), move to next calendar day start (DST-safe).
t = t.AddDate(0, 0, 1)
endTime = &t
}
@@ -227,8 +227,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// 设置结束时间为当天结束
endTime = endTime.Add(24*time.Hour - time.Nanosecond)
// 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
endTime = endTime.AddDate(0, 0, 1)
} else {
// 使用 period 参数
period := c.DefaultQuery("period", "today")
+2 -2
View File
@@ -49,8 +49,8 @@ const (
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
)
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.4
var defaultUserAgentVersion = "1.20.4"
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
var defaultUserAgentVersion = "1.20.5"
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
var defaultClientSecret = "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"
@@ -690,7 +690,7 @@ func TestConstants_值正确(t *testing.T) {
if RedirectURI != "http://localhost:8085/callback" {
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
}
if GetUserAgent() != "antigravity/1.20.4 windows/amd64" {
if GetUserAgent() != "antigravity/1.20.5 windows/amd64" {
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
}
if SessionTTL != 30*time.Minute {
@@ -125,6 +125,8 @@ type UserSpendingRankingItem struct {
type UserSpendingRankingResponse struct {
Ranking []UserSpendingRankingItem `json:"ranking"`
TotalActualCost float64 `json:"total_actual_cost"`
TotalRequests int64 `json:"total_requests"`
TotalTokens int64 `json:"total_tokens"`
}
// APIKeyUsageTrendPoint represents API key usage trend data point
+42 -6
View File
@@ -20,6 +20,11 @@ const (
billingCacheTTL = 5 * time.Minute
billingCacheJitter = 30 * time.Second
rateLimitCacheTTL = 7 * 24 * time.Hour // 7 days matches the longest window
// Rate limit window durations — must match service.RateLimitWindow* constants.
rateLimitWindow5h = 5 * time.Hour
rateLimitWindow1d = 24 * time.Hour
rateLimitWindow7d = 7 * 24 * time.Hour
)
// jitteredTTL 返回带随机抖动的 TTL,防止缓存雪崩
@@ -90,17 +95,40 @@ var (
return 1
`)
// updateRateLimitUsageScript atomically increments all three rate limit usage counters.
// Returns 0 if the key doesn't exist (cache miss), 1 on success.
// updateRateLimitUsageScript atomically increments all three rate limit usage counters
// with window expiration checking. If a window has expired, its usage is reset to cost
// (instead of accumulated) and the window timestamp is updated, matching the DB-side
// IncrementRateLimitUsage semantics.
//
// ARGV: [1]=cost, [2]=ttl_seconds, [3]=now_unix, [4]=window_5h_seconds, [5]=window_1d_seconds, [6]=window_7d_seconds
updateRateLimitUsageScript = redis.NewScript(`
local exists = redis.call('EXISTS', KEYS[1])
if exists == 0 then
return 0
end
local cost = tonumber(ARGV[1])
redis.call('HINCRBYFLOAT', KEYS[1], 'usage_5h', cost)
redis.call('HINCRBYFLOAT', KEYS[1], 'usage_1d', cost)
redis.call('HINCRBYFLOAT', KEYS[1], 'usage_7d', cost)
local now = tonumber(ARGV[3])
local win5h = tonumber(ARGV[4])
local win1d = tonumber(ARGV[5])
local win7d = tonumber(ARGV[6])
-- Helper: check if window is expired and update usage + window accordingly
-- Returns nothing, modifies the hash in-place.
local function update_window(usage_field, window_field, window_duration)
local w = tonumber(redis.call('HGET', KEYS[1], window_field) or 0)
if w == 0 or (now - w) >= window_duration then
-- Window expired or never started: reset usage to cost, start new window
redis.call('HSET', KEYS[1], usage_field, tostring(cost))
redis.call('HSET', KEYS[1], window_field, tostring(now))
else
-- Window still valid: accumulate
redis.call('HINCRBYFLOAT', KEYS[1], usage_field, cost)
end
end
update_window('usage_5h', 'window_5h', win5h)
update_window('usage_1d', 'window_1d', win1d)
update_window('usage_7d', 'window_7d', win7d)
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
`)
@@ -280,7 +308,15 @@ func (c *billingCache) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data
func (c *billingCache) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error {
key := billingRateLimitKey(keyID)
_, err := updateRateLimitUsageScript.Run(ctx, c.rdb, []string{key}, cost, int(rateLimitCacheTTL.Seconds())).Result()
now := time.Now().Unix()
_, err := updateRateLimitUsageScript.Run(ctx, c.rdb, []string{key},
cost,
int(rateLimitCacheTTL.Seconds()),
now,
int(rateLimitWindow5h.Seconds()),
int(rateLimitWindow1d.Seconds()),
int(rateLimitWindow7d.Seconds()),
).Result()
if err != nil && !errors.Is(err, redis.Nil) {
log.Printf("Warning: update rate limit usage cache failed for api key %d: %v", keyID, err)
return err
+12 -4
View File
@@ -2161,7 +2161,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
actual_cost,
requests,
tokens,
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost,
COALESCE(SUM(requests) OVER (), 0) as total_requests,
COALESCE(SUM(tokens) OVER (), 0) as total_tokens
FROM user_spend
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
LIMIT $3
@@ -2172,7 +2174,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
actual_cost,
requests,
tokens,
total_actual_cost
total_actual_cost,
total_requests,
total_tokens
FROM ranked
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
`
@@ -2190,9 +2194,11 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
ranking := make([]UserSpendingRankingItem, 0)
totalActualCost := 0.0
totalRequests := int64(0)
totalTokens := int64(0)
for rows.Next() {
var row UserSpendingRankingItem
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost); err != nil {
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
return nil, err
}
ranking = append(ranking, row)
@@ -2204,6 +2210,8 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
return &UserSpendingRankingResponse{
Ranking: ranking,
TotalActualCost: totalActualCost,
TotalRequests: totalRequests,
TotalTokens: totalTokens,
}, nil
}
@@ -3004,7 +3012,7 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
FROM usage_logs
WHERE created_at >= $1 AND created_at <= $2
WHERE created_at >= $1 AND created_at < $2
`
stats := &UsageStats{}
@@ -259,10 +259,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost"}).
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0).
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0).
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0)
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
mock.ExpectQuery("WITH user_spend AS \\(").
WithArgs(start, end, 12).
@@ -277,6 +277,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
{UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300},
},
TotalActualCost: 40.0,
TotalRequests: 30,
TotalTokens: 2600,
}, got)
require.NoError(t, mock.ExpectationsWereMet())
}
+10 -4
View File
@@ -30,6 +30,7 @@ func RegisterGatewayRoutes(
soraBodyLimit := middleware.RequestBodyLimit(soraMaxBodySize)
clientRequestID := middleware.ClientRequestID()
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
endpointNorm := handler.InboundEndpointMiddleware()
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
@@ -40,6 +41,7 @@ func RegisterGatewayRoutes(
gateway.Use(bodyLimit)
gateway.Use(clientRequestID)
gateway.Use(opsErrorLogger)
gateway.Use(endpointNorm)
gateway.Use(gin.HandlerFunc(apiKeyAuth))
gateway.Use(requireGroupAnthropic)
{
@@ -80,6 +82,7 @@ func RegisterGatewayRoutes(
gemini.Use(bodyLimit)
gemini.Use(clientRequestID)
gemini.Use(opsErrorLogger)
gemini.Use(endpointNorm)
gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
gemini.Use(requireGroupGoogle)
{
@@ -90,11 +93,11 @@ func RegisterGatewayRoutes(
}
// OpenAI Responses API(不带v1前缀的别名)
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses)
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses)
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
// OpenAI Chat Completions API(不带v1前缀的别名)
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ChatCompletions)
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ChatCompletions)
// Antigravity 模型列表
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
@@ -104,6 +107,7 @@ func RegisterGatewayRoutes(
antigravityV1.Use(bodyLimit)
antigravityV1.Use(clientRequestID)
antigravityV1.Use(opsErrorLogger)
antigravityV1.Use(endpointNorm)
antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity))
antigravityV1.Use(gin.HandlerFunc(apiKeyAuth))
antigravityV1.Use(requireGroupAnthropic)
@@ -118,6 +122,7 @@ func RegisterGatewayRoutes(
antigravityV1Beta.Use(bodyLimit)
antigravityV1Beta.Use(clientRequestID)
antigravityV1Beta.Use(opsErrorLogger)
antigravityV1Beta.Use(endpointNorm)
antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity))
antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
antigravityV1Beta.Use(requireGroupGoogle)
@@ -132,6 +137,7 @@ func RegisterGatewayRoutes(
soraV1.Use(soraBodyLimit)
soraV1.Use(clientRequestID)
soraV1.Use(opsErrorLogger)
soraV1.Use(endpointNorm)
soraV1.Use(middleware.ForcePlatform(service.PlatformSora))
soraV1.Use(gin.HandlerFunc(apiKeyAuth))
soraV1.Use(requireGroupAnthropic)
@@ -446,23 +446,17 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
}
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil {
windowStats := windowStatsFromAccountStats(stats)
if hasMeaningfulWindowStats(windowStats) {
if usage.FiveHour == nil {
usage.FiveHour = &UsageProgress{Utilization: 0}
}
usage.FiveHour.WindowStats = windowStats
if usage.FiveHour == nil {
usage.FiveHour = &UsageProgress{Utilization: 0}
}
usage.FiveHour.WindowStats = windowStatsFromAccountStats(stats)
}
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil {
windowStats := windowStatsFromAccountStats(stats)
if hasMeaningfulWindowStats(windowStats) {
if usage.SevenDay == nil {
usage.SevenDay = &UsageProgress{Utilization: 0}
}
usage.SevenDay.WindowStats = windowStats
if usage.SevenDay == nil {
usage.SevenDay = &UsageProgress{Utilization: 0}
}
usage.SevenDay.WindowStats = windowStatsFromAccountStats(stats)
}
return usage, nil
@@ -992,13 +986,6 @@ func windowStatsFromAccountStats(stats *usagestats.AccountStats) *WindowStats {
}
}
func hasMeaningfulWindowStats(stats *WindowStats) bool {
if stats == nil {
return false
}
return stats.Requests > 0 || stats.Tokens > 0 || stats.Cost > 0 || stats.StandardCost > 0 || stats.UserCost > 0
}
func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now time.Time) *UsageProgress {
if len(extra) == 0 {
return nil
+3 -1
View File
@@ -1549,7 +1549,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if len(input.Credentials) > 0 {
account.Credentials = input.Credentials
}
if len(input.Extra) > 0 {
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
if input.Extra != nil {
// 保留配额用量字段,防止编辑账号时意外重置
for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} {
if v, ok := account.Extra[key]; ok {
@@ -121,3 +121,35 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi
_, exists := repo.account.Extra[modelRateLimitsKey]
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
}
func TestUpdateAccount_EmptyExtraPayloadCanClearQuotaLimits(t *testing.T) {
accountID := int64(103)
repo := &updateAccountOveragesRepoStub{
account: &Account{
ID: accountID,
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Status: StatusActive,
Extra: map[string]any{
"quota_limit": 100.0,
"quota_daily_limit": 10.0,
"quota_weekly_limit": 40.0,
},
},
}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
// 显式空对象:语义是“清空 extra 中的可配置键”(例如关闭配额限制)
Extra: map[string]any{},
})
require.NoError(t, err)
require.NotNil(t, updated)
require.Equal(t, 1, repo.updateCalls)
require.NotNil(t, repo.account.Extra)
require.NotContains(t, repo.account.Extra, "quota_limit")
require.NotContains(t, repo.account.Extra, "quota_daily_limit")
require.NotContains(t, repo.account.Extra, "quota_weekly_limit")
require.Len(t, repo.account.Extra, 0)
}
@@ -7405,6 +7405,8 @@ type RecordUsageInput struct {
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
InboundEndpoint string // 入站端点(客户端请求路径)
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
@@ -7813,6 +7815,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
RequestID: requestID,
Model: result.Model,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
@@ -7893,6 +7897,8 @@ type RecordUsageLongContextInput struct {
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
InboundEndpoint string // 入站端点(客户端请求路径)
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
@@ -7990,6 +7996,8 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
RequestID: requestID,
Model: result.Model,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
@@ -107,10 +107,11 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return nil, fmt.Errorf("build upstream request: %w", err)
}
// Override session_id with a deterministic UUID derived from the sticky
// session key (buildUpstreamRequest may have set it to the raw value).
// Override session_id with a deterministic UUID derived from the isolated
// session key, ensuring different API keys produce different upstream sessions.
if promptCacheKey != "" {
upstreamReq.Header.Set("session_id", generateSessionUUID(promptCacheKey))
apiKeyID := getAPIKeyIDFromContext(c)
upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)))
}
// 7. Send request
@@ -24,6 +24,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/cespare/xxhash/v2"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
@@ -787,6 +788,20 @@ func getAPIKeyIDFromContext(c *gin.Context) int64 {
return apiKey.ID
}
// isolateOpenAISessionID 将 apiKeyID 混入 session 标识符,
// 确保不同 API Key 的用户即使使用相同的原始 session_id/conversation_id,
// 到达上游的标识符也不同,防止跨用户会话碰撞。
func isolateOpenAISessionID(apiKeyID int64, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
h := xxhash.New()
_, _ = fmt.Fprintf(h, "k%d:", apiKeyID)
_, _ = h.WriteString(raw)
return fmt.Sprintf("%016x", h.Sum64())
}
func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Account, apiKeyID int64, result CodexClientRestrictionDetectionResult, body []byte) {
if !result.Enabled {
return
@@ -2501,13 +2516,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" {
req.Header.Set("chatgpt-account-id", chatgptAccountID)
}
apiKeyID := getAPIKeyIDFromContext(c)
// 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。
clientSessionID := strings.TrimSpace(req.Header.Get("session_id"))
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
if isOpenAIResponsesCompactPath(c) {
req.Header.Set("accept", "application/json")
if req.Header.Get("version") == "" {
req.Header.Set("version", codexCLIVersion)
}
if req.Header.Get("session_id") == "" {
req.Header.Set("session_id", resolveOpenAICompactSessionID(c))
if clientSessionID == "" {
clientSessionID = resolveOpenAICompactSessionID(c)
}
} else if req.Header.Get("accept") == "" {
req.Header.Set("accept", "text/event-stream")
@@ -2518,13 +2537,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
if req.Header.Get("originator") == "" {
req.Header.Set("originator", "codex_cli_rs")
}
if promptCacheKey != "" {
if req.Header.Get("conversation_id") == "" {
req.Header.Set("conversation_id", promptCacheKey)
}
if req.Header.Get("session_id") == "" {
req.Header.Set("session_id", promptCacheKey)
}
// 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
if clientSessionID == "" {
clientSessionID = promptCacheKey
}
if clientConversationID == "" {
clientConversationID = promptCacheKey
}
if clientSessionID != "" {
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID))
}
if clientConversationID != "" {
req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID))
}
}
@@ -2887,22 +2911,27 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
}
if account.Type == AccountTypeOAuth {
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
req.Header.Del("conversation_id")
req.Header.Del("session_id")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
apiKeyID := getAPIKeyIDFromContext(c)
if isOpenAIResponsesCompactPath(c) {
req.Header.Set("accept", "application/json")
if req.Header.Get("version") == "" {
req.Header.Set("version", codexCLIVersion)
}
if req.Header.Get("session_id") == "" {
req.Header.Set("session_id", resolveOpenAICompactSessionID(c))
}
compactSession := resolveOpenAICompactSessionID(c)
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession))
} else {
req.Header.Set("accept", "text/event-stream")
}
if promptCacheKey != "" {
req.Header.Set("conversation_id", promptCacheKey)
req.Header.Set("session_id", promptCacheKey)
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
req.Header.Set("conversation_id", isolated)
req.Header.Set("session_id", isolated)
}
}
@@ -0,0 +1,50 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsolateOpenAISessionID(t *testing.T) {
t.Run("empty_raw_returns_empty", func(t *testing.T) {
assert.Equal(t, "", isolateOpenAISessionID(1, ""))
assert.Equal(t, "", isolateOpenAISessionID(1, " "))
})
t.Run("deterministic", func(t *testing.T) {
a := isolateOpenAISessionID(42, "sess_abc123")
b := isolateOpenAISessionID(42, "sess_abc123")
assert.Equal(t, a, b)
})
t.Run("different_apiKeyID_different_result", func(t *testing.T) {
a := isolateOpenAISessionID(1, "same_session")
b := isolateOpenAISessionID(2, "same_session")
require.NotEqual(t, a, b, "不同 API Key 使用相同 session_id 应产生不同隔离值")
})
t.Run("different_raw_different_result", func(t *testing.T) {
a := isolateOpenAISessionID(1, "session_a")
b := isolateOpenAISessionID(1, "session_b")
require.NotEqual(t, a, b)
})
t.Run("format_is_16_hex_chars", func(t *testing.T) {
result := isolateOpenAISessionID(99, "test_session")
assert.Len(t, result, 16, "应为 16 字符的 hex 字符串")
for _, ch := range result {
assert.True(t, (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'),
"应仅包含 hex 字符: %c", ch)
}
})
t.Run("zero_apiKeyID_still_works", func(t *testing.T) {
result := isolateOpenAISessionID(0, "session")
assert.NotEmpty(t, result)
// apiKeyID=0 与 apiKeyID=1 应产生不同结果
other := isolateOpenAISessionID(1, "session")
assert.NotEqual(t, result, other)
})
}
@@ -1124,11 +1124,22 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
headers.Set("accept-language", v)
}
}
if sessionResolution.SessionID != "" {
headers.Set("session_id", sessionResolution.SessionID)
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", sessionResolution.ConversationID)
// OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。
if account != nil && account.Type == AccountTypeOAuth {
apiKeyID := getAPIKeyIDFromContext(c)
if sessionResolution.SessionID != "" {
headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID))
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID))
}
} else {
if sessionResolution.SessionID != "" {
headers.Set("session_id", sessionResolution.SessionID)
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", sessionResolution.ConversationID)
}
}
if state := strings.TrimSpace(turnState); state != "" {
headers.Set(openAIWSTurnStateHeader, state)
@@ -1859,7 +1870,16 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
return nil, wrapOpenAIWSFallback(classifyOpenAIWSAcquireError(err), err)
}
defer lease.Release()
// cleanExit 标记正常终端事件退出,此时上游不会再发送帧,连接可安全归还复用。
// 所有异常路径(读写错误、error 事件等)已在各自分支中提前调用 MarkBroken,
// 因此 defer 中只需处理正常退出时不 MarkBroken 即可。
cleanExit := false
defer func() {
if !cleanExit {
lease.MarkBroken()
}
lease.Release()
}()
connID := strings.TrimSpace(lease.ConnID())
logOpenAIWSModeDebug(
"connected account_id=%d account_type=%s transport=%s conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d has_previous_response_id=%v",
@@ -2237,6 +2257,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
if isTerminalEvent {
cleanExit = true
break
}
}
@@ -2972,12 +2993,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
pinnedSessionConnID = connID
}
}
// lastTurnClean 标记最后一轮 sendAndRelay 是否正常完成(收到终端事件且客户端未断连)。
// 所有异常路径(读写错误、error 事件、客户端断连)已在各自分支或上层(L3403)中 MarkBroken,
// 因此 releaseSessionLease 中只需在非正常结束时 MarkBroken。
lastTurnClean := false
releaseSessionLease := func() {
if sessionLease == nil {
return
}
if dedicatedMode {
// dedicated 会话结束后主动标记损坏,确保连接不会跨会话复用。
if !lastTurnClean {
sessionLease.MarkBroken()
}
unpinSessionConn(sessionConnID)
@@ -3372,6 +3396,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel)
if relayErr != nil {
lastTurnClean = false
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
continue
}
@@ -3391,6 +3416,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
turnRetry = 0
turnPrevRecoveryTried = false
lastTurnFinishedAt = time.Now()
lastTurnClean = true
if hooks != nil && hooks.AfterTurn != nil {
hooks.AfterTurn(turn, result, nil)
}
@@ -380,7 +380,8 @@ func TestOpenAIGatewayService_Forward_WSv2_PoolReuseNotOneToOne(t *testing.T) {
require.True(t, strings.HasPrefix(result.RequestID, "resp_reuse_"))
}
require.Equal(t, int64(1), upgradeCount.Load(), "多个客户端请求应复用账号连接池而不是 1:1 对等建链")
// 条件式 MarkBroken:正常终端事件退出后连接归还复用,不再无条件销毁。
require.Equal(t, int64(1), upgradeCount.Load(), "正常完成后连接应归还复用,不应每次新建")
metrics := svc.SnapshotOpenAIWSPoolMetrics()
require.GreaterOrEqual(t, metrics.AcquireReuseTotal, int64(1))
require.GreaterOrEqual(t, metrics.ConnPickTotal, int64(1))
@@ -454,8 +455,10 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
require.True(t, gjson.Get(requestJSON, "stream").Exists(), "WSv2 payload 应保留 stream 字段")
require.True(t, gjson.Get(requestJSON, "stream").Bool(), "OAuth Codex 规范化后应强制 stream=true")
require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta"))
require.Equal(t, "sess-oauth-1", captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, "conv-oauth-1", captureDialer.lastHeaders.Get("conversation_id"))
// OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离,
// 测试中未设置 api_key 到 context,apiKeyID=0。
require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, isolateOpenAISessionID(0, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id"))
}
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
@@ -596,7 +599,8 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK
require.NotNil(t, result)
require.Equal(t, "resp_prompt_cache_key", result.RequestID)
require.Equal(t, "pcache_123", captureDialer.lastHeaders.Get("session_id"))
// OAuth 账号的 session_id 应被 isolateOpenAISessionID 隔离(apiKeyID=0,未在 context 设置)。
require.Equal(t, isolateOpenAISessionID(0, "pcache_123"), captureDialer.lastHeaders.Get("session_id"))
require.Empty(t, captureDialer.lastHeaders.Get("conversation_id"))
require.NotNil(t, captureConn.lastWrite)
require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists())
@@ -961,6 +965,10 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnMetadataInPayloadOnConnReuse(t *t
require.NotNil(t, result1)
require.Equal(t, "resp_meta_1", result1.RequestID)
require.Len(t, captureConn.writes, 1)
firstWrite := requestToJSONString(captureConn.writes[0])
require.Equal(t, "turn_meta_payload_1", gjson.Get(firstWrite, "client_metadata.x-codex-turn-metadata").String())
rec2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(rec2)
c2.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
@@ -974,7 +982,7 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnMetadataInPayloadOnConnReuse(t *t
require.Equal(t, 1, captureDialer.DialCount(), "同一账号两轮请求应复用同一 WS 连接")
require.Len(t, captureConn.writes, 2)
firstWrite := requestToJSONString(captureConn.writes[0])
firstWrite = requestToJSONString(captureConn.writes[0])
secondWrite := requestToJSONString(captureConn.writes[1])
require.Equal(t, "turn_meta_payload_1", gjson.Get(firstWrite, "client_metadata.x-codex-turn-metadata").String())
require.Equal(t, "turn_meta_payload_2", gjson.Get(secondWrite, "client_metadata.x-codex-turn-metadata").String())
@@ -75,7 +75,7 @@
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows -->
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
<div v-if="preferFetchedOpenAIUsage" class="space-y-1">
<div v-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.five_hour"
label="5h"
@@ -136,24 +136,6 @@
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo?.seven_day"
label="7d"
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
color="emerald"
/>
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
@@ -389,8 +371,43 @@
<div v-else>
<!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<!-- API Key accounts with quota limits: show progress bars -->
<div v-else-if="hasApiKeyQuota" class="space-y-1">
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
<div v-else class="space-y-1">
<!-- Today stats row (requests, tokens, cost, user_cost) -->
<div
v-if="todayStats"
class="mb-0.5 flex items-center"
>
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatKeyRequests }} req
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatKeyTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatKeyCost }}
</span>
<span
v-if="todayStats.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
>
U ${{ formatKeyUserCost }}
</span>
</div>
</div>
<!-- Loading skeleton for today stats -->
<div
v-else-if="todayStatsLoading"
class="mb-0.5 flex items-center gap-1"
>
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- API Key accounts with quota limits: show progress bars -->
<UsageProgressBar
v-if="quotaDailyBar"
label="1d"
@@ -414,8 +431,10 @@
:display-value="quotaTotalBar.displayValue"
color="purple"
/>
<!-- No data at all -->
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
</div>
<div v-else class="text-xs text-gray-400">-</div>
</div>
</template>
@@ -427,12 +446,23 @@ import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
const props = defineProps<{
account: Account
}>()
const props = withDefaults(
defineProps<{
account: Account
todayStats?: WindowStats | null
todayStatsLoading?: boolean
manualRefreshToken?: number
}>(),
{
todayStats: null,
todayStatsLoading: false,
manualRefreshToken: 0
}
)
const { t } = useI18n()
@@ -497,26 +527,9 @@ const isActiveOpenAIRateLimited = computed(() => {
return !Number.isNaN(resetAt) && resetAt > Date.now()
})
const preferFetchedOpenAIUsage = computed(() => {
return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value
})
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
const isOpenAICodexSnapshotStale = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
const extra = props.account.extra as Record<string, unknown> | undefined
const updatedAtRaw = extra?.codex_usage_updated_at
if (!updatedAtRaw) return true
const updatedAt = Date.parse(String(updatedAtRaw))
if (Number.isNaN(updatedAt)) return true
return Date.now() - updatedAt >= 10 * 60 * 1000
})
const shouldAutoLoadUsageOnMount = computed(() => {
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value
}
return shouldFetchUsage.value
})
@@ -1021,6 +1034,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
return makeQuotaBar(props.account.quota_used ?? 0, limit)
})
// ===== Key account today stats formatters =====
const formatKeyRequests = computed(() => {
if (!props.todayStats) return ''
return formatCompactNumber(props.todayStats.requests, { allowBillions: false })
})
const formatKeyTokens = computed(() => {
if (!props.todayStats) return ''
return formatCompactNumber(props.todayStats.tokens)
})
const formatKeyCost = computed(() => {
if (!props.todayStats) return '0.00'
return props.todayStats.cost.toFixed(2)
})
const formatKeyUserCost = computed(() => {
if (!props.todayStats || props.todayStats.user_cost == null) return '0.00'
return props.todayStats.user_cost.toFixed(2)
})
onMounted(() => {
if (!shouldAutoLoadUsageOnMount.value) return
loadUsage()
@@ -1029,10 +1064,21 @@ onMounted(() => {
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return
loadUsage().catch((e) => {
console.error('Failed to refresh OpenAI usage:', e)
})
})
watch(
() => props.manualRefreshToken,
(nextToken, prevToken) => {
if (nextToken === prevToken) return
if (!shouldFetchUsage.value) return
loadUsage().catch((e) => {
console.error('Failed to refresh usage after manual refresh:', e)
})
}
)
</script>
@@ -2,7 +2,7 @@
<div>
<!-- Window stats row (above progress bar) -->
<div
v-if="windowStats"
v-if="windowStats && (windowStats.requests > 0 || windowStats.tokens > 0)"
class="mb-0.5 flex items-center"
>
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
@@ -12,12 +12,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatAccountCost }}
</span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
>
U ${{ formatUserCost }}
</span>
@@ -56,7 +57,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types'
import { formatCompactNumber } from '@/utils/format'
const props = defineProps<{
label: string
@@ -66,6 +69,8 @@ const props = defineProps<{
windowStats?: WindowStats | null
}>()
const { t } = useI18n()
// Label background colors
const labelClass = computed(() => {
const colors = {
@@ -135,19 +140,12 @@ const formatResetTime = computed(() => {
// Window stats formatters
const formatRequests = computed(() => {
if (!props.windowStats) return ''
const r = props.windowStats.requests
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
return r.toString()
return formatCompactNumber(props.windowStats.requests, { allowBillions: false })
})
const formatTokens = computed(() => {
if (!props.windowStats) return ''
const t = props.windowStats.tokens
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
return formatCompactNumber(props.windowStats.tokens)
})
const formatAccountCost = computed(() => {
@@ -202,7 +202,34 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|77|300')
})
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
it('OpenAI OAuth 有现成快照时首屏先显示快照再加载 usage 覆盖', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 18,
resets_at: '2099-03-07T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
},
seven_day: {
utilization: 36,
resets_at: '2099-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
@@ -222,7 +249,7 @@ describe('AccountUsageCell', () => {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
@@ -231,9 +258,80 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('5h|12')
expect(wrapper.text()).toContain('7d|34')
// 始终拉 usage,fetched data 优先显示(包含 window_stats)
expect(getUsage).toHaveBeenCalledWith(2001)
expect(wrapper.text()).toContain('5h|18|900')
expect(wrapper.text()).toContain('7d|36|900')
})
it('OpenAI OAuth 有现成快照时,手动刷新信号会触发 usage 重拉', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 18,
resets_at: '2099-03-07T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
},
seven_day: {
utilization: 36,
resets_at: '2099-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2010,
platform: 'openai',
type: 'oauth',
extra: {
codex_usage_updated_at: '2099-03-07T10:00:00Z',
codex_5h_used_percent: 12,
codex_5h_reset_at: '2099-03-07T12:00:00Z',
codex_7d_used_percent: 34,
codex_7d_reset_at: '2099-03-13T12:00:00Z'
},
rate_limit_reset_at: null
}),
manualRefreshToken: 0
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
// mount 时已经拉取一次
expect(getUsage).toHaveBeenCalledTimes(1)
await wrapper.setProps({ manualRefreshToken: 1 })
await flushPromises()
// 手动刷新再拉一次
expect(getUsage).toHaveBeenCalledTimes(2)
expect(getUsage).toHaveBeenCalledWith(2010)
// fetched data 优先显示,包含 window_stats
expect(wrapper.text()).toContain('5h|18|900')
})
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
@@ -418,9 +516,96 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2004)
expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000')
expect(wrapper.text()).not.toContain('5h|0|')
expect(getUsage).toHaveBeenCalledWith(2004)
expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000')
expect(wrapper.text()).not.toContain('5h|0|')
})
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3001,
platform: 'anthropic',
type: 'apikey'
}),
todayStats: {
requests: 1_000_000,
tokens: 1_000_000_000,
cost: 12.345,
standard_cost: 12.345,
user_cost: 6.789
}
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('1.0M req')
expect(wrapper.text()).toContain('1.0B')
expect(wrapper.text()).toContain('A $12.35')
expect(wrapper.text()).toContain('U $6.79')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3002,
platform: 'anthropic',
type: 'apikey'
}),
todayStats: null,
todayStatsLoading: true
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.findAll('.animate-pulse').length).toBeGreaterThan(0)
})
it('Key 账号在无 today stats 且无配额时显示兜底短横线', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3003,
platform: 'anthropic',
type: 'apikey',
quota_limit: 0,
quota_daily_limit: 0,
quota_weekly_limit: 0
}),
todayStats: null,
todayStatsLoading: false
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text().trim()).toBe('-')
})
})
@@ -127,7 +127,7 @@
>
{{ t('admin.dashboard.failedToLoad') }}
</div>
<div v-else-if="rankingItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
</div>
@@ -143,21 +143,24 @@
</thead>
<tbody>
<tr
v-for="(item, index) in rankingItems"
:key="`${item.user_id}-${index}`"
class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="emit('ranking-click', item)"
v-for="(item, index) in rankingDisplayItems"
:key="item.isOther ? 'others' : `${item.user_id}-${index}`"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="item.isOther
? 'bg-gray-50/70 dark:bg-dark-700/20'
: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40'"
@click="item.isOther ? undefined : emit('ranking-click', item)"
>
<td class="py-1.5">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 text-[11px] font-semibold text-gray-500 dark:text-gray-400">
#{{ index + 1 }}
{{ item.isOther ? 'Σ' : `#${index + 1}` }}
</span>
<span
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
:title="getRankingUserLabel(item)"
:title="getRankingRowLabel(item)"
>
{{ getRankingUserLabel(item) }}
{{ getRankingRowLabel(item) }}
</span>
</div>
</td>
@@ -197,11 +200,14 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost'
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
const props = withDefaults(defineProps<{
modelStats: ModelStat[]
enableRankingView?: boolean
rankingItems?: UserSpendingRankingItem[]
rankingTotalActualCost?: number
rankingTotalRequests?: number
rankingTotalTokens?: number
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
@@ -211,6 +217,8 @@ const props = withDefaults(defineProps<{
enableRankingView: false,
rankingItems: () => [],
rankingTotalActualCost: 0,
rankingTotalRequests: 0,
rankingTotalTokens: 0,
loading: false,
metric: 'tokens',
showMetricToggle: false,
@@ -266,14 +274,14 @@ const chartData = computed(() => {
const rankingChartData = computed(() => {
if (!props.rankingItems?.length) return null
const rankedTotal = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedTotal, 0)
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
const data = props.rankingItems.map((item) => item.actual_cost)
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
if (otherActualCost > 0.000001) {
if (otherRankingItem.value) {
labels.push(t('admin.dashboard.spendingRankingOther'))
data.push(otherActualCost)
data.push(otherRankingItem.value.actual_cost)
backgroundColor.push('#94a3b8')
}
return {
@@ -281,13 +289,43 @@ const rankingChartData = computed(() => {
datasets: [
{
data,
backgroundColor: chartColors.slice(0, data.length),
backgroundColor,
borderWidth: 0
}
]
}
})
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
if (!props.rankingItems?.length) return null
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
const otherTokens = Math.max((props.rankingTotalTokens || 0) - rankedTokens, 0)
if (otherActualCost <= 0.000001 && otherRequests <= 0 && otherTokens <= 0) return null
return {
user_id: 0,
email: '',
actual_cost: otherActualCost,
requests: otherRequests,
tokens: otherTokens,
isOther: true
}
})
const rankingDisplayItems = computed<RankingDisplayItem[]>(() => {
if (!props.rankingItems?.length) return []
return otherRankingItem.value
? [...props.rankingItems, otherRankingItem.value]
: [...props.rankingItems]
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -351,6 +389,11 @@ const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
return t('admin.redeem.userPrefix', { id: item.user_id })
}
const getRankingRowLabel = (item: RankingDisplayItem): string => {
if (item.isOther) return t('admin.dashboard.spendingRankingOther')
return getRankingUserLabel(item)
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
@@ -5,6 +5,14 @@ import ModelDistributionChart from '../ModelDistributionChart.vue'
const messages: Record<string, string> = {
'admin.dashboard.modelDistribution': 'Model Distribution',
'admin.dashboard.spendingRankingTitle': 'User Spending Ranking',
'admin.dashboard.viewModelDistribution': 'Model Distribution',
'admin.dashboard.viewSpendingRanking': 'User Spending Ranking',
'admin.dashboard.spendingRankingUser': 'User',
'admin.dashboard.spendingRankingRequests': 'Requests',
'admin.dashboard.spendingRankingTokens': 'Tokens',
'admin.dashboard.spendingRankingSpend': 'Spend',
'admin.dashboard.spendingRankingOther': 'Others',
'admin.dashboard.model': 'Model',
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
@@ -13,6 +21,7 @@ const messages: Record<string, string> = {
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
'admin.dashboard.noDataAvailable': 'No data available',
'admin.redeem.userPrefix': 'User #{id}',
}
vi.mock('vue-i18n', async () => {
@@ -116,4 +125,47 @@ describe('ModelDistributionChart', () => {
})
expect(label).toBe('model-b: $1.40 (87.5%)')
})
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats: [],
enableRankingView: true,
rankingItems: [
{ user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 },
{ user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 },
],
rankingTotalActualCost: 30,
rankingTotalRequests: 20,
rankingTotalTokens: 2000,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const rankingButton = wrapper.findAll('button').find((button) => button.text() === 'User Spending Ranking')
expect(rankingButton).toBeTruthy()
await rankingButton!.trigger('click')
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual([
'#1 alpha@example.com',
'#2 beta@example.com',
'Others',
])
expect(chartData.datasets[0].data).toEqual([12, 8, 10])
expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6')
expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8')
expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0])
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(3)
expect(rows[2].text()).toContain('Others')
expect(rows[2].text()).toContain('4')
expect(rows[2].text()).toContain('400')
expect(rows[2].text()).toContain('$10.00')
})
})
+2
View File
@@ -1225,6 +1225,8 @@ export interface UserSpendingRankingItem {
export interface UserSpendingRankingResponse {
ranking: UserSpendingRankingItem[]
total_actual_cost: number
total_requests: number
total_tokens: number
start_date: string
end_date: string
}
@@ -8,6 +8,7 @@ describe('buildOpenAIUsageRefreshKey', () => {
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
last_used_at: '2026-03-07T09:59:00Z',
extra: {
codex_usage_updated_at: '2026-03-07T10:00:00Z',
codex_5h_used_percent: 0,
@@ -27,12 +28,35 @@ describe('buildOpenAIUsageRefreshKey', () => {
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
})
it('会在 last_used_at 变化时生成不同 key', () => {
const base = {
id: 3,
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
last_used_at: '2026-03-07T10:00:00Z',
extra: {
codex_usage_updated_at: '2026-03-07T10:00:00Z',
codex_5h_used_percent: 12,
codex_7d_used_percent: 24
}
} as any
const next = {
...base,
last_used_at: '2026-03-07T10:02:00Z'
}
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
})
it('非 OpenAI OAuth 账号返回空 key', () => {
expect(buildOpenAIUsageRefreshKey({
id: 2,
platform: 'anthropic',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
last_used_at: '2026-03-07T10:00:00Z',
extra: {}
} as any)).toBe('')
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { formatCompactNumber } from '../format'
describe('formatCompactNumber', () => {
it('formats boundary values with K/M/B', () => {
expect(formatCompactNumber(0)).toBe('0')
expect(formatCompactNumber(999)).toBe('999')
expect(formatCompactNumber(1000)).toBe('1.0K')
expect(formatCompactNumber(999999)).toBe('1000.0K')
expect(formatCompactNumber(1000000)).toBe('1.0M')
expect(formatCompactNumber(1000000000)).toBe('1.0B')
})
it('supports disabling billion unit (requests style)', () => {
expect(formatCompactNumber(1000000000, { allowBillions: false })).toBe('1000.0M')
})
it('returns 0 for nullish input', () => {
expect(formatCompactNumber(null)).toBe('0')
expect(formatCompactNumber(undefined)).toBe('0')
})
})
+2 -1
View File
@@ -5,7 +5,7 @@ const normalizeUsageRefreshValue = (value: unknown): string => {
return String(value)
}
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'rate_limit_reset_at' | 'extra'>): string => {
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'last_used_at' | 'rate_limit_reset_at' | 'extra'>): string => {
if (account.platform !== 'openai' || account.type !== 'oauth') {
return ''
}
@@ -14,6 +14,7 @@ export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platfo
return [
account.id,
account.updated_at,
account.last_used_at,
account.rate_limit_reset_at,
extra.codex_usage_updated_at,
extra.codex_5h_used_percent,
+20
View File
@@ -247,6 +247,26 @@ export function formatTokensK(tokens: number): string {
return tokens.toString()
}
/**
* 格式化大数字(K/M/B,保留 1 位小数)
* @param num 数字
* @param options allowBillions=false 时最高只显示到 M
*/
export function formatCompactNumber(
num: number | null | undefined,
options?: { allowBillions?: boolean }
): string {
if (num === null || num === undefined) return '0'
const abs = Math.abs(num)
const allowBillions = options?.allowBillions !== false
if (allowBillions && abs >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`
if (abs >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${(num / 1_000).toFixed(1)}K`
return num.toString()
}
/**
* 格式化倒计时(从现在到目标时间的剩余时间)
* @param targetDate 目标日期字符串或 Date 对象
+30 -9
View File
@@ -205,7 +205,12 @@
<AccountGroupsCell :groups="row.groups" :max-display="4" />
</template>
<template #cell-usage="{ row }">
<AccountUsageCell :account="row" />
<AccountUsageCell
:account="row"
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
:today-stats-loading="todayStatsLoading"
:manual-refresh-token="usageManualRefreshToken"
/>
</template>
<template #cell-proxy="{ row }">
<div v-if="row.proxy" class="flex items-center gap-2">
@@ -325,13 +330,13 @@ import Icon from '@/components/icons/Icon.vue'
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const proxies = ref<Proxy[]>([])
const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
const selPlatforms = computed<AccountPlatform[]>(() => {
@@ -404,6 +409,7 @@ const todayStatsLoading = ref(false)
const todayStatsError = ref<string | null>(null)
const todayStatsReqSeq = ref(0)
const pendingTodayStatsRefresh = ref(false)
const usageManualRefreshToken = ref(0)
const buildDefaultTodayStats = (): WindowStats => ({
requests: 0,
@@ -414,7 +420,11 @@ const buildDefaultTodayStats = (): WindowStats => ({
})
const refreshTodayStatsBatch = async () => {
if (hiddenColumns.has('today_stats')) {
// Why this checks both columns:
// - today_stats column shows dedicated today's metrics.
// - usage column also embeds today's stats for Key/Bedrock rows.
// So we only skip fetching when BOTH columns are hidden.
if (hiddenColumns.has('today_stats') && hiddenColumns.has('usage')) {
todayStatsLoading.value = false
todayStatsError.value = null
return
@@ -466,13 +476,19 @@ const loadSavedColumns = () => {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) {
const parsed = JSON.parse(saved) as string[]
parsed.forEach(key => hiddenColumns.add(key))
parsed.forEach(key => {
hiddenColumns.add(key)
})
} else {
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
hiddenColumns.add(key)
})
}
} catch (e) {
console.error('Failed to load saved columns:', e)
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
hiddenColumns.add(key)
})
}
}
@@ -546,7 +562,7 @@ const toggleColumn = (key: string) => {
hiddenColumns.add(key)
}
saveColumnsToStorage()
if (key === 'today_stats' && wasHidden) {
if ((key === 'today_stats' || key === 'usage') && wasHidden) {
refreshTodayStatsBatch().catch((error) => {
console.error('Failed to load account today stats after showing column:', error)
})
@@ -770,11 +786,15 @@ const refreshAccountsIncrementally = async () => {
const handleManualRefresh = async () => {
await load()
// Force usage cells to refetch /usage on explicit user refresh.
usageManualRefreshToken.value += 1
}
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
// Keep behavior consistent with manual refresh.
usageManualRefreshToken.value += 1
}
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
@@ -890,7 +910,8 @@ const openMenu = (a: Account, e: MouseEvent) => {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left, top
let left: number
let top: number
if (viewportWidth < 768) {
// 居中显示,水平位置
+9 -1
View File
@@ -241,6 +241,8 @@
:enable-ranking-view="true"
:ranking-items="rankingItems"
:ranking-total-actual-cost="rankingTotalActualCost"
:ranking-total-requests="rankingTotalRequests"
:ranking-total-tokens="rankingTotalTokens"
:loading="chartsLoading"
:ranking-loading="rankingLoading"
:ranking-error="rankingError"
@@ -334,6 +336,8 @@ const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
const rankingItems = ref<UserSpendingRankingItem[]>([])
const rankingTotalActualCost = ref(0)
const rankingTotalRequests = ref(0)
const rankingTotalTokens = ref(0)
let chartLoadSeq = 0
let usersTrendLoadSeq = 0
let rankingLoadSeq = 0
@@ -347,7 +351,7 @@ const formatLocalDate = (date: Date): string => {
const getTodayLocalDate = () => formatLocalDate(new Date())
// Date range
const granularity = ref<'day' | 'hour'>('day')
const granularity = ref<'day' | 'hour'>('hour')
const startDate = ref(getTodayLocalDate())
const endDate = ref(getTodayLocalDate())
@@ -630,11 +634,15 @@ const loadUserSpendingRanking = async () => {
if (currentSeq !== rankingLoadSeq) return
rankingItems.value = response.ranking || []
rankingTotalActualCost.value = response.total_actual_cost || 0
rankingTotalRequests.value = response.total_requests || 0
rankingTotalTokens.value = response.total_tokens || 0
} catch (error) {
if (currentSeq !== rankingLoadSeq) return
console.error('Error loading user spending ranking:', error)
rankingItems.value = []
rankingTotalActualCost.value = 0
rankingTotalRequests.value = 0
rankingTotalTokens.value = 0
rankingError.value = true
} finally {
if (currentSeq === rankingLoadSeq) {
+4 -2
View File
@@ -122,7 +122,7 @@ type DistributionMetric = 'tokens' | 'actual_cost'
type EndpointSource = 'inbound' | 'upstream' | 'path'
const route = useRoute()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
const modelDistributionMetric = ref<DistributionMetric>('tokens')
const groupDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionMetric = ref<DistributionMetric>('tokens')
@@ -159,6 +159,7 @@ const formatLD = (d: Date) => {
return `${year}-${month}-${day}`
}
const getTodayLocalDate = () => formatLD(new Date())
const getGranularityForRange = (start: string, end: string): 'day' | 'hour' => start === end ? 'hour' : 'day'
const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDate())
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
@@ -193,6 +194,7 @@ const applyRouteQueryFilters = () => {
start_date: startDate.value,
end_date: endDate.value
}
granularity.value = getGranularityForRange(startDate.value, endDate.value)
}
const loadLogs = async () => {
@@ -258,7 +260,7 @@ const loadChartData = async () => {
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() }
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = getGranularityForRange(startDate.value, endDate.value); applyFilters() }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const cancelExport = () => exportAbortController?.abort()