Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
27600b1
fix(gateway): filter count_tokens generation fields
Pluviobyte May 28, 2026
2bd3125
Preserve usage request context
wey-gu May 28, 2026
ead471d
feat(account): 支持按 5h/7d 用量阈值自动暂停账号调度
wucm667 May 29, 2026
8b7a822
fix(account): address review on OpenAI quota auto-pause
wucm667 May 29, 2026
eba2046
fix: enrich OpenAI OAuth token refresh
xiaoyiluck666 May 29, 2026
c9caadb
fix(account): address second-round review on quota auto-pause
wucm667 May 29, 2026
0a521f0
fix(gemini): close tool_use block before text in messages streaming
Pluviobyte May 29, 2026
f68d351
Merge pull request #2873 from wucm667/feat/account-quota-threshold-au…
Wei-Shaw May 29, 2026
5229274
Merge pull request #2849 from Pluviobyte/fix/count-tokens-payload-filter
Wei-Shaw May 29, 2026
69e7c4d
Merge pull request #2865 from wey-gu/feat/usage-request-context
Wei-Shaw May 29, 2026
0a8c77f
Merge pull request #2884 from Pluviobyte/fix/gemini-messages-tool-tex…
Wei-Shaw May 29, 2026
68901cb
chore(pricing): update model pricing metadata
Wei-Shaw May 29, 2026
7321e4d
chore: sync VERSION to 0.1.133 [skip ci]
github-actions[bot] May 29, 2026
06fca66
feat(quota): sentinel 回填消除无配额行用户 preflight 每请求回源 DB
DaydreamCoding May 29, 2026
f7f5e33
feat(quota): user×platform 配额 DB 写聚合 flusher
DaydreamCoding May 29, 2026
5fd9a35
test(pricing): 修复 codex-auto-review 定价测试断言
DaydreamCoding May 29, 2026
f18451e
Merge pull request #2891 from DaydreamCoding/feat/platform-quota-conn…
Wei-Shaw May 29, 2026
c256a54
feat(admin): 账号用量窗口 5h/7d 增加说明 tooltip
wucm667 May 29, 2026
57d9e15
feat: 添加账号时支持同步上游模型
BenjaminAaron196 May 29, 2026
b60d8bb
feat(usage): 在 /admin/usage 支持查看已删除用户的历史使用情况
DaydreamCoding May 28, 2026
bf24b61
perf(usage): 优化 /admin/usage 打开速度与刷新响应
DaydreamCoding May 30, 2026
d8cbf9a
refactor(gateway): introduce request body refs
is7Qin May 29, 2026
b1c4be4
refactor(gateway): remove parsed request object graphs
is7Qin May 29, 2026
619e5ae
refactor(gateway): isolate anthropic body rewrites
is7Qin May 29, 2026
2caee9d
refactor(gateway): snapshot usage worker inputs
is7Qin May 30, 2026
5a3e193
refactor(gateway): shorten OpenAI request body retention
is7Qin May 30, 2026
09af6eb
refactor(gateway): avoid extra OpenAI WS body map copy
is7Qin May 30, 2026
34de99e
refactor(gateway): cap upstream error body reads
is7Qin May 30, 2026
6a5f6b9
refactor(gateway): introduce OpenAI request view
is7Qin May 30, 2026
b65dde6
fix(usage): 修正 OpenAI 5h 用量百分比语义
wucm667 May 31, 2026
bf3787d
fix(gateway): allow Claude Code count_tokens
wucm667 May 31, 2026
1e2193c
fix: avoid websocket usage dedup conflicts
gsh20040816 May 31, 2026
8ac2e23
refactor(gateway): defer OpenAI request map decoding
is7Qin May 31, 2026
f10bca8
refactor(apicompat): redesign the Codex Responses ↔ Chat Completions …
visa2 May 31, 2026
1afae0a
fix(gateway): address OpenAI OOM review lint
is7Qin May 31, 2026
a01686c
fix antigravity gemini rate limit and account scheduling
moonagic May 31, 2026
0560340
fix: change balance to pointer type
fatelei Jun 1, 2026
c596b0a
Merge pull request #2908 from DaydreamCoding/feat/admin-usage
Wei-Shaw Jun 1, 2026
0f70f84
Merge pull request #2925 from is7Qin/feat/openai-oom
Wei-Shaw Jun 1, 2026
29c8b8a
Merge pull request #2918 from wucm667/fix/openai-5h-usage-window-inve…
Wei-Shaw Jun 1, 2026
52ef848
Merge pull request #2919 from wucm667/fix/claude-code-only-allow-coun…
Wei-Shaw Jun 1, 2026
ed8f566
Merge pull request #2895 from wucm667/feat/usage-window-tooltip
Wei-Shaw Jun 1, 2026
910ff3c
Merge pull request #2892 from Arron196/feat/sync-upstream-models-on-c…
Wei-Shaw Jun 1, 2026
418d09b
Merge pull request #2881 from xiaoyiluck666/fix/openai-oauth-refresh-…
Wei-Shaw Jun 1, 2026
c8cd91e
test(openai): 覆盖 failover 请求体重映射
wucm667 May 31, 2026
7ad2dc7
Merge pull request #2922 from gsh20040816/codex/fix-openai-ws-usage-d…
Wei-Shaw Jun 1, 2026
08e19bb
fix(openai): bridge oversized websocket requests
xlx0852 Jun 1, 2026
80fa2f2
Merge pull request #2920 from wucm667/fix/openai-failover-stale-cache…
Wei-Shaw Jun 1, 2026
bc5813f
Merge pull request #2936 from xlx0852/codex/ws-http-bridge-recovery
Wei-Shaw Jun 1, 2026
2a075a8
fix(openai): 注入 WS Codex 生图桥接工具
Kanshan03 Jun 1, 2026
a0057f4
Merge pull request #2932 from fatelei/issue-2917
Wei-Shaw Jun 1, 2026
003b278
test(apicompat): check type assertions in responses stream wire tests
visa2 Jun 1, 2026
3ba4a51
Merge pull request #2937 from Kanshan03/fix/ws-codex-image-bridge
Wei-Shaw Jun 1, 2026
5f63fe1
Merge pull request #2927 from moonagic/main
Wei-Shaw Jun 1, 2026
aa69e39
Merge pull request #2926 from visa2/feat/codex-responses-bridge-redesign
Wei-Shaw Jun 1, 2026
9b6b894
Merge remote-tracking branch 'upstream/main' into sync-upstream-main-…
GottenHeave Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/cmd/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.132
0.1.133
7 changes: 7 additions & 0 deletions backend/cmd/server/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
Expand Down Expand Up @@ -246,6 +247,12 @@ func provideCleanup(
}
return nil
}},
{"UserPlatformQuotaUsageFlusher", func() error {
if quotaFlusher != nil {
quotaFlusher.Stop()
}
return nil
}},
}

infraSteps := []cleanupStep{
Expand Down
14 changes: 11 additions & 3 deletions backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/cmd/server/wire_gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // quotaFlusher
)

require.NotPanics(t, func() {
Expand Down
32 changes: 32 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,9 @@ type BillingConfig struct {
// - billing_cache_service.checkUserPlatformQuotaEligibility 首次缓存装载
// 读写两端必须共用同一 TTL,避免缓存生命周期不一致导致 quota 计数漂移。
UserPlatformQuotaCacheTTLSeconds int `mapstructure:"user_platform_quota_cache_ttl_seconds"`
// UserPlatformQuotaSentinelTTLSeconds sentinel(无 limit 占位)entry 的 TTL,
// 显著短于 quota cache 默认 86400s 以控 Redis 内存;默认 3600=1h。
UserPlatformQuotaSentinelTTLSeconds int `mapstructure:"user_platform_quota_sentinel_ttl_seconds"`
}

type CircuitBreakerConfig struct {
Expand Down Expand Up @@ -882,6 +885,12 @@ type GatewayOpenAIWSConfig struct {
StoreDisabledForceNewConn bool `mapstructure:"store_disabled_force_new_conn"`
// PrewarmGenerateEnabled: 是否启用 WSv2 generate=false 预热(默认 false)
PrewarmGenerateEnabled bool `mapstructure:"prewarm_generate_enabled"`
// ClientReadLimitBytes: 入站客户端 WS 单帧读取上限。
ClientReadLimitBytes int64 `mapstructure:"client_read_limit_bytes"`
// HTTPBridgeEnabled: 首包过大时,保持客户端 WS,改用 HTTP Responses 上游。
HTTPBridgeEnabled bool `mapstructure:"http_bridge_enabled"`
// HTTPBridgeThresholdBytes: 触发 HTTP bridge 的入站 WS payload 阈值。
HTTPBridgeThresholdBytes int64 `mapstructure:"http_bridge_threshold_bytes"`

// Feature 开关:v2 优先于 v1
ResponsesWebsockets bool `mapstructure:"responses_websockets"`
Expand Down Expand Up @@ -1091,6 +1100,13 @@ type DatabaseConfig struct {
ConnMaxLifetimeMinutes int `mapstructure:"conn_max_lifetime_minutes"`
// ConnMaxIdleTimeMinutes: 空闲连接最大存活时间,及时释放不活跃连接
ConnMaxIdleTimeMinutes int `mapstructure:"conn_max_idle_time_minutes"`
// UserPlatformQuotaFlusherEnabled: 是否启用 user×platform 配额写聚合 flusher
UserPlatformQuotaFlusherEnabled bool `mapstructure:"user_platform_quota_flusher_enabled"`
// UserPlatformQuotaFlushIntervalMs: flusher 刷写间隔(毫秒)
UserPlatformQuotaFlushIntervalMs int `mapstructure:"user_platform_quota_flush_interval_ms"`
// UserPlatformQuotaFlushBatchSize: flusher 单批最大条数
// 建议 ≤ 6000(单条 UPSERT 原子上限)
UserPlatformQuotaFlushBatchSize int `mapstructure:"user_platform_quota_flush_batch_size"`
}

func (d *DatabaseConfig) DSN() string {
Expand Down Expand Up @@ -1571,6 +1587,7 @@ func setDefaults() {
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)

// Turnstile
viper.SetDefault("turnstile.required", false)
Expand Down Expand Up @@ -1657,6 +1674,9 @@ func setDefaults() {
viper.SetDefault("database.max_idle_conns", 128)
viper.SetDefault("database.conn_max_lifetime_minutes", 30)
viper.SetDefault("database.conn_max_idle_time_minutes", 5)
viper.SetDefault("database.user_platform_quota_flusher_enabled", false)
viper.SetDefault("database.user_platform_quota_flush_interval_ms", 2000)
viper.SetDefault("database.user_platform_quota_flush_batch_size", 1000)

// Redis
viper.SetDefault("redis.host", "localhost")
Expand Down Expand Up @@ -1792,6 +1812,9 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.store_disabled_conn_mode", "strict")
viper.SetDefault("gateway.openai_ws.store_disabled_force_new_conn", true)
viper.SetDefault("gateway.openai_ws.prewarm_generate_enabled", false)
viper.SetDefault("gateway.openai_ws.client_read_limit_bytes", 64*1024*1024)
viper.SetDefault("gateway.openai_ws.http_bridge_enabled", true)
viper.SetDefault("gateway.openai_ws.http_bridge_threshold_bytes", 15*1024*1024)
viper.SetDefault("gateway.openai_ws.responses_websockets", false)
viper.SetDefault("gateway.openai_ws.responses_websockets_v2", true)
viper.SetDefault("gateway.openai_ws.max_conns_per_account", 128)
Expand Down Expand Up @@ -2529,6 +2552,15 @@ func (c *Config) Validate() error {
if c.Gateway.OpenAIWS.PrewarmCooldownMS < 0 {
return fmt.Errorf("gateway.openai_ws.prewarm_cooldown_ms must be non-negative")
}
if c.Gateway.OpenAIWS.ClientReadLimitBytes <= 0 {
return fmt.Errorf("gateway.openai_ws.client_read_limit_bytes must be positive")
}
if c.Gateway.OpenAIWS.HTTPBridgeThresholdBytes < 0 {
return fmt.Errorf("gateway.openai_ws.http_bridge_threshold_bytes must be non-negative")
}
if c.Gateway.OpenAIWS.HTTPBridgeEnabled && c.Gateway.OpenAIWS.HTTPBridgeThresholdBytes == 0 {
return fmt.Errorf("gateway.openai_ws.http_bridge_threshold_bytes must be positive when http_bridge_enabled is true")
}
if c.Gateway.OpenAIWS.FallbackCooldownSeconds < 0 {
return fmt.Errorf("gateway.openai_ws.fallback_cooldown_seconds must be non-negative")
}
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.PrewarmCooldownMS != 300 {
t.Fatalf("Gateway.OpenAIWS.PrewarmCooldownMS = %d, want 300", cfg.Gateway.OpenAIWS.PrewarmCooldownMS)
}
if cfg.Gateway.OpenAIWS.ClientReadLimitBytes != 64*1024*1024 {
t.Fatalf("Gateway.OpenAIWS.ClientReadLimitBytes = %d, want %d", cfg.Gateway.OpenAIWS.ClientReadLimitBytes, 64*1024*1024)
}
if !cfg.Gateway.OpenAIWS.HTTPBridgeEnabled {
t.Fatalf("Gateway.OpenAIWS.HTTPBridgeEnabled = false, want true")
}
if cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes != 15*1024*1024 {
t.Fatalf("Gateway.OpenAIWS.HTTPBridgeThresholdBytes = %d, want %d", cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes, 15*1024*1024)
}
if cfg.Gateway.OpenAIWS.RetryBackoffInitialMS != 120 {
t.Fatalf("Gateway.OpenAIWS.RetryBackoffInitialMS = %d, want 120", cfg.Gateway.OpenAIWS.RetryBackoffInitialMS)
}
Expand Down
50 changes: 50 additions & 0 deletions backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2131,6 +2131,56 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
response.Success(c, gin.H{"models": models})
}

// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
// POST /api/v1/admin/accounts/models/sync-upstream-preview
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
var req struct {
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}

tempAccount := &service.Account{
Platform: req.Platform,
Type: req.Type,
Credentials: map[string]any{
"api_key": req.APIKey,
"base_url": req.BaseURL,
},
}

if h.accountTestService == nil {
response.InternalError(c, "Account test service is not configured")
return
}

models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
if err != nil {
var syncErr *service.UpstreamModelSyncError
if errors.As(err, &syncErr) {
switch syncErr.Kind {
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
response.BadRequest(c, syncErr.SafeMessage())
default:
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
}
return
}

slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform)
response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream")
return
}

response.Success(c, gin.H{"models": models})
}

// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
// POST /api/v1/admin/accounts/:id/set-privacy
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/handler/admin/admin_service_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ func (s *stubAdminService) GetUser(ctx context.Context, id int64) (*service.User
return &user, nil
}

func (s *stubAdminService) GetUserIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetUser(ctx, id)
}

func (s *stubAdminService) CreateUser(ctx context.Context, input *service.CreateUserInput) (*service.User, error) {
user := service.User{ID: 100, Email: input.Email, Status: service.StatusActive}
return &user, nil
Expand Down
36 changes: 26 additions & 10 deletions backend/internal/handler/admin/usage_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,24 @@ func (h *UsageHandler) Stats(c *gin.Context) {
EndTime: &endTime,
}

stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
var stats *usagestats.UsageStats
// nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
if parseBoolQueryWithDefault(c.Query("nocache"), false) {
s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", "bypass")
} else {
s, hit, err := h.getStatsCached(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
}

response.Success(c, stats)
Expand All @@ -344,23 +358,25 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
}

// Limit to 30 results
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}

// Return simplified user list (only id and email)
// Return simplified user list (only id, email and deleted flag)
type SimpleUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
}

result := make([]SimpleUser, len(users))
for i, u := range users {
result[i] = SimpleUser{
ID: u.ID,
Email: u.Email,
ID: u.ID,
Email: u.Email,
Deleted: u.DeletedAt != nil,
}
}

Expand Down
56 changes: 56 additions & 0 deletions backend/internal/handler/admin/usage_handler_search_users_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package admin

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

// 捕获 ListUsers 入参、返回一个已删用户的 admin service 桩。
type searchUsersAdminStub struct {
service.AdminService
gotFilters service.UserListFilters
}

func (s *searchUsersAdminStub) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
s.gotFilters = filters
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
return []service.User{
{ID: 1, Email: "active@test.com"},
{ID: 2, Email: "deleted@test.com", DeletedAt: &ts},
}, 2, nil
}

func TestAdminUsageSearchUsers_IncludesDeletedAndFlags(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &searchUsersAdminStub{}
handler := NewUsageHandler(nil, nil, stub, nil)
router := gin.New()
router.GET("/admin/usage/search-users", handler.SearchUsers)

req := httptest.NewRequest(http.MethodGet, "/admin/usage/search-users?q=test", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
require.True(t, stub.gotFilters.IncludeDeleted, "SearchUsers 必须请求 IncludeDeleted")

var resp struct {
Data []struct {
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 2)
require.False(t, resp.Data[0].Deleted)
require.True(t, resp.Data[1].Deleted, "已删用户必须标记 deleted=true")
}
Loading
Loading