From 57b7dbdc8558f330a2ef1758c0c95d913056aef3 Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 17 Sep 2026 21:22:09 +0800 Subject: [PATCH 01/21] =?UTF-8?q?feat(affiliate):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81=E7=99=BB=E8=AE=B0=E7=BA=BF?= =?UTF-8?q?=E4=B8=8B=E6=8F=90=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理员在站外给邀请人打款后,系统里原本无法扣减对应的返利额度,用户仍可把 这部分额度转入余额。现在可在「邀请返利 → 提取记录」点「登记线下提现」, 从该用户的可提取返利额度中扣除已打款金额: - POST /api/v1/admin/affiliates/users/:user_id/withdraw,请求体只含 amount, 金额按 8 位小数舍入后校验 - 先解冻已到期的冻结额度,再以 aff_quota >= amount 为条件原子扣减,写入 action=withdraw 的流水并带额度快照;额度不足返回 AFFILIATE_QUOTA_INSUFFICIENT - 不新增字段与迁移:线下提现复用已有 action 列区分,操作人由管理端审计日志记录 - 提取记录同时列出转入余额与线下提现,新增「类型」列 - 弹窗选择用户后展示可提取额度,支持一键填入全部;从未产生返利的用户按 0 处理 返利记录改为 LEFT JOIN 订单与被邀请人,兑换码、管理员充值来源的返利也会出现 在明细中,接口里订单相关字段与被邀请人 ID 可为 null。 --- .../handler/admin/affiliate_handler.go | 36 +- .../admin/affiliate_handler_withdraw_test.go | 105 ++++++ .../internal/handler/auth_email_oauth_test.go | 4 + backend/internal/repository/affiliate_repo.go | 139 +++++++- .../affiliate_repo_integration_test.go | 156 +++++++++ .../repository/affiliate_repo_test.go | 27 +- backend/internal/server/routes/admin.go | 1 + backend/internal/service/affiliate_service.go | 48 ++- .../service/affiliate_service_test.go | 64 ++++ .../service/payment_fulfillment_test.go | 4 + frontend/src/api/admin/affiliates.ts | 44 ++- .../src/i18n/locales/en/admin/overview.ts | 34 +- .../src/i18n/locales/zh/admin/overview.ts | 34 +- .../affiliates/AdminAffiliateRecordsTable.vue | 51 ++- .../AffiliateOfflineWithdrawDialog.vue | 323 ++++++++++++++++++ .../AdminAffiliateRecordsTable.spec.ts | 179 ++++++++++ .../AffiliateOfflineWithdrawDialog.spec.ts | 152 +++++++++ 17 files changed, 1365 insertions(+), 36 deletions(-) create mode 100644 backend/internal/handler/admin/affiliate_handler_withdraw_test.go create mode 100644 frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue create mode 100644 frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts create mode 100644 frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index d443d344d2..0c2db16cd1 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -201,6 +201,36 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) { response.Success(c, overview) } +// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给 +// 用户的金额,由 AffiliateService 校验。 +type WithdrawQuotaRequest struct { + Amount float64 `json:"amount"` +} + +// WithdrawQuota records an offline withdrawal paid outside the site and +// deducts it from the user's available affiliate quota. +// POST /api/v1/admin/affiliates/users/:user_id/withdraw +func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user_id") + return + } + + var req WithdrawQuotaRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + // ListInviteRecords returns all inviter-invitee relationships. // GET /api/v1/admin/affiliates/invites func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { @@ -214,7 +244,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListRebateRecords returns all order-level affiliate rebate records. +// ListRebateRecords returns every affiliate rebate accrual, with order details +// when the rebate came from a payment order. // GET /api/v1/admin/affiliates/rebates func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -227,7 +258,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListTransferRecords returns all affiliate quota-to-balance transfer records. +// ListTransferRecords returns affiliate quota outflows: user transfers into +// balance and admin-recorded offline withdrawals. // GET /api/v1/admin/affiliates/transfers func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go new file mode 100644 index 0000000000..2ef1be66bf --- /dev/null +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -0,0 +1,105 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type withdrawQuotaCall struct { + userID int64 + amount float64 +} + +type withdrawQuotaRepoStub struct { + service.AffiliateRepository + calls []withdrawQuotaCall + result *service.AffiliateWithdrawResult + err error +} + +func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount}) + return s.result, s.err +} + +type withdrawQuotaResponse struct { + Code int `json:"code"` + Reason string `json:"reason"` + Data *service.AffiliateWithdrawResult `json:"data"` +} + +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil) + router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp withdrawQuotaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return rec.Code, resp +} + +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与 +// 舍入后的金额交给仓储,并返回登记结果。 +func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, 0, resp.Code) + require.NotNil(t, resp.Data) + require.Equal(t, int64(9), resp.Data.LedgerID) + require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON +// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) { + cases := []struct { + name string + userID string + body string + wantReason string + }{ + {name: "non-numeric user id", userID: "abc", body: `{"amount":1}`}, + {name: "non-positive user id", userID: "0", body: `{"amount":1}`}, + {name: "malformed json", userID: "42", body: `{"amount":`}, + {name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{} + status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, repo.calls) + }) + } +} + +// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和 +// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。 +func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) { + repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason) + require.Len(t, repo.calls, 1) +} diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index 493b509989..18e3ea6725 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in panic("unexpected TransferQuotaToBalance call") } +func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 61da539e25..3553600557 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,6 +341,102 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } +// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以 +// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。 +// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。 +func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, service.ErrUserNotFound + } + if amount <= 0 { + return nil, service.ErrAffiliateWithdrawAmountInvalid + } + + var result *service.AffiliateWithdrawResult + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { + return fmt.Errorf("thaw before withdraw: %w", err) + } + + res, err := txClient.ExecContext(txCtx, ` +UPDATE user_affiliates +SET aff_quota = aff_quota - $1, + updated_at = NOW() +WHERE user_id = $2 + AND aff_quota >= $1`, amount, userID) + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + if affected == 0 { + return service.ErrAffiliateQuotaInsufficient + } + + snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID) + if err != nil { + return err + } + + rows, err := txClient.QueryContext(txCtx, ` +INSERT INTO user_affiliate_ledger ( + user_id, + action, + amount, + source_user_id, + balance_after, + aff_quota_after, + aff_frozen_quota_after, + aff_history_quota_after, + created_at, + updated_at +) +VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW()) +RETURNING id`, + userID, + amount, + snapshot.BalanceAfter, + snapshot.AvailableQuotaAfter, + snapshot.FrozenQuotaAfter, + snapshot.HistoryQuotaAfter, + ) + if err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + if !rows.Next() { + _ = rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + return errors.New("insert affiliate withdraw ledger: no id returned") + } + var ledgerID int64 + if err := rows.Scan(&ledgerID); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + result = &service.AffiliateWithdrawResult{ + LedgerID: ledgerID, + UserID: userID, + Amount: amount, + AvailableQuotaAfter: snapshot.AvailableQuotaAfter, + FrozenQuotaAfter: snapshot.FrozenQuotaAfter, + HistoryQuotaAfter: snapshot.HistoryQuotaAfter, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { if limit <= 0 { limit = 100 @@ -466,13 +562,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi "inviter.email", "inviter.username", "invitee.email", "invitee.username", "po.id::text", "po.out_trade_no", "po.payment_type", "po.status", }) + // 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号 + // 删除后 source_user_id 为 NULL,这两类行都保留。 baseJoin := ` FROM user_affiliate_ledger ual -JOIN payment_orders po ON po.id = ual.source_order_id -JOIN users invitee ON invitee.id = ual.source_user_id JOIN users inviter ON inviter.id = ual.user_id -WHERE ual.action = 'accrue' - AND ual.source_order_id IS NOT NULL` +LEFT JOIN users invitee ON invitee.id = ual.source_user_id +LEFT JOIN payment_orders po ON po.id = ual.source_order_id +WHERE ual.action = 'accrue'` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -496,7 +593,7 @@ WHERE ual.action = 'accrue' args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT po.id, - po.out_trade_no, + COALESCE(po.out_trade_no, ''), ual.user_id, COALESCE(inviter.email, ''), COALESCE(inviter.username, ''), @@ -506,8 +603,8 @@ SELECT po.id, po.amount::double precision, po.pay_amount::double precision, ual.amount::double precision, - po.payment_type, - po.status, + COALESCE(po.payment_type, ''), + COALESCE(po.status, ''), ual.created_at `+baseJoin+where+` `+orderBy+` @@ -520,17 +617,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) items := make([]service.AffiliateRebateRecord, 0) for rows.Next() { var item service.AffiliateRebateRecord + var orderID sql.NullInt64 + var inviteeID sql.NullInt64 + var orderAmount sql.NullFloat64 + var payAmount sql.NullFloat64 if err := rows.Scan( - &item.OrderID, + &orderID, &item.OutTradeNo, &item.InviterID, &item.InviterEmail, &item.InviterUsername, - &item.InviteeID, + &inviteeID, &item.InviteeEmail, &item.InviteeUsername, - &item.OrderAmount, - &item.PayAmount, + &orderAmount, + &payAmount, &item.RebateAmount, &item.PaymentType, &item.OrderStatus, @@ -538,6 +639,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) ); err != nil { return nil, 0, err } + item.OrderID = nullableInt64Ptr(orderID) + item.InviteeID = nullableInt64Ptr(inviteeID) + item.OrderAmount = nullableFloat64Ptr(orderAmount) + item.PayAmount = nullableFloat64Ptr(payAmount) items = append(items, item) } if err := rows.Err(); err != nil { @@ -554,7 +659,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, baseJoin := ` FROM user_affiliate_ledger ual JOIN users u ON u.id = ual.user_id -WHERE ual.action = 'transfer'` +WHERE ual.action IN ('transfer', 'withdraw')` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -566,6 +671,7 @@ WHERE ual.action = 'transfer'` orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ "user": "u.email", + "action": "ual.action", "amount": "ual.amount", "balance_after": "ual.balance_after", "available_quota_after": "ual.aff_quota_after", @@ -576,6 +682,7 @@ WHERE ual.action = 'transfer'` args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT ual.id, + ual.action, ual.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), @@ -602,6 +709,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) var historyQuotaAfter sql.NullFloat64 if err := rows.Scan( &item.LedgerID, + &item.Action, &item.UserID, &item.UserEmail, &item.Username, @@ -958,6 +1066,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 { return &v.Float64 } +func nullableInt64Ptr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} + func generateAffiliateCode() (string, error) { buf := make([]byte, affiliateCodeLength) if _, err := rand.Read(buf); err != nil { diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index b01ed528a6..f74da3da6f 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -417,3 +417,159 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) { require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows") } + +func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User { + t.Helper() + return mustCreateUser(t, client, &service.User{ + Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()), + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 5.5, + Concurrency: 5, + }) +} + +func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) { + t.Helper() + affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000) + _, err := client.ExecContext(ctx, ` +INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history) + require.NoError(t, err) +} + +// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径: +// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。 +func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "user") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5) + require.NoError(t, err) + require.Positive(t, result.LedgerID) + require.InDelta(t, 12.5, result.Amount, 1e-9) + require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9) + + require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client, + "SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client, + "SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9) + + records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{ + Search: u.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Equal(t, result.LedgerID, record.LedgerID) + require.Equal(t, "withdraw", record.Action) + require.Equal(t, u.ID, record.UserID) + require.InDelta(t, 12.5, record.Amount, 1e-9) + require.True(t, record.SnapshotAvailable) + require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9) + require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9) +} + +// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期 +// 但尚未解冻的额度在扣减前解冻并可被提现。 +func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "thaw") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10) + _, err := client.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 10) + require.NoError(t, err) + require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.Equal(t, 1, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的 +// 冻结额度不可提现,额度不足时不扣减、不写流水。 +func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "insufficient") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15) + _, err := client.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + + _, err = repo.WithdrawQuota(txCtx, u.ID, 6) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + + require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 10.0, querySingleFloat(t, txCtx, client, + "SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 0, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码 +// 等非订单来源的返利出现在返利记录中,订单字段为空。 +func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter") + invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee") + mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0) + + applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil) + require.NoError(t, err) + require.True(t, applied) + + records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{ + Search: invitee.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Nil(t, record.OrderID) + require.Nil(t, record.OrderAmount) + require.Nil(t, record.PayAmount) + require.Empty(t, record.OutTradeNo) + require.Equal(t, inviter.ID, record.InviterID) + require.NotNil(t, record.InviteeID) + require.Equal(t, invitee.ID, *record.InviteeID) + require.InDelta(t, 2.0, record.RebateAmount, 1e-9) +} diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index ccb7bb3daf..b3221a1ce8 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -20,9 +20,34 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) { require.NoError(t, err) content := string(source) - require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") require.Contains(t, content, "ual.amount::double precision") require.Contains(t, content, "ual.balance_after::double precision") require.NotContains(t, content, "parseAffiliateRebateAmount") require.NotContains(t, content, `"current_balance": "u.balance"`) } + +// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部 +// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤, +// 兑换码、管理员充值来源的返利才会出现在明细里。 +func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id") + require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL") +} + +// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时 +// 列出转入余额与线下提现两类额度流出。 +func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')") +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 405a5f343f..b3f6b602e6 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -820,6 +820,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/lookup", h.Admin.Affiliate.LookupUsers) users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate) users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview) + users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota) users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings) users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings) } diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 91cca5e216..2c6423c787 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -17,6 +17,9 @@ var ( ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") + + ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota") + ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount") ) const ( @@ -102,6 +105,7 @@ type AffiliateRepository interface { GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) + WithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -156,25 +160,31 @@ type AffiliateInviteRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值) +// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时 +// InviteeID 为 nil。 type AffiliateRebateRecord struct { - OrderID int64 `json:"order_id"` + OrderID *int64 `json:"order_id"` OutTradeNo string `json:"out_trade_no"` InviterID int64 `json:"inviter_id"` InviterEmail string `json:"inviter_email"` InviterUsername string `json:"inviter_username"` - InviteeID int64 `json:"invitee_id"` + InviteeID *int64 `json:"invitee_id"` InviteeEmail string `json:"invitee_email"` InviteeUsername string `json:"invitee_username"` - OrderAmount float64 `json:"order_amount"` - PayAmount float64 `json:"pay_amount"` + OrderAmount *float64 `json:"order_amount"` + PayAmount *float64 `json:"pay_amount"` RebateAmount float64 `json:"rebate_amount"` PaymentType string `json:"payment_type"` OrderStatus string `json:"order_status"` CreatedAt time.Time `json:"created_at"` } +// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额) +// 或 withdraw(管理员登记的线下提现)。 type AffiliateTransferRecord struct { LedgerID int64 `json:"ledger_id"` + Action string `json:"action"` UserID int64 `json:"user_id"` UserEmail string `json:"user_email"` Username string `json:"username"` @@ -191,6 +201,16 @@ type AffiliateTransferRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +type AffiliateWithdrawResult struct { + LedgerID int64 `json:"ledger_id"` + UserID int64 `json:"user_id"` + Amount float64 `json:"amount"` + AvailableQuotaAfter float64 `json:"available_quota_after"` + FrozenQuotaAfter float64 `json:"frozen_quota_after"` + HistoryQuotaAfter float64 `json:"history_quota_after"` +} + type AffiliateUserOverview struct { UserID int64 `json:"user_id"` Email string `json:"email"` @@ -559,6 +579,26 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent) } +// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 +// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 +// 金额按返利流水精度保留 8 位小数,舍入后必须为正。 +func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") + } + if s == nil || s.repo == nil { + return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") + } + if math.IsNaN(amount) || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + amount = roundTo(amount, 8) + if amount <= 0 || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + return s.repo.WithdrawQuota(ctx, userID, amount) +} + // AdminListCustomUsers 列出有专属配置的用户。 func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) { if s == nil || s.repo == nil { diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index c02a4dd711..1e3f0b6c59 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -129,3 +129,67 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { }) } } + +type affiliateWithdrawCall struct { + userID int64 + amount float64 +} + +type affiliateWithdrawRepoStub struct { + AffiliateRepository + calls []affiliateWithdrawCall + result *AffiliateWithdrawResult + err error +} + +func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { + s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount}) + return s.result, s.err +} + +// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、 +// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。 +func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { + t.Parallel() + for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} { + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, amount) + require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount) + require.Empty(t, repo.calls, "amount %v must not reach repository", amount) + } +} + +// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。 +func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1) + require.Error(t, err) + require.Empty(t, repo.calls) +} + +// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后 +// 交给仓储,仓储结果原样返回。 +func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { + t.Parallel() + want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891} + repo := &affiliateWithdrawRepoStub{result: want} + svc := &AffiliateService{repo: repo} + + got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149) + require.NoError(t, err) + require.Same(t, want, got) + require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAdminWithdrawQuota_PropagatesInsufficientQuota 验证额度不足由仓储判定, +// 服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1) + require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient) +} diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b1366c3c96..f604e811f9 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con panic("unexpected TransferQuotaToBalance call") } +func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index dadb0ae9e5..8cc736bce7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -46,25 +46,35 @@ export interface AffiliateInviteRecord { created_at: string } +/** + * One rebate accrual. Non-order sources (redeem codes, admin recharges) carry no + * order, so order fields are null; invitee_id is null when the invitee account + * was deleted. + */ export interface AffiliateRebateRecord { - order_id: number + order_id: number | null out_trade_no: string inviter_id: number inviter_email: string inviter_username: string - invitee_id: number + invitee_id: number | null invitee_email: string invitee_username: string - order_amount: number - pay_amount: number + order_amount: number | null + pay_amount: number | null rebate_amount: number payment_type: string order_status: string created_at: string } +/** transfer = user moved quota into balance; withdraw = admin-recorded offline withdrawal. */ +export type AffiliateOutflowAction = 'transfer' | 'withdraw' + +/** One affiliate quota outflow: a transfer into balance or an offline withdrawal. */ export interface AffiliateTransferRecord { ledger_id: number + action: AffiliateOutflowAction user_id: number user_email: string username: string @@ -77,6 +87,20 @@ export interface AffiliateTransferRecord { created_at: string } +export interface WithdrawAffiliateQuotaRequest { + /** Amount already paid to the user outside the site (USD). */ + amount: number +} + +export interface AffiliateWithdrawResult { + ledger_id: number + user_id: number + amount: number + available_quota_after: number + frozen_quota_after: number + history_quota_after: number +} + export interface AffiliateUserOverview { user_id: number email: string @@ -215,6 +239,17 @@ export async function getUserOverview( return data } +export async function withdrawUserQuota( + userId: number, + payload: WithdrawAffiliateQuotaRequest, +): Promise { + const { data } = await apiClient.post( + `/admin/affiliates/users/${userId}/withdraw`, + payload, + ) + return data +} + export const affiliatesAPI = { listUsers, lookupUsers, @@ -225,6 +260,7 @@ export const affiliatesAPI = { listRebateRecords, listTransferRecords, getUserOverview, + withdrawUserQuota, } export default affiliatesAPI diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 725cdada28..51bd13a6ac 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -390,10 +390,12 @@ export default { affiliates: { invitesDescription: 'View site-wide inviter and invitee relationships', - rebatesDescription: 'View recharge orders that generated affiliate rebates', - transfersDescription: 'View affiliate quota transfers into account balance', + rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges', + transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals', errors: { - loadFailed: 'Failed to load affiliate records' + loadFailed: 'Failed to load affiliate records', + AFFILIATE_QUOTA_INSUFFICIENT: 'Insufficient available affiliate quota', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: 'Invalid withdrawal amount' }, records: { search: 'Search', @@ -418,7 +420,31 @@ export default { historyQuotaAfter: 'Historical Rebate After', invitedAt: 'Invited At', rebatedAt: 'Rebated At', - transferredAt: 'Transferred At' + transferredAt: 'Transferred At', + outflowType: 'Type' + }, + outflowTypes: { + transfer: 'To Balance', + withdraw: 'Offline Withdrawal' + }, + withdraw: { + button: 'Record Offline Withdrawal', + title: 'Record Offline Withdrawal', + user: 'User', + userPlaceholder: 'Search by email or username', + noUserFound: 'No matching users', + changeUser: 'Change user', + availableQuota: 'Available quota', + frozenHint: 'Rebates still in the freeze period are not included in the available quota', + amount: 'Withdrawal amount (USD)', + amountHint: 'Enter the amount already paid to this user outside the site', + fillAll: 'All', + warning: 'Recording deducts this amount from the user\'s available affiliate quota and cannot be undone. Make sure the off-site payment is complete.', + submit: 'Confirm', + submitting: 'Recording...', + success: 'Recorded offline withdrawal of {amount}; {remaining} still available', + amountRequired: 'Enter an amount greater than 0', + amountExceeds: 'Amount cannot exceed the available quota' }, overview: { title: 'Affiliate User Overview', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b3b3e706d2..abc2b0c8d7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -390,10 +390,12 @@ export default { affiliates: { invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', + rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利', + transfersDescription: '查看返利额度转入余额与线下提现的流水', errors: { - loadFailed: '加载邀请返利记录失败' + loadFailed: '加载邀请返利记录失败', + AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效' }, records: { search: '搜索', @@ -418,7 +420,31 @@ export default { historyQuotaAfter: '提取后历史返利', invitedAt: '邀请时间', rebatedAt: '返利时间', - transferredAt: '提取时间' + transferredAt: '提取时间', + outflowType: '类型' + }, + outflowTypes: { + transfer: '转入余额', + withdraw: '线下提现' + }, + withdraw: { + button: '登记线下提现', + title: '登记线下提现', + user: '用户', + userPlaceholder: '输入邮箱或用户名搜索', + noUserFound: '未找到匹配的用户', + changeUser: '更换用户', + availableQuota: '当前可提取额度', + frozenHint: '冻结期内的返利不计入可提取额度', + amount: '提现金额(USD)', + amountHint: '填写已在站外实际打款给该用户的金额', + fillAll: '全部', + warning: '登记后将从该用户的可提取返利额度中扣除,且无法撤销。请确认已完成站外打款。', + submit: '确认登记', + submitting: '登记中...', + success: '已登记线下提现 {amount},剩余可提取 {remaining}', + amountRequired: '请输入大于 0 的金额', + amountExceeds: '提现金额不能超过当前可提取额度' }, overview: { title: '用户返利概览', diff --git a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue index 789df41a0d..ce105853fe 100644 --- a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue +++ b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue @@ -12,6 +12,15 @@ + @@ -36,7 +45,9 @@ /> + @@ -113,6 +133,13 @@ + + (null) +const withdrawDialog = ref(false) let debounceTimer: ReturnType | null = null const columns = computed(() => { @@ -201,6 +230,7 @@ const columns = computed(() => { } return [ { key: 'user', label: t('admin.affiliates.records.user'), sortable: true }, + { key: 'action', label: t('admin.affiliates.records.outflowType'), sortable: true }, { key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true }, { key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true }, { key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true }, @@ -303,6 +333,17 @@ function handleSort(key: string, order: 'asc' | 'desc') { void loadRecords() } +function handleWithdrawSuccess() { + withdrawDialog.value = false + reloadFromFirstPage() +} + +function outflowTypeLabel(action: string | null | undefined): string { + return action === 'withdraw' + ? t('admin.affiliates.outflowTypes.withdraw') + : t('admin.affiliates.outflowTypes.transfer') +} + function formatAmount(value: number | null | undefined): string { return Number(value || 0).toFixed(2) } diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue new file mode 100644 index 0000000000..8d3cf67979 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,323 @@ + + + diff --git a/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts new file mode 100644 index 0000000000..6306a5364b --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts @@ -0,0 +1,179 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue' + +const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({ + listInviteRecords: vi.fn(), + listRebateRecords: vi.fn(), + listTransferRecords: vi.fn(), + getUserOverview: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => { + const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } + return { affiliatesAPI, default: affiliatesAPI } +}) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const TablePageLayoutStub = { + template: '
', +} + +const DataTableStub = { + props: ['columns', 'data'], + template: ` + + + + +
+ +
+ `, +} + +const WithdrawDialogStub = { + name: 'AffiliateOfflineWithdrawDialog', + props: ['show'], + emits: ['close', 'success'], + template: '
', +} + +function mountTable(type: 'invites' | 'rebates' | 'transfers') { + return mount(AdminAffiliateRecordsTable, { + props: { type }, + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + Icon: true, + OrderStatusBadge: true, + BaseDialog: true, + AffiliateOfflineWithdrawDialog: WithdrawDialogStub, + }, + }, + }) +} + +function page(items: T[]) { + return { items, total: items.length, page: 1, page_size: 20, pages: 1 } +} + +describe('AdminAffiliateRecordsTable', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders non-order rebate accruals with empty order fields', async () => { + listRebateRecords.mockResolvedValue(page([ + { + order_id: null, + out_trade_no: '', + inviter_id: 1, + inviter_email: 'inviter@example.com', + inviter_username: 'inviter', + invitee_id: 2, + invitee_email: 'invitee@example.com', + invitee_username: 'invitee', + order_amount: null, + pay_amount: null, + rebate_amount: 2, + payment_type: '', + order_status: '', + created_at: '2026-09-17T08:00:00Z', + }, + ])) + + const wrapper = mountTable('rebates') + await flushPromises() + + const row = wrapper.get('[data-test="row-0"]') + expect(row.get('[data-test="cell-order"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_status"]').text()).toBe('-') + expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00') + expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false) + }) + + it('lists offline withdrawals next to balance transfers with their type', async () => { + listTransferRecords.mockResolvedValue(page([ + { + ledger_id: 11, + action: 'withdraw', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 12.5, + balance_after: 5.5, + available_quota_after: 7.5, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-17T08:00:00Z', + }, + { + ledger_id: 10, + action: 'transfer', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 3, + balance_after: 5.5, + available_quota_after: 20, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-16T08:00:00Z', + }, + ])) + + const wrapper = mountTable('transfers') + await flushPromises() + + const withdrawRow = wrapper.get('[data-test="row-0"]') + expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw') + expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50') + + const transferRow = wrapper.get('[data-test="row-1"]') + expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer') + }) + + it('opens the offline withdrawal dialog and reloads the first page after recording', async () => { + listTransferRecords.mockResolvedValue(page([])) + + const wrapper = mountTable('transfers') + await flushPromises() + expect(listTransferRecords).toHaveBeenCalledTimes(1) + + const button = wrapper.get('[data-test="affiliate-withdraw-open"]') + expect(button.text()).toBe('admin.affiliates.withdraw.button') + await button.trigger('click') + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true') + + wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 }) + await flushPromises() + + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false') + expect(listTransferRecords).toHaveBeenCalledTimes(2) + expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 }) + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts new file mode 100644 index 0000000000..f6aac2570a --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -0,0 +1,152 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue' + +const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({ + lookupUsers: vi.fn(), + getUserOverview: vi.fn(), + withdrawUserQuota: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => ({ + affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + }), + } +}) + +const BaseDialogStub = { + props: ['show', 'title'], + emits: ['close'], + template: '
', +} + +function mountDialog() { + return mount(AffiliateOfflineWithdrawDialog, { + props: { show: true }, + global: { stubs: { BaseDialog: BaseDialogStub } }, + }) +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue({ + user_id: 42, + email: 'inviter@example.com', + username: 'inviter', + aff_code: 'INVITER', + rebate_rate_percent: 20, + invited_count: 3, + rebated_invitee_count: 2, + available_quota: availableQuota, + history_quota: 50, + }) + await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() +} + +describe('AffiliateOfflineWithdrawDialog', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('records the exact available quota as the withdrawal amount', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 12.34567891) + + expect(lookupUsers).toHaveBeenCalledWith('inviter') + expect(getUserOverview).toHaveBeenCalledWith(42) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891') + + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891') + + withdrawUserQuota.mockResolvedValue({ + ledger_id: 9, + user_id: 42, + amount: 12.34567891, + available_quota_after: 0, + frozen_quota_after: 0, + history_quota_after: 50, + }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 }) + }) + + it('blocks amounts above the available quota', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001') + + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + expect(withdrawUserQuota).not.toHaveBeenCalled() + }) + + it('treats a user without an affiliate profile as having no available quota', async () => { + lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }]) + getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' }) + const wrapper = mountDialog() + + await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() + + expect(showError).not.toHaveBeenCalled() + expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com') + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('1') + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + }) + + it('shows the API error and reloads the available quota when recording fails', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + withdrawUserQuota.mockRejectedValue({ reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}') + expect(getUserOverview).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('success')).toBeUndefined() + }) +}) From c19204289d04f643fcf3442dbf998d3796f33555 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 23 Sep 2026 01:24:59 +0800 Subject: [PATCH 02/21] =?UTF-8?q?fix(affiliate):=20=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E6=8F=90=E7=8E=B0=E7=99=BB=E8=AE=B0=E6=8C=89=20Idempotency-Key?= =?UTF-8?q?=20=E5=B9=82=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣 一次返利额度。现在一次登记由 Idempotency-Key 标识,数据库在同一事务内以唯一 约束去重: - user_affiliate_ledger 新增 operation_id(幂等键派生的 SHA-256)与部分唯一 索引(迁移 240);接口要求 Idempotency-Key,缺失或非法返回 400 - 事务内先以 operation_id 写入占位流水,再解冻、扣减并补写额度快照;同键 重试返回首笔流水的结果并带 X-Idempotency-Replayed,不重复扣减;同键换了 用户或金额返回 409 IDEMPOTENCY_KEY_CONFLICT;额度不足时占位随事务回滚 - 并发的同键请求在唯一索引上等待先到者:先到者提交则返回同一条流水,回滚 则自行登记 - 弹窗在网络错误、超时、5xx、408/409 时锁定用户与金额,重试沿用同一请求与 幂等键;幂等键按管理员、用户、金额暂存于 sessionStorage,登记有确定结果后 释放 --- .../handler/admin/affiliate_handler.go | 10 +- .../admin/affiliate_handler_withdraw_test.go | 133 ++++++--- .../internal/handler/auth_email_oauth_test.go | 2 +- backend/internal/repository/affiliate_repo.go | 147 +++++++--- .../affiliate_repo_integration_test.go | 253 +++++++++++++++++- .../repository/affiliate_repo_test.go | 22 ++ backend/internal/service/affiliate_service.go | 27 +- .../service/affiliate_service_test.go | 82 ++++-- .../service/payment_fulfillment_test.go | 2 +- .../240_affiliate_ledger_operation_id.sql | 10 + frontend/src/api/admin/affiliates.ts | 22 +- .../src/i18n/locales/en/admin/overview.ts | 2 + .../src/i18n/locales/zh/admin/overview.ts | 2 + .../AffiliateOfflineWithdrawDialog.vue | 66 +++-- .../AffiliateOfflineWithdrawDialog.spec.ts | 101 ++++++- .../affiliateWithdrawOperation.spec.ts | 50 ++++ .../affiliates/affiliateWithdrawOperation.ts | 68 +++++ 17 files changed, 859 insertions(+), 140 deletions(-) create mode 100644 backend/migrations/240_affiliate_ledger_operation_id.sql create mode 100644 frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts create mode 100644 frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index 0c2db16cd1..a5ed9e1cc0 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -208,7 +208,10 @@ type WithdrawQuotaRequest struct { } // WithdrawQuota records an offline withdrawal paid outside the site and -// deducts it from the user's available affiliate quota. +// deducts it from the user's available affiliate quota. The required +// Idempotency-Key header identifies one registration: a repeated request with +// the same key deducts nothing and returns the first result with +// X-Idempotency-Replayed: true. // POST /api/v1/admin/affiliates/users/:user_id/withdraw func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) @@ -223,11 +226,14 @@ func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { return } - result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount) + result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount, c.GetHeader("Idempotency-Key")) if err != nil { response.ErrorFrom(c, err) return } + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } response.Success(c, result) } diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go index 2ef1be66bf..0d4344fde5 100644 --- a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -13,9 +13,12 @@ import ( "github.com/stretchr/testify/require" ) +const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e" + type withdrawQuotaCall struct { - userID int64 - amount float64 + userID int64 + amount float64 + operationID string } type withdrawQuotaRepoStub struct { @@ -25,18 +28,18 @@ type withdrawQuotaRepoStub struct { err error } -func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { - s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount}) +func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { + s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID}) return s.result, s.err } type withdrawQuotaResponse struct { - Code int `json:"code"` - Reason string `json:"reason"` - Data *service.AffiliateWithdrawResult `json:"data"` + Code int `json:"code"` + Reason string `json:"reason"` + Data json.RawMessage `json:"data"` } -func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) { +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) { t.Helper() gin.SetMode(gin.TestMode) router := gin.New() @@ -45,61 +48,125 @@ func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, bod req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var resp withdrawQuotaResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - return rec.Code, resp + return rec, resp } -// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与 -// 舍入后的金额交给仓储,并返回登记结果。 +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、 +// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。 func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} - status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`) + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`) - require.Equal(t, http.StatusOK, status) + require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, 0, resp.Code) - require.NotNil(t, resp.Data) - require.Equal(t, int64(9), resp.Data.LedgerID) - require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls) + var data service.AffiliateWithdrawResult + require.NoError(t, json.Unmarshal(resp.Data, &data)) + require.Equal(t, int64(9), data.LedgerID) + require.Empty(t, rec.Header().Get("X-Idempotency-Replayed")) + + require.Len(t, repo.calls, 1) + require.Equal(t, int64(42), repo.calls[0].userID) + require.Equal(t, 12.34567891, repo.calls[0].amount) + require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID) } -// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON -// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个 +// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。 +func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}} + for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} { + rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`) + require.Equal(t, http.StatusOK, rec.Code) + } + + require.Len(t, repo.calls, 3) + require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID) + require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID) +} + +// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时 +// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。 +func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{ + LedgerID: 9, + UserID: 42, + Amount: 10, + AvailableQuotaAfter: 90, + HistoryQuotaAfter: 100, + Replayed: true, + }} + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed")) + require.JSONEq(t, `{ + "ledger_id": 9, + "user_id": 42, + "amount": 10, + "available_quota_after": 90, + "frozen_quota_after": 0, + "history_quota_after": 100 + }`, string(resp.Data)) +} + +// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、 +// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。 func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) { cases := []struct { name string userID string + key string body string wantReason string }{ - {name: "non-numeric user id", userID: "abc", body: `{"amount":1}`}, - {name: "non-positive user id", userID: "0", body: `{"amount":1}`}, - {name: "malformed json", userID: "42", body: `{"amount":`}, - {name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, - {name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`}, + {name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`}, + {name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`}, + {name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"}, + {name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"}, + {name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { repo := &withdrawQuotaRepoStub{} - status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body) + rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body) - require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, http.StatusBadRequest, rec.Code) require.Equal(t, tc.wantReason, resp.Reason) require.Empty(t, repo.calls) }) } } -// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和 -// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。 -func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) { - repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient} - status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`) +// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、 +// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。 +func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) { + cases := []struct { + err error + wantStatus int + wantReason string + }{ + {err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"}, + {err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"}, + } + for _, tc := range cases { + t.Run(tc.wantReason, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{err: tc.err} + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`) - require.Equal(t, http.StatusBadRequest, status) - require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason) - require.Len(t, repo.calls, 1) + require.Equal(t, tc.wantStatus, rec.Code) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, rec.Header().Get("X-Idempotency-Replayed")) + require.Len(t, repo.calls, 1) + }) + } } diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index 18e3ea6725..0a6b653ec6 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,7 +407,7 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in panic("unexpected TransferQuotaToBalance call") } -func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) { +func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*service.AffiliateWithdrawResult, error) { panic("unexpected WithdrawQuota call") } diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 3553600557..4e2eaaf3d4 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,19 +341,34 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } -// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以 -// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。 -// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。 -func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { +// WithdrawQuota 登记一笔线下提现。operationID 标识一次登记:同一事务内先以 +// operation_id 唯一约束写入占位流水,同标识的流水已存在时不重复扣减,用户与金额 +// 一致则返回该流水记录的结果(Replayed=true),不一致返回 +// ErrIdempotencyKeyConflict。占位成功后先解冻已到期的冻结额度,再以 +// aff_quota >= amount 为条件原子扣减并补写额度快照。可提取额度不足或用户没有 +// 返利档案时返回 ErrAffiliateQuotaInsufficient,占位随事务回滚,不占用该标识。 +func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { if userID <= 0 { return nil, service.ErrUserNotFound } if amount <= 0 { return nil, service.ErrAffiliateWithdrawAmountInvalid } + if operationID == "" { + return nil, service.ErrIdempotencyKeyRequired + } var result *service.AffiliateWithdrawResult err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + ledgerID, claimed, err := claimAffiliateWithdrawLedger(txCtx, txClient, userID, amount, operationID) + if err != nil { + return err + } + if !claimed { + result, err = findAffiliateWithdrawByOperation(txCtx, txClient, userID, amount, operationID) + return err + } + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { return fmt.Errorf("thaw before withdraw: %w", err) } @@ -380,45 +395,21 @@ WHERE user_id = $2 return err } - rows, err := txClient.QueryContext(txCtx, ` -INSERT INTO user_affiliate_ledger ( - user_id, - action, - amount, - source_user_id, - balance_after, - aff_quota_after, - aff_frozen_quota_after, - aff_history_quota_after, - created_at, - updated_at -) -VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW()) -RETURNING id`, - userID, - amount, + if _, err := txClient.ExecContext(txCtx, ` +UPDATE user_affiliate_ledger +SET balance_after = $1, + aff_quota_after = $2, + aff_frozen_quota_after = $3, + aff_history_quota_after = $4, + updated_at = NOW() +WHERE id = $5`, snapshot.BalanceAfter, snapshot.AvailableQuotaAfter, snapshot.FrozenQuotaAfter, snapshot.HistoryQuotaAfter, - ) - if err != nil { - return fmt.Errorf("insert affiliate withdraw ledger: %w", err) - } - if !rows.Next() { - _ = rows.Close() - if err := rows.Err(); err != nil { - return fmt.Errorf("insert affiliate withdraw ledger: %w", err) - } - return errors.New("insert affiliate withdraw ledger: no id returned") - } - var ledgerID int64 - if err := rows.Scan(&ledgerID); err != nil { - _ = rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err + ledgerID, + ); err != nil { + return fmt.Errorf("record affiliate withdraw snapshot: %w", err) } result = &service.AffiliateWithdrawResult{ @@ -437,6 +428,84 @@ RETURNING id`, return result, nil } +// claimAffiliateWithdrawLedger 为有返利档案的用户写入一条带 operation_id 的 +// withdraw 占位流水。同标识的流水已存在或用户没有返利档案时 claimed 为 false。 +// 同标识的并发事务在唯一索引上等待先写入者结束:先写入者提交后返回 claimed=false, +// 回滚后本事务继续写入。 +func claimAffiliateWithdrawLedger(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (int64, bool, error) { + rows, err := client.QueryContext(ctx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, operation_id, created_at, updated_at) +SELECT ua.user_id, 'withdraw', $2, $3, NOW(), NOW() +FROM user_affiliates ua +WHERE ua.user_id = $1 +ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING +RETURNING id`, userID, amount, operationID) + if err != nil { + return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err) + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err) + } + return 0, false, nil + } + var ledgerID int64 + if err := rows.Scan(&ledgerID); err != nil { + return 0, false, err + } + return ledgerID, true, rows.Close() +} + +// findAffiliateWithdrawByOperation 读取 operation_id 已对应的线下提现流水, +// 用户与金额一致时返回该流水记录的登记结果(Replayed=true),不一致返回 +// ErrIdempotencyKeyConflict;没有对应流水说明用户没有返利档案,返回 +// ErrAffiliateQuotaInsufficient。 +func findAffiliateWithdrawByOperation(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { + rows, err := client.QueryContext(ctx, ` +SELECT id, + user_id = $2 AND action = 'withdraw' AND amount = CAST($3 AS DECIMAL(20,8)), + user_id, + amount::double precision, + COALESCE(aff_quota_after, 0)::double precision, + COALESCE(aff_frozen_quota_after, 0)::double precision, + COALESCE(aff_history_quota_after, 0)::double precision +FROM user_affiliate_ledger +WHERE operation_id = $1`, operationID, userID, amount) + if err != nil { + return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err) + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err) + } + return nil, service.ErrAffiliateQuotaInsufficient + } + var sameRequest bool + result := &service.AffiliateWithdrawResult{Replayed: true} + if err := rows.Scan( + &result.LedgerID, + &sameRequest, + &result.UserID, + &result.Amount, + &result.AvailableQuotaAfter, + &result.FrozenQuotaAfter, + &result.HistoryQuotaAfter, + ); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + if !sameRequest { + return nil, service.ErrIdempotencyKeyConflict + } + return result, nil +} + func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { if limit <= 0 { limit = 100 diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index f74da3da6f..c68482dd75 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -4,7 +4,10 @@ package repository import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "sync" "testing" "time" @@ -439,6 +442,25 @@ VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, hist require.NoError(t, err) } +// mustCreateCommittedAffiliateWithdrawUser 在测试事务之外建用户与返利档案, +// 测试结束时删除(级联返利档案与流水)。WithdrawQuota 在这些用例里自行开启 +// 并提交事务,与生产调用一致。 +func mustCreateCommittedAffiliateWithdrawUser(t *testing.T, label string, quota, frozen, history float64) *service.User { + t.Helper() + client := testEntClient(t) + u := mustCreateAffiliateWithdrawUser(t, client, label) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", u.ID) + }) + mustSeedAffiliateQuota(t, context.Background(), client, u.ID, quota, frozen, history) + return u +} + +func newAffiliateWithdrawOperationID(label string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", label, time.Now().UnixNano()))) + return hex.EncodeToString(sum[:]) +} + // TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径: // 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。 func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) { @@ -450,10 +472,12 @@ func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) u := mustCreateAffiliateWithdrawUser(t, client, "user") mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30) + operationID := newAffiliateWithdrawOperationID("deduct") - result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5) + result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5, operationID) require.NoError(t, err) require.Positive(t, result.LedgerID) + require.False(t, result.Replayed) require.InDelta(t, 12.5, result.Amount, 1e-9) require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9) require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) @@ -484,6 +508,14 @@ func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9) require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9) require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9) + + rows, err := client.QueryContext(txCtx, "SELECT operation_id FROM user_affiliate_ledger WHERE id = $1", result.LedgerID) + require.NoError(t, err) + defer func() { _ = rows.Close() }() + require.True(t, rows.Next()) + var storedOperationID string + require.NoError(t, rows.Scan(&storedOperationID)) + require.Equal(t, operationID, storedOperationID) } // TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期 @@ -502,7 +534,7 @@ INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, create VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) require.NoError(t, err) - result, err := repo.WithdrawQuota(txCtx, u.ID, 10) + result, err := repo.WithdrawQuota(txCtx, u.ID, 10, newAffiliateWithdrawOperationID("thaw")) require.NoError(t, err) require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) @@ -511,30 +543,225 @@ VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) } // TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的 -// 冻结额度不可提现,额度不足时不扣减、不写流水。 +// 冻结额度不可提现:额度不足时不扣减、不写流水,占位随事务回滚、不占用该 +// operation_id,同一标识改成可提取范围内的金额仍可登记;没有返利档案的用户 +// 同样按额度不足处理。 func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) { ctx := context.Background() - tx := testEntTx(t) - txCtx := dbent.NewTxContext(ctx, tx) - client := tx.Client() + client := testEntClient(t) repo := NewAffiliateRepository(client, integrationDB) - u := mustCreateAffiliateWithdrawUser(t, client, "insufficient") - mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15) - _, err := client.ExecContext(txCtx, ` + u := mustCreateCommittedAffiliateWithdrawUser(t, "insufficient", 5, 10, 15) + _, err := integrationDB.ExecContext(ctx, ` INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID) require.NoError(t, err) + operationID := newAffiliateWithdrawOperationID("insufficient") - _, err = repo.WithdrawQuota(txCtx, u.ID, 6) + _, err = repo.WithdrawQuota(ctx, u.ID, 6, operationID) require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) - require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client, + require.InDelta(t, 5.0, querySingleFloat(t, ctx, client, "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) - require.InDelta(t, 10.0, querySingleFloat(t, txCtx, client, + require.InDelta(t, 10.0, querySingleFloat(t, ctx, client, "SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) - require.Equal(t, 0, querySingleInt(t, txCtx, client, + require.Equal(t, 0, querySingleInt(t, ctx, client, "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + + result, err := repo.WithdrawQuota(ctx, u.ID, 5, operationID) + require.NoError(t, err) + require.False(t, result.Replayed) + require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) + + noProfile := mustCreateAffiliateWithdrawUser(t, client, "no-profile") + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", noProfile.ID) + }) + _, err = repo.WithdrawQuota(ctx, noProfile.ID, 1, newAffiliateWithdrawOperationID("no-profile")) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + require.Equal(t, 0, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1", noProfile.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult 复现 +// 「提交成功但响应丢失后重试」:首次登记已在独立事务中提交,以同一 operation_id +// 重试不重复扣减、不重复写流水,返回首次登记的流水与额度快照。两笔合法的等额打款 +// 使用不同的 operation_id,各扣一次。 +func TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "retry", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("retry") + + first, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + require.False(t, first.Replayed) + + retry, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + require.True(t, retry.Replayed) + require.Equal(t, first.LedgerID, retry.LedgerID) + require.Equal(t, u.ID, retry.UserID) + require.InDelta(t, 10.0, retry.Amount, 1e-9) + require.InDelta(t, 90.0, retry.AvailableQuotaAfter, 1e-9) + require.InDelta(t, first.FrozenQuotaAfter, retry.FrozenQuotaAfter, 1e-9) + require.InDelta(t, 100.0, retry.HistoryQuotaAfter, 1e-9) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + + second, err := repo.WithdrawQuota(ctx, u.ID, 10, newAffiliateWithdrawOperationID("retry-second")) + require.NoError(t, err) + require.False(t, second.Replayed) + require.NotEqual(t, first.LedgerID, second.LedgerID) + require.InDelta(t, 80.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 2, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration +// 固定「并发同键」的交错:首个登记已占位并扣减但未提交时,同一 operation_id 的 +// 请求在唯一索引上等待。首个登记提交后,等待的请求不重复扣减,返回同一条流水; +// 首个登记回滚后,等待的请求自行完成登记。两种结局都只扣一次。 +func TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration(t *testing.T) { + for _, commitFirst := range []bool{true, false} { + name := "first rolls back" + if commitFirst { + name = "first commits" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "inflight", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("inflight") + + tx, err := client.Tx(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = tx.Rollback() }) + first, err := repo.WithdrawQuota(dbent.NewTxContext(ctx, tx), u.ID, 10, operationID) + require.NoError(t, err) + + type outcome struct { + result *service.AffiliateWithdrawResult + err error + } + waiting := make(chan outcome, 1) + go func() { + result, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + waiting <- outcome{result: result, err: err} + }() + + select { + case got := <-waiting: + t.Fatalf("same operation must wait for the in-flight registration, got result=%+v err=%v", got.result, got.err) + case <-time.After(300 * time.Millisecond): + } + + if commitFirst { + require.NoError(t, tx.Commit()) + } else { + require.NoError(t, tx.Rollback()) + } + + var got outcome + select { + case got = <-waiting: + case <-time.After(10 * time.Second): + t.Fatal("waiting registration did not finish after the in-flight one ended") + } + require.NoError(t, got.err) + if commitFirst { + require.True(t, got.result.Replayed) + require.Equal(t, first.LedgerID, got.result.LedgerID) + } else { + require.False(t, got.result.Replayed) + require.NotEqual(t, first.LedgerID, got.result.LedgerID) + } + require.InDelta(t, 90.0, got.result.AvailableQuotaAfter, 1e-9) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + }) + } +} + +// TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce 让多个请求 +// 同时以同一 operation_id 登记:只有一个执行扣减,其余返回同一条流水。 +func TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "concurrent", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("concurrent") + + const workers = 8 + results := make([]*service.AffiliateWithdrawResult, workers) + errs := make([]error, workers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + results[i], errs[i] = repo.WithdrawQuota(ctx, u.ID, 10, operationID) + }(i) + } + close(start) + wg.Wait() + + executed := 0 + for i := 0; i < workers; i++ { + require.NoError(t, errs[i]) + require.Equal(t, results[0].LedgerID, results[i].LedgerID) + require.InDelta(t, 90.0, results[i].AvailableQuotaAfter, 1e-9) + if !results[i].Replayed { + executed++ + } + } + require.Equal(t, 1, executed) + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest 验证 +// 同一 operation_id 换了金额或用户时以 ErrIdempotencyKeyConflict 拒绝, +// 不扣减、不写流水。 +func TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "conflict", 100, 0, 100) + other := mustCreateCommittedAffiliateWithdrawUser(t, "conflict-other", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("conflict") + + _, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + + _, err = repo.WithdrawQuota(ctx, u.ID, 11, operationID) + require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict) + _, err = repo.WithdrawQuota(ctx, other.ID, 10, operationID) + require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 100.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", other.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id IN ($1, $2) AND action = 'withdraw'", u.ID, other.ID)) } // TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码 diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index b3221a1ce8..a9a2016678 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -51,3 +51,25 @@ func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) { require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')") } + +// TestAffiliateWithdrawClaimsOperationBeforeDeducting 锁定线下提现的幂等形态: +// 同一事务内先按 operation_id 唯一约束写入占位流水,冲突时不扣减, +// 且唯一约束由迁移建立。 +func TestAffiliateWithdrawClaimsOperationBeforeDeducting(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING") + withdraw := content[strings.Index(content, "func (r *affiliateRepository) WithdrawQuota("):] + withdraw = withdraw[:strings.Index(withdraw, "\n}\n")] + claimAt := strings.Index(withdraw, "claimAffiliateWithdrawLedger(") + deductAt := strings.Index(withdraw, "SET aff_quota = aff_quota - $1") + require.Positive(t, claimAt) + require.Greater(t, deductAt, claimAt, "operation claim must precede the quota deduction") + + migration, err := os.ReadFile("../../migrations/240_affiliate_ledger_operation_id.sql") + require.NoError(t, err) + require.Contains(t, string(migration), "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id") + require.Contains(t, string(migration), "WHERE operation_id IS NOT NULL") +} diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 2c6423c787..f48156e995 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "math" "strings" @@ -105,7 +107,7 @@ type AffiliateRepository interface { GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) - WithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) + WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -202,6 +204,7 @@ type AffiliateTransferRecord struct { } // AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。 type AffiliateWithdrawResult struct { LedgerID int64 `json:"ledger_id"` UserID int64 `json:"user_id"` @@ -209,6 +212,7 @@ type AffiliateWithdrawResult struct { AvailableQuotaAfter float64 `json:"available_quota_after"` FrozenQuotaAfter float64 `json:"frozen_quota_after"` HistoryQuotaAfter float64 `json:"history_quota_after"` + Replayed bool `json:"-"` } type AffiliateUserOverview struct { @@ -582,13 +586,23 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user // AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 // 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 // 金额按返利流水精度保留 8 位小数,舍入后必须为正。 -func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { +// +// idempotencyKey 标识一次登记且必填:同一个键只扣减一次,重复请求在用户与金额 +// 一致时返回首次登记的结果,不一致时返回 ErrIdempotencyKeyConflict。 +func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64, idempotencyKey string) (*AffiliateWithdrawResult, error) { if userID <= 0 { return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") } if s == nil || s.repo == nil { return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") } + key, err := NormalizeIdempotencyKey(idempotencyKey) + if err != nil { + return nil, err + } + if key == "" { + return nil, ErrIdempotencyKeyRequired + } if math.IsNaN(amount) || math.IsInf(amount, 0) { return nil, ErrAffiliateWithdrawAmountInvalid } @@ -596,7 +610,14 @@ func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, if amount <= 0 || math.IsInf(amount, 0) { return nil, ErrAffiliateWithdrawAmountInvalid } - return s.repo.WithdrawQuota(ctx, userID, amount) + return s.repo.WithdrawQuota(ctx, userID, amount, affiliateWithdrawOperationID(key)) +} + +// affiliateWithdrawOperationID 把线下提现登记的幂等键映射为流水的 operation_id, +// 同一个键始终得到同一个值。 +func affiliateWithdrawOperationID(idempotencyKey string) string { + sum := sha256.Sum256([]byte("admin.affiliates.withdraw\x00" + idempotencyKey)) + return hex.EncodeToString(sum[:]) } // AdminListCustomUsers 列出有专属配置的用户。 diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index 1e3f0b6c59..b3d92039a5 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -5,6 +5,7 @@ package service import ( "context" "math" + "strings" "testing" "github.com/stretchr/testify/require" @@ -131,8 +132,9 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { } type affiliateWithdrawCall struct { - userID int64 - amount float64 + userID int64 + amount float64 + operationID string } type affiliateWithdrawRepoStub struct { @@ -142,11 +144,13 @@ type affiliateWithdrawRepoStub struct { err error } -func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { - s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount}) +func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) { + s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount, operationID: operationID}) return s.result, s.err } +const testAffiliateWithdrawKey = "affiliate-withdraw-7-3f2b8c1e" + // TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、 // NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。 func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { @@ -154,7 +158,7 @@ func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} { repo := &affiliateWithdrawRepoStub{} svc := &AffiliateService{repo: repo} - _, err := svc.AdminWithdrawQuota(context.Background(), 1, amount) + _, err := svc.AdminWithdrawQuota(context.Background(), 1, amount, testAffiliateWithdrawKey) require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount) require.Empty(t, repo.calls, "amount %v must not reach repository", amount) } @@ -165,11 +169,55 @@ func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) { t.Parallel() repo := &affiliateWithdrawRepoStub{} svc := &AffiliateService{repo: repo} - _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1) + _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1, testAffiliateWithdrawKey) require.Error(t, err) require.Empty(t, repo.calls) } +// TestAdminWithdrawQuota_RequiresValidIdempotencyKey 验证幂等键必填且须为 +// 可见 ASCII、不超过 128 字符;不合格的键不进入仓储。 +func TestAdminWithdrawQuota_RequiresValidIdempotencyKey(t *testing.T) { + t.Parallel() + cases := []struct { + key string + want error + }{ + {key: "", want: ErrIdempotencyKeyRequired}, + {key: " ", want: ErrIdempotencyKeyRequired}, + {key: "has space", want: ErrIdempotencyKeyInvalid}, + {key: "登记", want: ErrIdempotencyKeyInvalid}, + {key: strings.Repeat("k", 129), want: ErrIdempotencyKeyInvalid}, + } + for _, tc := range cases { + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, tc.key) + require.ErrorIs(t, err, tc.want, "key %q", tc.key) + require.Empty(t, repo.calls, "key %q must not reach repository", tc.key) + } +} + +// TestAdminWithdrawQuota_DerivesStableOperationID 验证仓储收到的 operation_id +// 由幂等键确定性派生:同一个键(含首尾空白)得到同一个值,不同的键得到不同的值, +// 且不是原始键本身。 +func TestAdminWithdrawQuota_DerivesStableOperationID(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{result: &AffiliateWithdrawResult{LedgerID: 1}} + svc := &AffiliateService{repo: repo} + + for _, key := range []string{testAffiliateWithdrawKey, " " + testAffiliateWithdrawKey + " ", testAffiliateWithdrawKey + "-2"} { + _, err := svc.AdminWithdrawQuota(context.Background(), 42, 10, key) + require.NoError(t, err) + } + + require.Len(t, repo.calls, 3) + first := repo.calls[0].operationID + require.Regexp(t, `^[0-9a-f]{64}$`, first) + require.NotEqual(t, testAffiliateWithdrawKey, first) + require.Equal(t, first, repo.calls[1].operationID) + require.NotEqual(t, first, repo.calls[2].operationID) +} + // TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后 // 交给仓储,仓储结果原样返回。 func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { @@ -178,18 +226,22 @@ func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { repo := &affiliateWithdrawRepoStub{result: want} svc := &AffiliateService{repo: repo} - got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149) + got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey) require.NoError(t, err) require.Same(t, want, got) - require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls) + require.Len(t, repo.calls, 1) + require.Equal(t, int64(42), repo.calls[0].userID) + require.Equal(t, 12.34567891, repo.calls[0].amount) } -// TestAdminWithdrawQuota_PropagatesInsufficientQuota 验证额度不足由仓储判定, -// 服务层原样返回,前端据错误码提示。 -func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) { +// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数 +// 由仓储判定,服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) { t.Parallel() - repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient} - svc := &AffiliateService{repo: repo} - _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1) - require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient) + for _, want := range []error{ErrAffiliateQuotaInsufficient, ErrIdempotencyKeyConflict} { + repo := &affiliateWithdrawRepoStub{err: want} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, testAffiliateWithdrawKey) + require.ErrorIs(t, err, want) + } } diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index f604e811f9..0a7d1b8895 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,7 +150,7 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con panic("unexpected TransferQuotaToBalance call") } -func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*AffiliateWithdrawResult, error) { +func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) { panic("unexpected WithdrawQuota call") } diff --git a/backend/migrations/240_affiliate_ledger_operation_id.sql b/backend/migrations/240_affiliate_ledger_operation_id.sql new file mode 100644 index 0000000000..5a97f41821 --- /dev/null +++ b/backend/migrations/240_affiliate_ledger_operation_id.sql @@ -0,0 +1,10 @@ +-- 管理员登记线下提现的幂等标识:同一 operation_id 只对应一条流水, +-- 重试同一次登记时据此返回首次登记的结果,不重复扣减。 +ALTER TABLE user_affiliate_ledger + ADD COLUMN IF NOT EXISTS operation_id VARCHAR(64); + +COMMENT ON COLUMN user_affiliate_ledger.operation_id IS '管理员登记线下提现的幂等标识(由 Idempotency-Key 派生的 SHA-256 十六进制串);其他流水为 NULL'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id + ON user_affiliate_ledger (operation_id) + WHERE operation_id IS NOT NULL; diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index 8cc736bce7..570bbf1fe7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -101,6 +101,12 @@ export interface AffiliateWithdrawResult { history_quota_after: number } +export interface AffiliateWithdrawResponse { + result: AffiliateWithdrawResult + /** True when the key matched an earlier registration and nothing was deducted again. */ + replayed: boolean +} + export interface AffiliateUserOverview { user_id: number email: string @@ -239,15 +245,25 @@ export async function getUserOverview( return data } +/** + * Records an offline withdrawal. `idempotencyKey` identifies one registration: + * a retry with the same key deducts nothing and returns the first result, with + * `replayed` set. + */ export async function withdrawUserQuota( userId: number, payload: WithdrawAffiliateQuotaRequest, -): Promise { - const { data } = await apiClient.post( + idempotencyKey: string, +): Promise { + const response = await apiClient.post( `/admin/affiliates/users/${userId}/withdraw`, payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, ) - return data + return { + result: response.data, + replayed: response.headers?.['x-idempotency-replayed'] === 'true', + } } export const affiliatesAPI = { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 51bd13a6ac..d469c778ef 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -443,6 +443,8 @@ export default { submit: 'Confirm', submitting: 'Recording...', success: 'Recorded offline withdrawal of {amount}; {remaining} still available', + replayed: 'This offline withdrawal of {amount} was already recorded and was not deducted again; {remaining} was left available after it', + uncertainHint: 'The last submission returned no result and may already be recorded. The user and amount are locked; submitting again retries the same registration and never deducts twice.', amountRequired: 'Enter an amount greater than 0', amountExceeds: 'Amount cannot exceed the available quota' }, diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index abc2b0c8d7..95396dea1d 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -443,6 +443,8 @@ export default { submit: '确认登记', submitting: '登记中...', success: '已登记线下提现 {amount},剩余可提取 {remaining}', + replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}', + uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。', amountRequired: '请输入大于 0 的金额', amountExceeds: '提现金额不能超过当前可提取额度' }, diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue index 8d3cf67979..a9fe09d7b5 100644 --- a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -22,7 +22,7 @@ type="button" class="text-lg leading-none text-gray-400 hover:text-red-600 disabled:opacity-50" :title="t('admin.affiliates.withdraw.changeUser')" - :disabled="submitting" + :disabled="submitting || outcomeUncertain" data-test="withdraw-clear-user" @click="clearUser" > @@ -84,13 +84,13 @@ step="any" inputmode="decimal" class="input" - :disabled="!selectedUser || overviewLoading || submitting" + :disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain" data-test="withdraw-amount" />
+
+ {{ t('admin.affiliates.withdraw.uncertainHint') }} +
+
{{ t('admin.affiliates.withdraw.warning') }}
@@ -132,6 +140,11 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import { useAppStore } from '@/stores/app' import { affiliatesAPI, type AffiliateWithdrawResult, type SimpleUser } from '@/api/admin/affiliates' import { extractApiErrorCode, extractI18nErrorMessage } from '@/utils/apiError' +import { + completeAffiliateWithdrawOperation, + prepareAffiliateWithdrawOperation, + type AffiliateWithdrawOperation, +} from './affiliateWithdrawOperation' const props = defineProps<{ show: boolean @@ -156,6 +169,9 @@ const availableQuota = ref(0) const overviewLoading = ref(false) const amount = ref('') const submitting = ref(false) +// 已提交、尚未拿到确定结果的登记。结果未知时锁定用户与金额,重试沿用它的幂等键。 +const pendingOperation = ref(null) +const outcomeUncertain = computed(() => pendingOperation.value?.outcomeUncertain === true) let searchTimer: ReturnType | null = null let searchSeq = 0 @@ -165,7 +181,7 @@ const amountText = computed(() => String(amount.value ?? '').trim()) const parsedAmount = computed(() => (amountText.value === '' ? Number.NaN : Number(amountText.value))) const amountError = computed(() => { - if (!selectedUser.value || overviewLoading.value || amountText.value === '') return '' + if (outcomeUncertain.value || !selectedUser.value || overviewLoading.value || amountText.value === '') return '' const value = parsedAmount.value if (!Number.isFinite(value) || Math.round(value * LEDGER_SCALE) <= 0) { return t('admin.affiliates.withdraw.amountRequired') @@ -176,14 +192,12 @@ const amountError = computed(() => { return '' }) -const canSubmit = computed( - () => - selectedUser.value !== null && - !overviewLoading.value && - !submitting.value && - amountText.value !== '' && - amountError.value === '', -) +// 结果未知的登记可能已经扣减,重试不受刷新后的可提取额度约束。 +const canSubmit = computed(() => { + if (selectedUser.value === null || submitting.value) return false + if (outcomeUncertain.value) return true + return !overviewLoading.value && amountText.value !== '' && amountError.value === '' +}) function formatPreciseAmount(value: number): string { const [intPart, fraction = ''] = Number(value || 0).toFixed(8).replace(/0+$/, '').split('.') @@ -240,7 +254,7 @@ async function loadAvailableQuota(userId: number) { availableQuota.value = 0 // 从未产生过邀请返利的用户没有返利档案,概览接口返回 USER_NOT_FOUND,可提取额度即为 0。 if (extractApiErrorCode(error) === 'USER_NOT_FOUND') return - selectedUser.value = null + if (!outcomeUncertain.value) selectedUser.value = null showError(error) } finally { if (seq === overviewSeq) overviewLoading.value = false @@ -283,6 +297,7 @@ function resetForm() { overviewLoading.value = false amount.value = '' submitting.value = false + pendingOperation.value = null } function handleClose() { @@ -293,11 +308,21 @@ function handleClose() { async function submit() { const user = selectedUser.value if (!user || !canSubmit.value) return + if (!pendingOperation.value) { + pendingOperation.value = prepareAffiliateWithdrawOperation(user.id, parsedAmount.value) + } + const operation = pendingOperation.value submitting.value = true try { - const result = await affiliatesAPI.withdrawUserQuota(user.id, { amount: parsedAmount.value }) + const { result, replayed } = await affiliatesAPI.withdrawUserQuota( + operation.userId, + { amount: operation.amount }, + operation.key, + ) + completeAffiliateWithdrawOperation(operation) + pendingOperation.value = null appStore.showSuccess( - t('admin.affiliates.withdraw.success', { + t(replayed ? 'admin.affiliates.withdraw.replayed' : 'admin.affiliates.withdraw.success', { amount: `$${formatPreciseAmount(result.amount)}`, remaining: `$${formatPreciseAmount(result.available_quota_after)}`, }), @@ -305,7 +330,16 @@ async function submit() { emit('success', result) } catch (error) { showError(error) - void loadAvailableQuota(user.id) + // 网络错误、超时、5xx 与 408/409 时服务端可能已经登记:保留这笔登记, + // 重试沿用同一请求与幂等键,直到拿到确定结果。 + const status = (error as { status?: number } | null)?.status + if (!operation.outcomeUncertain && status && status >= 400 && status < 500 && status !== 408 && status !== 409) { + completeAffiliateWithdrawOperation(operation) + pendingOperation.value = null + } else { + operation.outcomeUncertain = true + } + void loadAvailableQuota(operation.userId) } finally { submitting.value = false } diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts index f6aac2570a..d1b5b2e722 100644 --- a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -43,9 +43,8 @@ function mountDialog() { }) } -async function pickUser(wrapper: VueWrapper, availableQuota: number) { - lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) - getUserOverview.mockResolvedValue({ +function overviewWithQuota(availableQuota: number) { + return { user_id: 42, email: 'inviter@example.com', username: 'inviter', @@ -55,7 +54,26 @@ async function pickUser(wrapper: VueWrapper, availableQuota: number) { rebated_invitee_count: 2, available_quota: availableQuota, history_quota: 50, - }) + } +} + +function withdrawResponse(amount: number, availableAfter: number, replayed = false) { + return { + result: { + ledger_id: 9, + user_id: 42, + amount, + available_quota_after: availableAfter, + frozen_quota_after: 0, + history_quota_after: 50, + }, + replayed, + } +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue(overviewWithQuota(availableQuota)) await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter') vi.advanceTimersByTime(300) await flushPromises() @@ -84,18 +102,11 @@ describe('AffiliateOfflineWithdrawDialog', () => { await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891') - withdrawUserQuota.mockResolvedValue({ - ledger_id: 9, - user_id: 42, - amount: 12.34567891, - available_quota_after: 0, - frozen_quota_after: 0, - history_quota_after: 50, - }) + withdrawUserQuota.mockResolvedValue(withdrawResponse(12.34567891, 0)) await wrapper.get('[data-test="withdraw-submit"]').trigger('click') await flushPromises() - expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }) + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }, expect.stringMatching(/^affiliate-withdraw-/)) expect(showSuccess).toHaveBeenCalledWith( 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', ) @@ -140,7 +151,7 @@ describe('AffiliateOfflineWithdrawDialog', () => { const wrapper = mountDialog() await pickUser(wrapper, 10) - withdrawUserQuota.mockRejectedValue({ reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + withdrawUserQuota.mockRejectedValue({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) await wrapper.get('[data-test="withdraw-amount"]').setValue('5') await wrapper.get('[data-test="withdraw-submit"]').trigger('click') await flushPromises() @@ -149,4 +160,66 @@ describe('AffiliateOfflineWithdrawDialog', () => { expect(getUserOverview).toHaveBeenCalledTimes(2) expect(wrapper.emitted('success')).toBeUndefined() }) + + it.each([ + ['a network error', { status: 0, message: 'Network error. Please check your connection.' }], + ['a gateway error', { status: 502, message: 'Bad Gateway' }], + ['a request timeout', { status: 408, message: 'Request Timeout' }], + ])('retries the same registration with the same key after %s', async (_label, failure) => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + + // 服务端其实已登记,只是响应丢失:刷新后的可提取额度已扣到 0。 + withdrawUserQuota.mockRejectedValueOnce(failure) + getUserOverview.mockResolvedValue(overviewWithQuota(0)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-uncertain-hint"]').text()).toBe('admin.affiliates.withdraw.uncertainHint') + expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="withdraw-clear-user"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-test="withdraw-amount-error"]').exists()).toBe(false) + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeUndefined() + + withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(10, 0, true)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledTimes(2) + const [firstCall, retryCall] = withdrawUserQuota.mock.calls + expect(firstCall).toEqual([42, { amount: 10 }, expect.stringMatching(/^affiliate-withdraw-/)]) + expect(retryCall).toEqual(firstCall) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.replayed {"amount":"$10.00","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')).toHaveLength(1) + }) + + it('starts a new registration with a fresh key after a definite rejection', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + await wrapper.get('[data-test="withdraw-amount"]').setValue('6') + + withdrawUserQuota.mockRejectedValueOnce({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-test="withdraw-uncertain-hint"]').exists()).toBe(false) + expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(5, 5)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledTimes(2) + const [rejectedCall, nextCall] = withdrawUserQuota.mock.calls + expect(nextCall[1]).toEqual({ amount: 5 }) + expect(nextCall[2]).not.toBe(rejectedCall[2]) + expect(showSuccess).toHaveBeenCalledWith('admin.affiliates.withdraw.success {"amount":"$5.00","remaining":"$5.00"}') + }) }) diff --git a/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts new file mode 100644 index 0000000000..99424f1ef0 --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { completeAffiliateWithdrawOperation, prepareAffiliateWithdrawOperation } from '../affiliateWithdrawOperation' + +let adminId = 2000 + +beforeEach(() => { + localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId })) + sessionStorage.clear() +}) + +describe('affiliate offline withdrawal operation identity', () => { + it('keeps the key of an unfinished registration for the same user and ledger amount', () => { + const first = prepareAffiliateWithdrawOperation(42, 10) + const retry = prepareAffiliateWithdrawOperation(42, 10.000000001) + + expect(first.outcomeUncertain).toBe(false) + expect(first.key).toMatch(new RegExp(`^affiliate-withdraw-${adminId}-`)) + expect(retry.key).toBe(first.key) + expect(retry.amount).toBe(10) + expect(retry.outcomeUncertain).toBe(true) + + completeAffiliateWithdrawOperation(retry) + const next = prepareAffiliateWithdrawOperation(42, 10) + expect(next.key).not.toBe(first.key) + expect(next.outcomeUncertain).toBe(false) + }) + + it('isolates different users, amounts and administrators', () => { + const first = prepareAffiliateWithdrawOperation(42, 10) + const differentAmount = prepareAffiliateWithdrawOperation(42, 10.5) + const differentUser = prepareAffiliateWithdrawOperation(43, 10) + localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId })) + const differentAdmin = prepareAffiliateWithdrawOperation(42, 10) + + expect(new Set([first, differentAmount, differentUser, differentAdmin].map(operation => operation.key)).size).toBe(4) + }) + + it('resumes a key stored by an earlier page and clears it after completion', () => { + const scope = `sub2api:admin:affiliate-withdraw:${adminId}:42:12.5` + sessionStorage.setItem(scope, 'saved-withdraw-key') + + const resumed = prepareAffiliateWithdrawOperation(42, 12.5) + expect(resumed.key).toBe('saved-withdraw-key') + expect(resumed.outcomeUncertain).toBe(true) + + completeAffiliateWithdrawOperation(resumed) + expect(sessionStorage.getItem(scope)).toBeNull() + expect(prepareAffiliateWithdrawOperation(42, 12.5).key).not.toBe('saved-withdraw-key') + }) +}) diff --git a/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts new file mode 100644 index 0000000000..ab3f9bbe64 --- /dev/null +++ b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts @@ -0,0 +1,68 @@ +/** 一笔线下提现登记及标识它的幂等键。 */ +export interface AffiliateWithdrawOperation { + userId: number + amount: number + key: string + storageKey: string | null + outcomeUncertain: boolean +} + +// 返利额度按 8 位小数记账。 +const LEDGER_SCALE = 1e8 + +const pendingKeys = new Map() + +function currentAdminId(): number | null { + try { + const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null + const id = user?.id + return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null + } catch { + return null + } +} + +function readStoredKey(storageKey: string): string | null { + try { + return globalThis.sessionStorage?.getItem(storageKey) ?? null + } catch { + return null + } +} + +function storeKey(storageKey: string, key: string | null) { + try { + if (key) globalThis.sessionStorage?.setItem(storageKey, key) + else globalThis.sessionStorage?.removeItem(storageKey) + } catch { + // 浏览器存储不可用时,同一页面内的重试仍由内存中的键保护。 + } +} + +/** + * 取得该用户与金额对应的登记。结果未知的登记在完成前一直保留它的键, + * 再次提交同一用户与金额时沿用该键,服务端据此返回首次结果而不重复扣减。 + */ +export function prepareAffiliateWithdrawOperation(userId: number, amount: number): AffiliateWithdrawOperation { + const normalizedAmount = Math.round(amount * LEDGER_SCALE) / LEDGER_SCALE + const adminId = currentAdminId() + const storageKey = adminId ? `sub2api:admin:affiliate-withdraw:${adminId}:${userId}:${normalizedAmount}` : null + let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null + const outcomeUncertain = !!key + if (!key) { + const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + key = `affiliate-withdraw-${adminId ?? 'unknown'}-${requestId}` + } + if (storageKey) { + pendingKeys.set(storageKey, key) + storeKey(storageKey, key) + } + return { userId, amount: normalizedAmount, key, storageKey, outcomeUncertain } +} + +/** 登记有了确定结果后释放它的键。 */ +export function completeAffiliateWithdrawOperation(operation: AffiliateWithdrawOperation) { + if (!operation.storageKey) return + pendingKeys.delete(operation.storageKey) + storeKey(operation.storageKey, null) +} From c4c2e660704dbde070f445beac3808cc829880c1 Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:21:53 +0800 Subject: [PATCH 03/21] feat(models): support GPT-6 Sol, Luna and Claude Opus 5.5 --- .../gpt6-sol-luna-opus55-model-selector.jpg | Bin 0 -> 35773 bytes .../internal/handler/gateway_models_test.go | 31 ++++ .../pkg/apicompat/anthropic_responses_test.go | 66 ++++++++ .../pkg/apicompat/anthropic_to_responses.go | 4 +- .../anthropic_to_responses_response.go | 53 ++++++- .../apicompat/chatcompletions_to_responses.go | 40 +++-- ...esponses_to_anthropic_instructions_test.go | 6 +- .../responses_to_anthropic_request.go | 60 ++++++- ...esponses_to_anthropic_tool_pairing_test.go | 2 +- backend/internal/pkg/apicompat/types.go | 19 ++- backend/internal/pkg/claude/constants.go | 6 + .../pkg/claude/constants_model_test.go | 12 ++ backend/internal/pkg/claude/effort_catalog.go | 6 + backend/internal/pkg/openai/constants.go | 20 +++ backend/internal/pkg/openai/constants_test.go | 10 ++ backend/internal/service/billing_service.go | 65 +++++++- .../internal/service/billing_service_test.go | 106 +++++++++++++ ...teway_anthropic_apikey_passthrough_test.go | 42 +++++ .../service/gateway_claude_oauth_body.go | 4 +- .../internal/service/gateway_count_tokens.go | 11 ++ backend/internal/service/gateway_forward.go | 11 ++ .../gateway_forward_as_chat_completions.go | 28 ++-- .../service/gateway_forward_as_responses.go | 34 ++-- .../gateway_forward_as_responses_test.go | 74 +++++++++ backend/internal/service/gateway_request.go | 34 +++- .../service/openai_codex_models_service.go | 18 ++- .../openai_codex_models_service_test.go | 19 +++ .../service/openai_codex_transform.go | 4 + .../service/openai_compat_prompt_cache_key.go | 6 +- .../openai_compat_prompt_cache_key_test.go | 9 ++ .../openai_gateway_chat_completions.go | 5 + ...teway_chat_completions_anthropic_native.go | 19 ++- .../openai_gateway_chat_completions_raw.go | 6 + .../openai_gateway_chat_completions_test.go | 113 +++++++++++++ .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_messages.go | 1 + .../service/openai_gateway_request_body.go | 57 ++++++- ...enai_gateway_responses_anthropic_native.go | 26 ++- .../internal/service/openai_model_alias.go | 11 ++ .../openai_passthrough_normalization_test.go | 6 +- backend/internal/service/pricing_service.go | 64 ++++++++ .../model_prices_and_context_window.json | 148 ++++++++++++++++++ .../account/AccountStatusIndicator.vue | 1 + frontend/src/components/keys/UseKeyModal.vue | 51 ++++++ .../keys/__tests__/UseKeyModal.spec.ts | 20 ++- .../__tests__/useModelWhitelist.spec.ts | 4 + frontend/src/composables/useModelWhitelist.ts | 6 +- 47 files changed, 1227 insertions(+), 113 deletions(-) create mode 100644 assets/gpt6-sol-luna-opus55-model-selector.jpg diff --git a/assets/gpt6-sol-luna-opus55-model-selector.jpg b/assets/gpt6-sol-luna-opus55-model-selector.jpg new file mode 100644 index 0000000000000000000000000000000000000000..71e177a556d5bff4b8bb9ab302e3e3536981b04a GIT binary patch literal 35773 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 literal 0 HcmV?d00001 diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 81df742e9b..5231f4c095 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1479,3 +1479,34 @@ func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug) } } + +func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t *testing.T) { + for _, tc := range []struct { + name string + selected []string + restricted bool + want []string + }{ + {"selected and ordered", []string{"gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6-luna", "gpt-6-sol"}}, + {"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}}, + {"account restricts new models", []string{"gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}}, + } { + t.Run(tc.name, func(t *testing.T) { + groupID := int64(25) + account := service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey} + if tc.restricted { + account.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}} + } + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {account}}}) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: tc.selected}}}) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.want, modelIDsForTest(got.Data)) + }) + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 539ccda0d7..d823393cb2 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1819,6 +1819,72 @@ func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) } +func TestOpus55ResponsesAdaptiveThinkingAndToolChoice(t *testing.T) { + for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max"} { + req := &ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}} + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Equal(t, "adaptive", out.Thinking.Type) + require.Zero(t, out.Thinking.BudgetTokens) + if effort == "" { + effort = "medium" + } + require.Equal(t, effort, out.OutputConfig.Effort) + } + for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} { + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)}) + require.ErrorContains(t, err, "forced tool_choice") + } + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "none"}}) + require.ErrorContains(t, err, "reasoning effort") + old, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "xhigh"}}) + require.NoError(t, err) + require.Equal(t, "max", old.OutputConfig.Effort) + require.Equal(t, "enabled", old.Thinking.Type) +} + +func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) { + block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "upstream-signed-block"} + response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-opus-5-5", Content: []AnthropicContentBlock{block, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}}) + require.Len(t, response.Output, 2) + require.NotEmpty(t, response.Output[0].EncryptedContent) + raw, err := json.Marshal(response.Output) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(raw, &items)) + items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[1].CallID, Output: "ok"}) + raw, err = json.Marshal(items) + require.NoError(t, err) + converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: raw}) + require.NoError(t, err) + require.Len(t, converted.Messages, 2) + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks)) + require.Equal(t, block, blocks[0]) + require.Equal(t, "tool_use", blocks[1].Type) + // Arbitrary OpenAI ciphertext must never be treated as an Anthropic signature. + _, _, err = convertResponsesInputToAnthropic("", json.RawMessage(`[{"type":"reasoning","encrypted_content":"anthropic-thinking-v1:!"}]`), true) + require.Error(t, err) +} + +func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { + temperature := 0.7 + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, effort := range []string{"", "none", "medium", "max"} { + out, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: model, ReasoningEffort: effort, Temperature: &temperature, TopP: &temperature, PromptCacheOptions: json.RawMessage(`{"mode":"explicit","ttl":"30m"}`), Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello","prompt_cache_breakpoint":{"mode":"explicit"}}]`)}}}) + require.NoError(t, err) + if effort == "none" { + require.NotNil(t, out.Temperature) + } else { + require.Nil(t, out.Temperature) + require.Nil(t, out.TopP) + } + require.JSONEq(t, `{"mode":"explicit","ttl":"30m"}`, string(out.PromptCacheOptions)) + require.Contains(t, string(out.Input), "prompt_cache_breakpoint") + } + } +} + func TestMessageStartSSE_StopReasonIsJSONNull(t *testing.T) { state := NewResponsesEventToAnthropicState() state.Model = "grok-4.5" diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index dfbe7b5e05..23acc0bea8 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) // AnthropicToResponses converts an Anthropic Messages request directly into @@ -470,7 +472,7 @@ func boolPtr(v bool) *bool { // All gpt-5.x models are reasoning-only; the Responses API returns // "Unsupported parameter: temperature" if these fields are present. func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") + return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model) } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f270050d43..f4c52fcef6 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -2,16 +2,32 @@ package apicompat import ( "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // --------------------------------------------------------------------------- // Non-streaming: AnthropicResponse → ResponsesResponse // --------------------------------------------------------------------------- +const anthropicThinkingEnvelopePrefix = "anthropic-thinking-v1:" + +func encodeAnthropicThinking(block AnthropicContentBlock) string { + // Only fields belonging to the signed thinking block are retained. + payload, _ := json.Marshal(struct { + Type string `json:"type"` + Thinking string `json:"thinking"` + Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` + }{block.Type, block.Thinking, block.Signature, block.Data}) + return anthropicThinkingEnvelopePrefix + base64.RawStdEncoding.EncodeToString(payload) +} + // AnthropicToResponsesResponse converts an Anthropic Messages response into a // Responses API response. This is the reverse of ResponsesToAnthropic and // enables Anthropic upstream responses to be returned in OpenAI Responses format. @@ -35,7 +51,15 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { for _, block := range resp.Content { switch block.Type { - case "thinking": + case "thinking", "redacted_thinking": + if claude.IsOpus55(resp.Model) && (block.Signature != "" || block.Data != "") { + item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)} + if block.Thinking != "" { + item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}} + } + outputs = append(outputs, item) + continue + } if block.Thinking != "" { outputs = append(outputs, ResponsesOutput{ Type: "reasoning", @@ -47,6 +71,10 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { }) } case "text": + if claude.IsOpus55(resp.Model) && block.Text != "" { + outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}}) + continue + } if block.Text != "" { msgParts = append(msgParts, ResponsesContentPart{ Type: "output_text", @@ -164,9 +192,11 @@ type AnthropicEventToResponsesState struct { CurrentName string // Content of the currently open item, folded into Outputs when it closes. - CurrentContent []ResponsesContentPart // message - CurrentArgs string // function_call - CurrentSummary string // reasoning + CurrentContent []ResponsesContentPart // message + CurrentArgs string // function_call + CurrentSummary string // reasoning + CurrentThinking AnthropicContentBlock + PreserveThinkingSignatures bool // Outputs accumulates every closed output item so that response.completed // can carry the full output list. The OpenAI SDK's get_final_response() @@ -248,6 +278,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) { func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent { if evt.Message != nil { state.ResponseID = evt.Message.ID + state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) if state.Model == "" { state.Model = evt.Message.Model } @@ -279,7 +310,7 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi var events []ResponsesStreamEvent switch evt.ContentBlock.Type { - case "thinking": + case "thinking", "redacted_thinking": // 开新 item 前必须先关掉在开的那个,与下面的 tool_use 分支一致。 // 一个 message item 在它的 text 块 content_block_stop 时是刻意保持打开的 // (同一 item 里可能还有后续 text 块),所以 thinking 块到来时它仍然开着: @@ -292,6 +323,8 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi state.CurrentItemID = generateItemID() state.CurrentItemType = "reasoning" + state.CurrentThinking = *evt.ContentBlock + state.CurrentSummary = evt.ContentBlock.Thinking state.ContentIndex = 0 events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ @@ -409,7 +442,10 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi })} case "signature_delta": - // Anthropic signature deltas have no Responses equivalent; skip + // Keep signatures in the opaque bridge envelope, never in visible text. + if state.PreserveThinkingSignatures { + state.CurrentThinking.Signature += evt.Delta.Signature + } return nil } @@ -552,6 +588,10 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response } item.Arguments = args case "reasoning": + if state.PreserveThinkingSignatures && (state.CurrentThinking.Signature != "" || state.CurrentThinking.Data != "") { + state.CurrentThinking.Thinking = state.CurrentSummary + item.EncryptedContent = encodeAnthropicThinking(state.CurrentThinking) + } if state.CurrentSummary != "" { item.Summary = []ResponsesSummary{{Type: "summary_text", Text: state.CurrentSummary}} } @@ -566,6 +606,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response state.CurrentContent = nil state.CurrentArgs = "" state.CurrentSummary = "" + state.CurrentThinking = AnthropicContentBlock{} state.TextAccum = "" state.OutputIndex++ state.ContentIndex = 0 diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index f72b5faf6d..773b12b69a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) type chatMessageContent struct { @@ -27,18 +29,19 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } out := &ResponsesRequest{ - Model: req.Model, - Instructions: req.Instructions, - Input: inputJSON, - Stream: true, // upstream always streams - Include: []string{"reasoning.encrypted_content"}, - ServiceTier: req.ServiceTier, - ParallelToolCalls: req.ParallelToolCalls, + Model: req.Model, + Instructions: req.Instructions, + Input: inputJSON, + Stream: true, // upstream always streams + Include: []string{"reasoning.encrypted_content"}, + ServiceTier: req.ServiceTier, + PromptCacheOptions: req.PromptCacheOptions, + ParallelToolCalls: req.ParallelToolCalls, } // Reasoning models (gpt-5.x) do not accept sampling parameters. // See isReasoningModel in anthropic_to_responses.go. - if !isReasoningModel(req.Model) { + if !isReasoningModel(req.Model) || (openai.IsGPT6SolOrLunaModelSpelling(req.Model) && req.ReasoningEffort == "none") { out.Temperature = req.Temperature out.TopP = req.TopP } @@ -365,26 +368,29 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont for _, p := range parts { switch p.Type { case "text": - if p.Text != "" { + if p.Text != "" || len(p.PromptCacheBreakpoint) > 0 { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_text", - Text: p.Text, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_text", + Text: p.Text, }) } case "image_url": if p.ImageURL != nil && p.ImageURL.URL != "" && !isEmptyBase64DataURI(p.ImageURL.URL) { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_image", - ImageURL: p.ImageURL.URL, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_image", + ImageURL: p.ImageURL.URL, }) } case "file": if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_file", - Filename: p.File.Filename, - FileData: p.File.FileData, - FileID: p.File.FileID, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_file", + Filename: p.File.Filename, + FileData: p.File.FileData, + FileID: p.File.FileID, }) } } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go index b63787cd44..4c113fefb2 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go @@ -80,7 +80,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"review this code"} ]` - system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) var systemText string @@ -97,7 +97,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hi"} ]` - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) for _, m := range messages { @@ -116,7 +116,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hello"} ]` - system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input)) + system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input), false) require.NoError(t, err) var systemText string diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 484fa98dba..06d9669d29 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -1,9 +1,12 @@ package apicompat import ( + "encoding/base64" "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // ResponsesToAnthropicRequest converts a Responses API request into an @@ -11,7 +14,7 @@ import ( // enables Anthropic platform groups to accept OpenAI Responses API requests // by converting them to the native /v1/messages format before forwarding upstream. func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { - system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model)) if err != nil { return nil, err } @@ -51,6 +54,34 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro out.ToolChoice = tc } + // Opus 5.5 always uses adaptive thinking. Resolve the upstream model before + // conversion, because client aliases need not identify a Claude model. + if claude.IsOpus55(req.Model) { + var choice struct { + Type string `json:"type"` + } + if len(out.ToolChoice) > 0 { + if err := json.Unmarshal(out.ToolChoice, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + } + if choice.Type == "any" || choice.Type == "tool" { + return nil, fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + effort := "medium" + if req.Reasoning != nil && req.Reasoning.Effort != "" { + effort = req.Reasoning.Effort + } + switch effort { + case "low", "medium", "high", "xhigh", "max": + default: + return nil, fmt.Errorf("claude-opus-5-5 does not support reasoning effort %q; use low, medium, high, xhigh or max", effort) + } + out.Thinking = &AnthropicThinking{Type: "adaptive"} + out.OutputConfig = &AnthropicOutputConfig{Effort: effort} + return out, nil + } + // reasoning.effort → output_config.effort + thinking if req.Reasoning != nil && req.Reasoning.Effort != "" { effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort) @@ -100,7 +131,7 @@ func mapResponsesEffortToAnthropic(effort string) string { // convertResponsesInputToAnthropic extracts system prompt and messages from // a Responses API instructions + input array. Returns the system as raw JSON // (for Anthropic's polymorphic system field) and a list of Anthropic messages. -func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { +func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage, preserveThinking bool) (json.RawMessage, []AnthropicMessage, error) { var systemParts []string if strings.TrimSpace(instructions) != "" { systemParts = append(systemParts, strings.TrimSpace(instructions)) @@ -165,11 +196,26 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess }) case item.Type == "reasoning": - // Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的, - // 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。 - // Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃, - // 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被 - // 原样塞进 Anthropic 请求体,上游直接回 400。 + // Only decode marked Anthropic bridge envelopes, not arbitrary + // OpenAI ciphertext. The upstream remains responsible for signature validation. + if preserveThinking && strings.HasPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix) { + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix)) + if err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking envelope: %w", err) + } + var block AnthropicContentBlock + if err := json.Unmarshal(raw, &block); err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking block: %w", err) + } + if (block.Type != "thinking" || block.Signature == "") && (block.Type != "redacted_thinking" || block.Data == "") { + return nil, nil, fmt.Errorf("invalid Anthropic signed thinking block") + } + content, err := json.Marshal([]AnthropicContentBlock{block}) + if err != nil { + return nil, nil, err + } + messages = append(messages, AnthropicMessage{Role: "assistant", Content: content}) + } case item.Role == "user": content, err := convertResponsesUserToAnthropicContent(item.Content) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index 42dd2d0cc5..db87f2058c 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { func convertAnthropic(t *testing.T, input string) []AnthropicMessage { t.Helper() - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) assertAnthropicPairing(t, messages) return messages diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 5d89769b15..44a5b4c79c 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -66,6 +66,7 @@ type AnthropicContentBlock struct { // Signature carries provider encrypted reasoning (e.g. xAI encrypted_content) // so multi-turn Claude clients can round-trip it back on subsequent turns. Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` // redacted_thinking // type=image Source *AnthropicImageSource `json:"source,omitempty"` @@ -226,6 +227,7 @@ type AnthropicDelta struct { // ResponsesRequest is the request body for POST /v1/responses. type ResponsesRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Instructions string `json:"instructions,omitempty"` Input json.RawMessage `json:"input"` // string or []ResponsesInputItem @@ -309,9 +311,10 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" - Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` // data URI for input_image + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` // data URI for input_image // input_file fields. Filename string `json:"filename,omitempty"` @@ -655,6 +658,7 @@ type ResponsesStreamEvent struct { // ChatCompletionsRequest is the request body for POST /v1/chat/completions. type ChatCompletionsRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Messages []ChatMessage `json:"messages"` Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat @@ -698,10 +702,11 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" | "file" - Text string `json:"text,omitempty"` - ImageURL *ChatImageURL `json:"image_url,omitempty"` - File *ChatFile `json:"file,omitempty"` + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "text" | "image_url" | "file" + Text string `json:"text,omitempty"` + ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` } // ChatImageURL contains the URL for an image content part. diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index ee2efcd29f..aa97f07c57 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -171,6 +171,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 4.8", CreatedAt: "2026-05-29T00:00:00Z", }, + { + ID: "claude-opus-5-5", + Type: "model", + DisplayName: "Claude Opus 5.5", + CreatedAt: "2026-09-22T00:00:00Z", + }, { ID: "claude-opus-5", Type: "model", diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 6cfa265bdc..941a72d486 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -18,3 +18,15 @@ func TestDefaultModelsContainsClaudeFable51(t *testing.T) { } t.Fatal("claude-fable-5-1 missing from DefaultModels") } + +func TestDefaultModelsContainsOpus55(t *testing.T) { + for _, model := range DefaultModels { + if model.ID == "claude-opus-5-5" { + if model.DisplayName != "Claude Opus 5.5" || model.CreatedAt != "2026-09-22T00:00:00Z" { + t.Fatalf("unexpected Opus 5.5 descriptor: %+v", model) + } + return + } + } + t.Fatal("claude-opus-5-5 missing") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index ab587a4c01..00fd337917 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -24,6 +24,7 @@ var effortFamilies = []struct { {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, {family: "claude-opus-4-5", levels: effortLowMediumHigh}, + {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, } @@ -39,6 +40,11 @@ func EffortLevelsForModel(model string) []string { return nil } +// IsOpus55 identifies the fixed Opus 5.5 ID after provider/local suffix normalization. +func IsOpus55(model string) bool { + return normalizeEffortModelID(model) == "claude-opus-5-5" +} + func normalizeEffortModelID(model string) string { id := strings.ToLower(strings.TrimSpace(model)) id = strings.TrimPrefix(id, "models/") diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 5c7cad3674..01f244390d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -23,6 +23,8 @@ var DefaultModels = []Model{ {ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"}, {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"}, {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"}, + {ID: "gpt-6-sol", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Sol"}, + {ID: "gpt-6-luna", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Luna"}, {ID: "gpt-6-astra", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"}, {ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"}, {ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"}, @@ -157,3 +159,21 @@ func CodexBaseInstructionsForModel(model string) string { } return latestCodexInstructions() } + +// IsGPT6SolOrLunaModelSpelling recognizes official IDs and existing local effort/compact suffixes. +func IsGPT6SolOrLunaModelSpelling(model string) bool { + canonical := CanonicalizeOpenAIModelAliasSpelling(model) + for _, base := range []string{"gpt-6-sol", "gpt-6-luna"} { + if canonical == base { + return true + } + suffix, ok := strings.CutPrefix(canonical, base+"-") + if ok { + switch suffix { + case "none", "low", "medium", "high", "xhigh", "max", "openai-compact": + return true + } + } + } + return false +} diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index 1cc7003083..523202d8b3 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -32,3 +32,13 @@ func TestDefaultModelsIncludeGPTImage25(t *testing.T) { require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare") require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst") } + +func TestGPT6SolLunaModelIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + require.Contains(t, DefaultModelIDs(), model) + require.True(t, IsGPT6SolOrLunaModelSpelling(model)) + } + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-astra")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-solitude")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-luna-preview")) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1be36ba528..9011f1ce4c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -12,6 +12,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -419,6 +421,16 @@ func (s *BillingService) initFallbackPricing() { s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) + s.fallbackPrices["claude-opus-5-5"] = &ModelPricing{ + InputPricePerToken: 4e-6, + OutputPricePerToken: 20e-6, + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheCreation5mPrice: 5e-6, + CacheCreation1hPrice: 8e-6, + SupportsCacheBreakdown: true, + } + // Claude Fable 5.x uses the same input/output and cache-write prices, while // Fable 5.1 reduces cache reads from $1 to $0.25 per MTok. s.fallbackPrices["claude-fable-5"] = &ModelPricing{ @@ -529,6 +541,35 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 1.5, } + // GPT-6 Sol/Luna official rates, 2026-09-22. + s.fallbackPrices["gpt-6-sol"] = &ModelPricing{ + InputPricePerToken: 2e-6, + InputPricePerTokenPriority: 4e-6, + OutputPricePerToken: 10e-6, + OutputPricePerTokenPriority: 20e-6, + CacheCreationPricePerToken: 2.5e-6, + CacheCreationPricePerTokenPriority: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheReadPricePerTokenPriority: 0.4e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } + s.fallbackPrices["gpt-6-luna"] = &ModelPricing{ + InputPricePerToken: 0.1e-6, + InputPricePerTokenPriority: 0.2e-6, + OutputPricePerToken: 0.5e-6, + OutputPricePerTokenPriority: 1e-6, + CacheCreationPricePerToken: 0.125e-6, + CacheCreationPricePerTokenPriority: 0.25e-6, + CacheReadPricePerToken: 0.01e-6, + CacheReadPricePerTokenPriority: 0.02e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ InputPricePerToken: 5e-6, @@ -934,6 +975,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { return s.fallbackPrices["claude-fable-5"] } + if claude.IsOpus55(modelLower) { + return s.fallbackPrices["claude-opus-5-5"] + } if strings.Contains(modelLower, "opus") { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { @@ -1113,6 +1157,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。 if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" { switch normalized { + case "gpt-6-sol", "gpt-6-luna": + return s.fallbackPrices[normalized] case "gpt-6-astra": return s.fallbackPrices["gpt-6-astra"] case "gpt-5.6-sol": @@ -1270,6 +1316,7 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (* InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority, OutputPricePerToken: litellmPricing.OutputCostPerToken, OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority, + CacheCreationPriceExplicit: openai.IsGPT6SolOrLunaModelSpelling(model) && litellmPricing.CacheCreationInputTokenCostExplicit, CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost, CacheCreationPricePerTokenPriority: litellmPricing.CacheCreationInputTokenCostPriority, CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost, @@ -1793,15 +1840,20 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing return &cloned } normalized := normalizeKnownOpenAICodexModel(model) - isGPT56 := isOpenAIGPT56Model(normalized) - needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || + usesCacheWritePremium := isOpenAIGPT56Model(normalized) || openai.IsGPT6SolOrLunaModelSpelling(normalized) + needsCacheCreationPolicy := usesCacheWritePremium && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + needsOpus55FastMultiplier := claude.IsOpus55(model) && pricing.FastMultiplier == nil + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsOpus55FastMultiplier { return pricing } cloned := *pricing - if isGPT56 && !cloned.CacheCreationPriceExplicit { + if needsOpus55FastMultiplier { + multiplier := 2.0 + cloned.FastMultiplier = &multiplier + } + if usesCacheWritePremium && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 } @@ -1811,6 +1863,9 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } if fastRatio > 0 { enforceOpenAIFastPricingRatio(&cloned, fastRatio) + if openai.IsGPT6SolOrLunaModelSpelling(normalized) && cloned.CacheCreationPriceExplicit { + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * fastRatio + } } return &cloned } @@ -1820,7 +1875,7 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing // 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 func openAIModelFastPricingRatio(normalized string) float64 { switch normalized { - case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra": + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna": return 2.0 case "gpt-5.5": return 2.5 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index ed83a97308..12e133df79 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -6,6 +6,7 @@ import ( "bytes" "log" "math" + "os" "strings" "testing" @@ -1931,3 +1932,108 @@ func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *te // textOutputTokens = 200 - 50 = 150 require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12) } + +func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { + data, err := os.ReadFile("../../resources/model-pricing/model_prices_and_context_window.json") + require.NoError(t, err) + catalog := &PricingService{} + catalog.pricingData, err = catalog.parsePricingData(data) + require.NoError(t, err) + sources := map[string]*BillingService{ + "billing fallback": newTestBillingService(), + "pricing fallback": NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6": {InputCostPerToken: 10e-6, OutputCostPerToken: 50e-6}, + "claude-opus-5": {InputCostPerToken: 5e-6, OutputCostPerToken: 25e-6}, + }}), + "catalog": NewBillingService(&config.Config{}, catalog), + } + for source, svc := range sources { + for _, tc := range []struct { + model string + input, output, write, read float64 + }{ + {"gpt-6-sol", 2e-6, 10e-6, 2.5e-6, 0.2e-6}, + {"gpt-6-luna", 0.1e-6, 0.5e-6, 0.125e-6, 0.01e-6}, + } { + t.Run(source+"/"+tc.model, func(t *testing.T) { + for _, n := range []int{271999, 272000, 272001} { + for tier, mult := range map[string]float64{"": 1, "priority": 2, "fast": 2, "flex": 0.5} { + tokens := UsageTokens{InputTokens: n - 3000, CacheReadTokens: 2000, CacheCreationTokens: 1000, OutputTokens: 500} + cost, err := svc.CalculateCostWithServiceTier(tc.model, tokens, 1, tier) + require.NoError(t, err) + im, om := 1.0, 1.0 + if n > 272000 { + im = 2 + om = 1.5 + } + require.InDelta(t, float64(tokens.InputTokens)*tc.input*im*mult, cost.InputCost, 1e-10) + require.InDelta(t, 1000*tc.write*im*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 2000*tc.read*im*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*tc.output*om*mult, cost.OutputCost, 1e-10) + require.InDelta(t, cost.InputCost+cost.OutputCost+cost.CacheCreationCost+cost.CacheReadCost, cost.TotalCost, 1e-10) + require.Equal(t, n > 272000, cost.LongContextBillingApplied) + } + } + }) + } + t.Run(source+"/opus", func(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600} + for tier, mult := range map[string]float64{"": 1, "fast": 2} { + cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier) + require.NoError(t, err) + require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10) + require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10) + require.False(t, cost.LongContextBillingApplied) + } + }) + } +} + +func TestNewModelPricingChannelOverridesAndFamilyIsolation(t *testing.T) { + svc := newTestBillingService() + for _, model := range []string{"gpt-6-sol", "gpt-6-luna", "claude-opus-5-5"} { + t.Run(model, func(t *testing.T) { + zero := 0.0 + prices, err := svc.GetModelPricingWithChannel(model, &ChannelModelPricing{InputPrice: &zero, OutputPrice: &zero, CacheWritePrice: &zero, CacheReadPrice: &zero}) + require.NoError(t, err) + cost := svc.computeTokenBreakdown(prices, UsageTokens{InputTokens: 300000, CacheReadTokens: 1000, CacheCreationTokens: 1000, OutputTokens: 1000}, 1, "priority", true) + require.Zero(t, cost.TotalCost) + fresh, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.Positive(t, fresh.InputPricePerToken) + }) + } + prices, err := svc.GetModelPricing("claude-opus-5") + require.NoError(t, err) + require.Equal(t, 5e-6, prices.InputPricePerToken) + prices, err = svc.GetModelPricing("gpt-6") + require.NoError(t, err) + require.Equal(t, 10e-6, prices.InputPricePerToken) + require.Equal(t, "gpt-6-sol", normalizeKnownOpenAICodexModel("openai/gpt-6-sol-max")) + require.Equal(t, "gpt-6-luna", normalizeKnownOpenAICodexModel("gpt-6-luna-openai-compact")) +} + +func TestNewModelPricingExplicitZeroCacheWrite(t *testing.T) { + svc := &PricingService{} + var err error + svc.pricingData, err = svc.parsePricingData([]byte(`{"gpt-6-sol":{"litellm_provider":"openai","input_cost_per_token":0.000002,"output_cost_per_token":0.00001,"input_cost_per_token_flex":0.000001,"cache_creation_input_token_cost":0}}`)) + require.NoError(t, err) + billing := NewBillingService(&config.Config{}, svc) + for _, tier := range []string{"", "priority", "flex"} { + cost, err := billing.CalculateCostWithServiceTier("gpt-6-sol", UsageTokens{CacheCreationTokens: 1000}, 1, tier) + require.NoError(t, err) + require.Zero(t, cost.CacheCreationCost) + } +} + +func TestNewModelPricingAliasesRetainExplicitOverrides(t *testing.T) { + zero := &LiteLLMModelPricing{} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6-sol": zero, "gpt-6-luna": zero, "claude-opus-5-5": zero, + }} + for _, model := range []string{"gpt-6-sol-max", "openai/gpt-6-luna-openai-compact", "claude-opus-5-5-thinking"} { + require.Same(t, zero, svc.GetModelPricing(model)) + } +} diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 34eb98d0b7..ff416f2c21 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1769,3 +1769,45 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_Non2xxRecordsOllamaActivity(t _, ok := deferred.lastUsedUpdates.Load(int64(604)) require.True(t, ok, "Anthropic passthrough non-2xx on Ollama account must record activity via handleErrorResponse") } + +func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { + for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + for _, field := range []string{`"thinking":{"type":"disabled"}`, `"thinking":{"type":"enabled","budget_tokens":1024}`, `"tool_choice":{"type":"any"}`, `"tool_choice":{"type":"tool","name":"lookup"}`} { + for _, count := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + model := "claude-opus-5-5" + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ} + if typ == AccountTypeAPIKey { + model = "public-opus" + account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-opus-5-5"}} + } + body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`) + parsed := &ParsedRequest{Model: model, Body: NewRequestBodyRef(body)} + svc := &GatewayService{} + var err error + if count { + err = svc.ForwardCountTokens(context.Background(), c, account, parsed) + } else { + _, err = svc.Forward(context.Background(), c, account, parsed) + } + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "invalid_request_error") + } + } + } +} + +func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) { + body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`) + require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5"))) + withoutThinking, _ := deleteJSONPathBytes(body, "thinking") + require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5"))) + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "none", gjson.GetBytes(out, "tool_choice.type").String()) + require.False(t, gjson.GetBytes(out, "output_config.effort").Exists(), "omission uses the official medium default") + require.Equal(t, "omitted", gjson.GetBytes(out, "thinking.display").String()) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) +} diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index b879611dee..4ad72588db 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -263,7 +263,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 // 策略:客户端传了什么就透传;没传则补默认 1。 - if !gjson.GetBytes(out, "temperature").Exists() { + if !gjson.GetBytes(out, "temperature").Exists() && !claude.IsOpus55(modelID) { if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true @@ -304,7 +304,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // - 其他形态(auto/any/none)原样透传 // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { - if gjson.GetBytes(out, "tool_choice").Exists() { + if !claude.IsOpus55(modelID) && gjson.GetBytes(out, "tool_choice").Exists() { if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { out = next modified = true diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 6a17f50b17..3cffa2201a 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -23,6 +23,17 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return fmt.Errorf("parse request: empty request") } + validationModel := parsed.Model + if account != nil && account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } + if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { + if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return err + } + } + if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body.Bytes() if reqModel := parsed.Model; reqModel != "" { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 3839198484..f292773765 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -93,6 +93,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // API-key mappings and OAuth native IDs are resolved before mimicry. + validationModel := parsed.Model + if account != nil && account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } + if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { + if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"type": "error", "error": gin.H{"type": "invalid_request_error", "message": err.Error()}}) + return nil, err + } + } // Anthropic Fast is requested with speed=fast rather than OpenAI's // service_tier. Attach it at this shared boundary so passthrough, OAuth and // partial-stream results all use the same billing and usage-log path. diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index f305146e58..ac1d393602 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -50,16 +50,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -75,7 +66,22 @@ func (s *GatewayService) ForwardAsChatCompletions( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = mappedModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + if claude.IsOpus55(mappedModel) { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_chat_completions: model mapping applied", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 999b544adc..44ed3329c4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -60,16 +60,7 @@ func (s *GatewayService) ForwardAsResponses( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming (Anthropic works best with streaming) - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -85,7 +76,22 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = mappedModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + if claude.IsOpus55(mappedModel) { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming (Anthropic works best with streaming) + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_responses: model mapping applied", zap.Int64("account_id", account.ID), @@ -412,6 +418,8 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -444,6 +452,9 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } // Convert to Responses format + if claude.IsOpus55(mappedModel) { + finalResp.Model = mappedModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel // Use original model name @@ -503,6 +514,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(mappedModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage var firstTokenMs *int diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 972c6bc1a6..5f74dfac59 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -3,7 +3,10 @@ package service import ( + "context" "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/tidwall/gjson" "io" "net/http" "net/http/httptest" @@ -449,3 +452,74 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { require.Equal(t, 6, result.Usage.OutputTokens) require.Contains(t, rec.Body.String(), `response.completed`) } + +func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) { + payload := strings.Join([]string{ + "event: message_start\n" + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10}}}`, + "event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signed"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":0}`, + "event: content_block_start\n" + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ok"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":1}`, + "event: message_delta\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + "event: message_stop\n" + `data: {"type":"message_stop"}`, + }, "\n\n") + "\n\n" + for _, stream := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload))} + svc := &GatewayService{} + var err error + if stream { + _, err = svc.handleResponsesStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + } else { + _, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + } + require.NoError(t, err) + require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") + require.Contains(t, rec.Body.String(), "public-opus") + require.Contains(t, rec.Body.String(), `"text":"ok"`) + } +} + +func TestOpus55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) { + for _, chat := range []bool{false, true} { + for _, forced := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := `{"model":"public-opus","input":"hello","reasoning":{"effort":"xhigh"}}` + if chat { + body = `{"model":"public-opus","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}` + } + if forced { + body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}` + } + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}} + svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-opus": "claude-opus-5-5"}}} + var err error + var result *ForwardResult + if chat { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil) + } else { + result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil) + } + if forced { + require.Error(t, err) + require.Equal(t, 400, rec.Code) + require.Nil(t, upstream.lastReq) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "claude-opus-5-5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists()) + } + } + } +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 72f48ad40a..da4e5d6017 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -223,7 +223,7 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.MetadataUserID = gjson.Get(jsonStr, "metadata.user_id").String() thinkingType := gjson.Get(jsonStr, "thinking.type").String() - parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" + parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || (protocol == domain.PlatformAnthropic && claude.IsOpus55(parsed.Model)) parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) if protocol == domain.PlatformAnthropic { @@ -566,6 +566,26 @@ func StripEmptyTextBlocks(body []byte) []byte { return out } +// validateClaudeOpus55Request rejects settings that the upstream cannot honor. +// Call before OAuth mimicry can remove tool_choice or alter thinking defaults. +func validateClaudeOpus55Request(body []byte, model string) error { + if !claude.IsOpus55(model) { + return nil + } + switch gjson.GetBytes(body, "thinking.type").String() { + case "disabled", "enabled": + return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") + } + if gjson.GetBytes(body, "tool_choice").String() == "required" { + return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + switch gjson.GetBytes(body, "tool_choice.type").String() { + case "any", "tool", "function", "custom", "namespace": + return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + return nil +} + // FilterThinkingBlocks removes thinking blocks from request body // Returns filtered body or original body if filtering fails (fail-safe) // This prevents 400 errors from invalid thinking block signatures. @@ -584,7 +604,7 @@ func FilterThinkingBlocks(body []byte, mappedModel string) []byte { if !ShouldPreFilterThinkingBlocks(mappedModel) { return body } - return filterThinkingBlocksInternal(body, false) + return filterThinkingBlocksInternal(body, claude.IsOpus55(mappedModel)) } // FilterThinkingBlocksForRetry strips thinking-related constructs for retry scenarios. @@ -1321,7 +1341,7 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []b // 策略: // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 // - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块 -func filterThinkingBlocksInternal(body []byte, _ bool) []byte { +func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte { // Fast path: if body doesn't contain "thinking", skip parsing if !bytes.Contains(body, []byte(`"type":"thinking"`)) && !bytes.Contains(body, []byte(`"type": "thinking"`)) && @@ -1338,7 +1358,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { } // Check if thinking is enabled - thinkingEnabled := false + thinkingEnabled := alwaysThinking if thinking, ok := req["thinking"].(map[string]any); ok { if thinkType, ok := thinking["type"].(string); ok && (thinkType == "enabled" || thinkType == "adaptive") { thinkingEnabled = true @@ -1379,6 +1399,12 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { // When thinking is enabled and this is an assistant message, // only keep thinking blocks with valid signatures if thinkingEnabled && role == "assistant" { + if alwaysThinking && blockType == "redacted_thinking" { + if data, ok := blockMap["data"].(string); ok && data != "" { + newContent = append(newContent, block) + continue + } + } signature, _ := blockMap["signature"].(string) if signature != "" && signature != antigravity.DummyThoughtSignature { newContent = append(newContent, block) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 4a4f02cfea..f4b77f061e 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -486,6 +486,10 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { + if claude.IsOpus55(modelID) { + descriptor.ContextWindow = 1_000_000 + descriptor.MaxContextWindow = 1_000_000 + } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true @@ -510,7 +514,9 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) descriptor.DefaultReasoningSummary = "none" descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} - if isOpenAIGPT56Model(modelID) { + // GPT-6 Sol/Luna retain the existing 5.6 Codex window as an offline + // compatibility template; live account metadata remains authoritative. + if isOpenAIGPT56Model(modelID) || openai.IsGPT6SolOrLunaModelSpelling(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } if isOpenAIGPT6AstraModel(modelID) { @@ -559,8 +565,8 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. - return isOpenAIGPT6AstraModel(modelID) + // GPT-6 models advertise Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6Model(modelID) } func configuredCodexSupportsUltrafastServiceTier(modelID string) bool { @@ -623,7 +629,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6Model(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", @@ -689,12 +695,12 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 7149dbf74e..3709ac6866 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3705,6 +3705,7 @@ func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") } + // Scenario: 非 OpenAI GPT 模型的 Codex 提示词不声称自己是 GPT。 // Antigravity(Google) 对「Codex 提示词 + GPT-5 身份」直接回 429 RESOURCE_EXHAUSTED。 func TestBuildCodexModelsManifestStripsGPTIdentityForNonGPTModels(t *testing.T) { @@ -3758,5 +3759,23 @@ func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { } require.NotContains(t, tmpl, "GPT-", model) require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl) + +func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) { + for _, id := range []string{"gpt-6-sol", "gpt-6-luna"} { + svc := &OpenAIGatewayService{} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"` + id + `","supported_reasoning_levels":[{"effort":"ultra"}],"default_reasoning_level":"ultra","multi_agent_reasoning_effort":"xhigh","service_tiers":[{"id":"ultrafast"}],"context_window":300000,"max_context_window":900000,"supports_search_tool":false,"apply_patch_tool_type":null}]}`)} + account := newCodexModelsAPIKeyTestAccount("https://api.openai.com/v1") + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, []string{"ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, "xhigh", models[0]["multi_agent_reasoning_effort"]) + require.Equal(t, float64(300000), models[0]["context_window"]) + require.Equal(t, float64(900000), models[0]["max_context_window"]) + require.Equal(t, []any{map[string]any{"id": "ultrafast"}}, models[0]["service_tiers"]) + require.Equal(t, false, models[0]["supports_search_tool"]) + require.Nil(t, models[0]["apply_patch_tool_type"]) + } } diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 8feac0843f..bd49f8fcaa 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -11,6 +11,8 @@ import ( ) var codexModelMap = map[string]string{ + "gpt-6-sol": "gpt-6-sol", + "gpt-6-luna": "gpt-6-luna", "gpt-6-astra": "gpt-6-astra", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", @@ -60,6 +62,8 @@ var codexVersionModelPrefixes = []struct { prefix string target string }{ + {prefix: "gpt-6-sol", target: "gpt-6-sol"}, + {prefix: "gpt-6-luna", target: "gpt-6-luna"}, {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"}, {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"}, {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"}, diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go index 3d3595d3c2..99ae309f9a 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key.go +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const compatPromptCacheKeyPrefix = "compat_cc_" @@ -14,9 +15,8 @@ const compatPromptCacheKeyPrefix = "compat_cc_" func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { trimmed := strings.TrimSpace(strings.ToLower(model)) canonical := canonicalizeOpenAIModelAliasSpelling(trimmed) - // GPT-6 is the public alias for Astra. Keep this deliberately scoped to - // Astra so other GPT-6 families do not inherit Messages compatibility state. - if canonical == "gpt-6" || canonical == "gpt-6-astra" { + // GPT-6 Sol/Luna share the supported Responses cache identity contract. + if canonical == "gpt-6" || canonical == "gpt-6-astra" || openai.IsGPT6SolOrLunaModelSpelling(canonical) { return true } // 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go index d486857662..776c68f6ad 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key_test.go +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -164,3 +164,12 @@ func TestDeriveAnthropicCompatPromptCacheKey_UsesCacheControlAnchors(t *testing. require.True(t, strings.HasPrefix(k1, "anthropic-cache-")) require.False(t, strings.HasPrefix(k1, compatPromptCacheKeyPrefix)) } + +func TestGPT6SolLunaCompatCacheIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "openai/gpt-6-luna", "gpt-6-sol-max"} { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } + for _, model := range []string{"gpt-6-sol-preview", "gpt-6-solitude", "gpt-6-luna-preview"} { + require.False(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index cd043db85d..db249da487 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -266,6 +266,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } else { // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). + chatReq.Model = upstreamModel responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) @@ -347,6 +348,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } // 4b. Apply OpenAI fast policy (may filter service_tier or block the request). + responsesBody, _, err = normalizeGPT6ResponsesSampling(responsesBody, upstreamModel) + if err != nil { + return nil, err + } updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody) if policyErr != nil { var blocked *OpenAIFastBlockedError diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index bc029bf480..4019530426 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -63,16 +63,19 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 4. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index c92528f2d7..ba5f7e7c7a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -80,6 +81,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } + if openai.IsGPT6SolOrLunaModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) && gjson.GetBytes(body, "reasoning_effort").String() != "none" { + err := fmt.Errorf("%s requires Responses for tool calls with reasoning; this account only supports Chat Completions. Use reasoning_effort=none or a Responses-capable account", upstreamModel) + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // 3. Rewrite model in body (no protocol conversion) upstreamBody := body if upstreamModel != originalModel { diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 35f6de0ed1..e26306a900 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -1136,3 +1136,116 @@ func TestBuildChatStreamErrorSSE(t *testing.T) { require.Equal(t, "cyber_policy", gjson.Get(payload, "error.code").String()) require.Equal(t, "blocked by policy", gjson.Get(payload, "error.message").String()) } + +func TestGPT6RawChatRejectsReasoningToolCalls(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"public": model}}} + svc := &OpenAIGatewayService{} + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, []byte(`{"model":"public","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`), "") + require.ErrorContains(t, err, "requires Responses") + require.Equal(t, 400, rec.Code) + } +} + +func TestGPT6ReasoningModeAndSamplingCompatibility(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + body := []byte(`{"model":"` + model + `","reasoning":{"mode":"pro","effort":"max"},"temperature":0.7,"top_p":0.9,"top_logprobs":2,"include":["reasoning.encrypted_content","message.output_text.logprobs"],"prompt_cache_options":{"ttl":"30m"}}`) + out, changed, err := normalizeOpenAIResponsesReasoningMode(body, "") + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) + require.False(t, gjson.GetBytes(out, "top_p").Exists()) + require.False(t, gjson.GetBytes(out, "top_logprobs").Exists()) + require.Equal(t, "reasoning.encrypted_content", gjson.GetBytes(out, "include.0").String()) + require.Equal(t, "30m", gjson.GetBytes(out, "prompt_cache_options.ttl").String()) + require.Equal(t, "max", normalizeOpenAIReasoningEffortForModel("max", model)) + d := newConfiguredCodexModelDescriptor(model) + require.NotNil(t, d.DefaultReasoningLevel) + require.Equal(t, "medium", *d.DefaultReasoningLevel) + require.EqualValues(t, 872000, d.MaxContextWindow) + require.Len(t, d.SupportedReasoningLevels, 5) + require.Len(t, d.ServiceTiers, 1) + } +} + +func TestGPT6RawChatNoneToolsAreForwarded(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"` + model + `","reasoning_effort":"none","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"chat_1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com"}} + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.Equal(t, 1000, result.Usage.InputTokens) + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } +} + +func TestGPT6UsageHTTPAndWSHaveSameCacheBreakdown(t *testing.T) { + response := []byte(`{"model":"gpt-6-sol","usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`) + httpUsage, ok := extractOpenAIUsageFromJSONBytes(response) + require.True(t, ok) + var wsUsage OpenAIUsage + parseOpenAIWSResponseUsageFromCompletedEvent(append(append([]byte(`{"type":"response.completed","response":`), response...), '}'), &wsUsage) + require.Equal(t, httpUsage, wsUsage) + require.Equal(t, 500, httpUsage.InputTokens-httpUsage.CacheReadInputTokens-httpUsage.CacheCreationInputTokens) +} + +func TestGPT6ReasoningModeUsesMappedUpstream(t *testing.T) { + body := []byte(`{"model":"public-model","reasoning":{"mode":"pro","effort":"max"},"temperature":0.5}`) + out, _, err := normalizeOpenAIResponsesReasoningMode(body, "gpt-6-sol") + require.NoError(t, err) + require.Equal(t, "public-model", gjson.GetBytes(out, "model").String()) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) +} + +func TestGPT6MappedCompatibilityBridgesKeepReasoningAndTools(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, messages := range []bool{false, true} { + body := []byte(`{"model":"public","reasoning_effort":"max","temperature":0.7,"top_p":0.9,"prompt_cache_options":{"ttl":"30m"},"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"messages":[{"role":"user","content":"hello"}]}`) + if messages { + body = []byte(`{"model":"public","max_tokens":1000,"output_config":{"effort":"max"},"temperature":0.7,"top_p":0.9,"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hello"}]}`) + } + response := `data: {"type":"response.completed","response":{"id":"resp_1","model":"` + model + `","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}}` + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(response))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com", "model_mapping": map[string]any{"public": model}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + var result *OpenAIForwardResult + var err error + if messages { + result, err = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "/v1/responses", upstream.lastReq.URL.Path) + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.False(t, gjson.GetBytes(upstream.lastBody, "temperature").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "top_p").Exists()) + if !messages { + require.Equal(t, "30m", gjson.GetBytes(upstream.lastBody, "prompt_cache_options.ttl").String()) + } + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7f4e354f22..51b7f223bd 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -81,7 +81,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = sanitizedToolBody } if account.IsOpenAIOAuthLike() { - reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body) + reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body, account.GetMappedModel(gjson.GetBytes(body, "model").String())) if reasoningErr != nil { return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 79de86b45b..277645fa9c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -147,6 +147,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } // 3. Convert Anthropic → Responses after compatibility-only replay guard. + anthropicReq.Model = upstreamModel responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 608d8d1ff7..66c852ab13 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "net/http" "strings" "unicode/utf8" @@ -1331,13 +1332,52 @@ func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, return normalized, changed, nil } -func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { +func normalizeGPT6ResponsesSampling(body []byte, model string) ([]byte, bool, error) { + if !openai.IsGPT6SolOrLunaModelSpelling(model) || gjson.GetBytes(body, "reasoning.effort").String() == "none" { + return body, false, nil + } + out := body + changed := false + for _, key := range []string{"temperature", "top_p", "top_logprobs", "logprobs"} { + if !gjson.GetBytes(out, key).Exists() { + continue + } + var err error + out, err = sjson.DeleteBytes(out, key) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 sampling parameter %s: %w", key, err) + } + changed = true + } + if include := gjson.GetBytes(out, "include"); include.IsArray() { + items := include.Array() + for i := len(items) - 1; i >= 0; i-- { + if items[i].String() != "message.output_text.logprobs" { + continue + } + var err error + out, err = sjson.DeleteBytes(out, fmt.Sprintf("include.%d", i)) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 logprobs include: %w", err) + } + changed = true + } + } + return out, changed, nil +} + +func normalizeOpenAIResponsesReasoningMode(body []byte, model string) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。 - if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) { - return body, false, nil + // GPT-6 treats reasoning.mode and reasoning.effort as independent + // official fields. Preserve both verbatim; earlier models retain the + // established mode stripping and pro-to-max compatibility behavior. + if model == "" { + model = gjson.GetBytes(body, "model").String() + } + if isOpenAIGPT6Model(model) { + return normalizeGPT6ResponsesSampling(body, model) } mode := gjson.GetBytes(body, "reasoning.mode") if !mode.Exists() || mode.Type != gjson.String { @@ -1429,7 +1469,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac changed = true } if account != nil && account.IsOpenAI() && account.IsOAuth() { - if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil { + if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized, account.GetMappedModel(gjson.GetBytes(normalized, "model").String())); err != nil { return body, false, err } else if reasoningChanged { normalized = reasoningBody @@ -1535,7 +1575,7 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo if err != nil { return body, false, err } - if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil { + if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, ""); reasoningErr != nil { return body, false, reasoningErr } else if reasoningChanged { normalized = reasoningBody @@ -2504,6 +2544,9 @@ func normalizeOpenAIReasoningEffort(raw string) string { } func normalizeOpenAIReasoningEffortForModel(raw, model string) string { + if strings.EqualFold(strings.TrimSpace(raw), "none") && openai.IsGPT6SolOrLunaModelSpelling(model) { + return "none" + } if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) { return "max" } @@ -2513,7 +2556,7 @@ func normalizeOpenAIReasoningEffortForModel(raw, model string) string { // supportsOpenAIReasoningEffortMax reports model families whose upstream scale // has a distinct max level. Other models keep the legacy max -> xhigh behavior. func supportsOpenAIReasoningEffortMax(model string) bool { - if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) { + if isOpenAIGPT6Model(model) || isOpenAIGPT56Model(model) { return true } diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 4295fa9544..dba295ad63 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -20,6 +20,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" @@ -64,16 +65,19 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 4. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 5. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 @@ -245,6 +249,8 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -266,6 +272,9 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } } + if claude.IsOpus55(upstreamModel) { + finalResp.Model = upstreamModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel @@ -324,6 +333,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(upstreamModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index f34a28abac..b7c4e32ef0 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -37,6 +37,13 @@ func normalizeKnownOpenAICodexModel(model string) string { } } + if openai.IsGPT6SolOrLunaModelSpelling(normalized) { + if strings.HasPrefix(normalized, "gpt-6-sol") { + return "gpt-6-sol" + } + return "gpt-6-luna" + } + switch { case normalized == "gpt-6" || normalized == "gpt-6-astra": return "gpt-6-astra" @@ -151,3 +158,7 @@ func firstUsageBillingModel(candidates []string) string { } return "" } + +func isOpenAIGPT6Model(model string) bool { + return isOpenAIGPT6AstraModel(model) || openai.IsGPT6SolOrLunaModelSpelling(model) +} diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index a05bee25ad..f2071443ad 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -129,7 +129,7 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) @@ -153,7 +153,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.False(t, changed) require.JSONEq(t, tt.body, string(normalized)) @@ -173,7 +173,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *te } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 701526252b..0b873a698b 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -19,6 +19,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -73,6 +74,48 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT6SolFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 2e-6, + InputCostPerTokenPriority: 4e-6, + OutputCostPerToken: 10e-6, + OutputCostPerTokenPriority: 20e-6, + CacheCreationInputTokenCost: 2.5e-6, + CacheCreationInputTokenCostPriority: 5e-6, + CacheReadInputTokenCost: 0.2e-6, + CacheReadInputTokenCostPriority: 0.4e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + openAIGPT6LunaFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 0.1e-6, + InputCostPerTokenPriority: 0.2e-6, + OutputCostPerToken: 0.5e-6, + OutputCostPerTokenPriority: 1e-6, + CacheCreationInputTokenCost: 0.125e-6, + CacheCreationInputTokenCostPriority: 0.25e-6, + CacheReadInputTokenCost: 0.01e-6, + CacheReadInputTokenCostPriority: 0.02e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + claudeOpus55FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 4e-6, OutputCostPerToken: 20e-6, + CacheCreationInputTokenCost: 5e-6, CacheCreationInputTokenCostAbove1hr: 8e-6, + CacheReadInputTokenCost: 0.2e-6, + InputCostPerTokenPriority: 8e-6, OutputCostPerTokenPriority: 40e-6, + CacheCreationInputTokenCostPriority: 10e-6, CacheReadInputTokenCostPriority: 0.4e-6, + SupportsServiceTier: true, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -136,6 +179,7 @@ var ( // LiteLLMModelPricing LiteLLM价格数据结构 // 只保留我们需要的字段,使用指针来处理可能缺失的值 type LiteLLMModelPricing struct { + CacheCreationInputTokenCostExplicit bool `json:"-"` InputCostPerToken float64 `json:"input_cost_per_token"` InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"` OutputCostPerToken float64 `json:"output_cost_per_token"` @@ -628,6 +672,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel } if entry.CacheCreationInputTokenCost != nil { pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost + pricing.CacheCreationInputTokenCostExplicit = true } if entry.CacheCreationInputTokenCostPriority != nil { pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority @@ -1328,6 +1373,12 @@ func (s *PricingService) extractBaseName(model string) string { // matchByModelFamily 基于模型系列匹配 func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { + if claude.IsOpus55(model) { + if pricing, ok := s.pricingData["claude-opus-5-5"]; ok { + return pricing + } + return claudeOpus55FallbackPricing + } // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 @@ -1428,6 +1479,9 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { for _, pattern := range lookups { for key, pricing := range s.pricingData { keyLower := strings.ToLower(key) + if matched.name == "opus-5" && claude.IsOpus55(keyLower) { + continue + } if strings.Contains(keyLower, pattern) { logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key) return pricing @@ -1456,6 +1510,16 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } + if openai.IsGPT6SolOrLunaModelSpelling(model) { + if pricing, ok := s.pricingData[normalizeKnownOpenAICodexModel(model)]; ok { + return pricing + } + if strings.HasPrefix(model, "gpt-6-sol") { + return openAIGPT6SolFallbackPricing + } + return openAIGPT6LunaFallbackPricing + } + // 尝试的回退变体 variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern) diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index bab05e4188..2ba65b23c2 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -10,6 +10,154 @@ "output_cost_per_token_above_200k_tokens": 1.2e-05, "supports_prompt_caching": true }, + "gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1e-07, + "context_window": 1050000 + }, + "gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_batches": 6.25e-08, + "cache_creation_input_token_cost_flex": 6.25e-08, + "cache_creation_input_token_cost_priority": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 5e-09, + "cache_read_input_token_cost_priority": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_batches": 2.5e-07, + "output_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_priority": 1e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 5e-09, + "context_window": 1050000 + }, + "claude-opus-5-5": { + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-05, + "provider_specific_entry": { + "fast": 2.0 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_priority": 8e-06, + "output_cost_per_token_priority": 4e-05, + "cache_creation_input_token_cost_priority": 1e-05, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token_batches": 2e-06, + "output_cost_per_token_batches": 1e-05 + }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 64bcfbf594..2130e257ac 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -230,6 +230,7 @@ const formatScopeName = (scope: string): string => { 'claude-opus-4-6-thinking': 'COpus46T', 'claude-opus-4-7': 'COpus47', 'claude-opus-4-8': 'COpus48', + 'claude-opus-5-5': 'COpus55', 'claude-opus-5': 'COpus5', 'claude-sonnet-4-6': 'CSon46', 'claude-sonnet-4-5': 'CSon45', diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 03544573b7..d008fddbbd 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1396,6 +1396,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-sol': { + name: 'GPT-6 Sol', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-sol': { name: 'GPT-5.6 Sol', limit: { @@ -1430,6 +1448,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-luna': { + name: 'GPT-6 Luna', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-luna': { name: 'GPT-5.6 Luna', limit: { @@ -1861,6 +1897,21 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin provider[platform].models = geminiModels } else if (platform === 'anthropic') { provider[platform].npm = '@ai-sdk/anthropic' + provider[platform].models = { + 'claude-opus-5-5': { + name: 'Claude Opus 5.5', + limit: { context: 1000000, output: 128000 }, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + options: { thinking: { type: 'adaptive' }, effort: 'medium' }, + variants: { + low: { effort: 'low' }, + medium: { effort: 'medium' }, + high: { effort: 'high' }, + xhigh: { effort: 'xhigh' }, + max: { effort: 'max' } + } + } + } } else if (platform === 'antigravity-claude') { provider[platform].npm = '@ai-sdk/anthropic' provider[platform].name = 'Antigravity (Claude)' diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 12b6c47888..efc32a0f57 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -668,12 +668,14 @@ describe('UseKeyModal', () => { const parsed = JSON.parse(wrapper.find('pre code').text()) const models = parsed.provider.openai.models - for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { + for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-sol', 'gpt-6-luna']) { expect(models[model]).toBeDefined() expect(models[model].variants).toHaveProperty('max') expect(models[model].variants).toHaveProperty('xhigh') } expect(models['gpt-5.6'].name).toBe('GPT-5.6 (Sol)') + expect(models['gpt-6-sol'].variants).toHaveProperty('none') + expect(models['gpt-6-luna'].limit).toEqual({ context: 1050000, output: 128000 }) expect(models['gpt-6']).toEqual({ name: 'GPT-6 (Astra)', limit: { context: 1050000, output: 128000 }, @@ -688,6 +690,22 @@ describe('UseKeyModal', () => { }) }) + it('exports Opus 5.5 only on the Anthropic provider with adaptive defaults', async () => { + const wrapper = mount(UseKeyModal, { + props: { show: true, apiKey: 'sk-test', baseUrl: 'https://example.com/v1', platform: 'anthropic' }, + global: { stubs: { BaseDialog: { template: '
' }, Icon: { template: '' } } } + }) + const tab = wrapper.findAll('button').find(button => button.text().includes('keys.useKeyModal.cliTabs.opencode')) + expect(tab).toBeDefined() + await tab!.trigger('click') + await nextTick() + const model = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models['claude-opus-5-5'] + expect(model.limit).toEqual({ context: 1000000, output: 128000 }) + expect(model.options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' }) + expect(model.variants.xhigh.effort).toBe('xhigh') + expect(model.variants).not.toHaveProperty('none') + }) + it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => { const wrapper = mount(UseKeyModal, { props: { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index df9f347d5a..bb73136e0a 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -17,6 +17,8 @@ describe('useModelWhitelist', () => { expect(models).toContain('gpt-5.6') expect(models).toContain('gpt-6') expect(models).toContain('gpt-6-astra') + expect(models).toContain('gpt-6-sol') + expect(models).toContain('gpt-6-luna') }) it('openai 预设映射包含 GPT-6 别名和 Astra', () => { @@ -50,6 +52,8 @@ describe('useModelWhitelist', () => { expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5-1') expect(getModelsByPlatform('claude')).toContain('claude-fable-5') expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5') + expect(getModelsByPlatform('claude')).toContain('claude-opus-5-5') + expect(getModelsByPlatform('antigravity')).not.toContain('claude-opus-5-5') expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8') expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 0b312d21bf..e99695652e 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -10,7 +10,7 @@ const openaiModels = [ // GPT-5.6 系列 'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', // GPT-6 系列 - 'gpt-6', 'gpt-6-astra', + 'gpt-6', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', // GPT-5.5 系列 'gpt-5.5', // GPT-5.4 系列 @@ -34,6 +34,7 @@ export const claudeModels = [ 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', + 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-sonnet-5', @@ -290,6 +291,7 @@ const anthropicPresetMappings = [ { label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'claude-opus-4-8', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { label: 'Opus 5.5', from: 'claude-opus-5-5', to: 'claude-opus-5-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 5', from: 'claude-opus-5', to: 'claude-opus-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }, { label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, @@ -306,8 +308,10 @@ const openaiPresetMappings = [ { label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' }, { label: 'GPT-6', from: 'gpt-6', to: 'gpt-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'GPT-5.6', from: 'gpt-5.6', to: 'gpt-5.6', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + { label: 'GPT-6 Sol', from: 'gpt-6-sol', to: 'gpt-6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, + { label: 'GPT-6 Luna', from: 'gpt-6-luna', to: 'gpt-6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-6 Astra', from: 'gpt-6-astra', to: 'gpt-6-astra', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' }, { label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, From 5b5f315b87818204a7f42dd09a46220649b70b2b Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:46:08 +0800 Subject: [PATCH 04/21] chore: remove model selector review screenshot --- assets/gpt6-sol-luna-opus55-model-selector.jpg | Bin 35773 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/gpt6-sol-luna-opus55-model-selector.jpg diff --git a/assets/gpt6-sol-luna-opus55-model-selector.jpg b/assets/gpt6-sol-luna-opus55-model-selector.jpg deleted file mode 100644 index 71e177a556d5bff4b8bb9ab302e3e3536981b04a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35773 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 From 5e244e7382bbb100ab2a4579b1078a79e6124c40 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 23 Sep 2026 11:36:08 +0800 Subject: [PATCH 05/21] =?UTF-8?q?fix(test):=20=E4=BF=AE=E5=A4=8D=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=80=8D=E7=8E=87=E5=8F=82=E6=95=B0=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E5=90=8E=E7=9A=84=20CI=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_account_scheduler_canonical_quota_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go index 575347e628..a7cc8116dd 100644 --- a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go +++ b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go @@ -97,7 +97,7 @@ func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) { scores := openAIPlanScores(plan) require.InDelta(t, 1, scores[1]-scores[2], 1e-9) - snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier) + snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) for _, account := range accounts { require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9) } From c060aa0031d15d8c67e195763a7088403d7c5edd Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:01:40 +0800 Subject: [PATCH 06/21] fix(models): repair tests after rebasing onto upstream main Close the existing catalog identity test before the new capability test and assert that an explicit null capability remains present. Update the quota snapshot fixture to the pointer parameter already used by the current base. Verified six full backend unit packages, scoped lint and CGO-disabled build. --- .../service/openai_account_scheduler_canonical_quota_test.go | 2 +- backend/internal/service/openai_codex_models_service_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go index 575347e628..a7cc8116dd 100644 --- a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go +++ b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go @@ -97,7 +97,7 @@ func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) { scores := openAIPlanScores(plan) require.InDelta(t, 1, scores[1]-scores[2], 1e-9) - snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier) + snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) for _, account := range accounts { require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9) } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 3709ac6866..d2ce58a038 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3705,7 +3705,6 @@ func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") } - // Scenario: 非 OpenAI GPT 模型的 Codex 提示词不声称自己是 GPT。 // Antigravity(Google) 对「Codex 提示词 + GPT-5 身份」直接回 429 RESOURCE_EXHAUSTED。 func TestBuildCodexModelsManifestStripsGPTIdentityForNonGPTModels(t *testing.T) { @@ -3759,6 +3758,8 @@ func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { } require.NotContains(t, tmpl, "GPT-", model) require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl) + } +} func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) { for _, id := range []string{"gpt-6-sol", "gpt-6-luna"} { @@ -3775,7 +3776,7 @@ func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) { require.Equal(t, float64(900000), models[0]["max_context_window"]) require.Equal(t, []any{map[string]any{"id": "ultrafast"}}, models[0]["service_tiers"]) require.Equal(t, false, models[0]["supports_search_tool"]) + require.Contains(t, models[0], "apply_patch_tool_type") require.Nil(t, models[0]["apply_patch_tool_type"]) - } } From ca088259351f54307d6d384f6da738ec00e3111b Mon Sep 17 00:00:00 2001 From: alfadb Date: Wed, 23 Sep 2026 13:35:44 +0800 Subject: [PATCH 07/21] =?UTF-8?q?feat(claude):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20Claude=20Code=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出站伪装用的 Claude Code 版本号此前硬编码在 claude.CLICurrentVersion, 只能靠环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖且需重启;Anthropic 抬高 版本下限时(如 Opus 5.5 要求 >= 2.1.280)必须改代码发版才能跟上。 改为每小时从官方 GitHub Releases 同步最新稳定版,无需重启也无需发版。 - 新增 ClaudeCodeVersionSyncService:1 小时间隔,数据源 anthropics/claude-code 的 releases/latest,失败回退最近 30 条列表;过滤 draft/prerelease; 只向前推进,拉取失败保留上次值不降级;面板可关闭 - 新增三个设置项:claude_code_client_version(管理员手动固定)、 claude_code_client_version_synced(同步服务独占写)、 claude_code_version_auto_sync_enabled(开关,默认开) - 取值优先级:面板手动值 -> 同步值 -> SUB2API_CLAUDE_CLI_VERSION -> 内置基线, 每层经 IsSupportedCLIVersion 校验,含 60s 缓存与 singleflight - 版本号运行期化:claude.DefaultHeaders 由包级 var 改为函数,新增 EffectiveCLIVersion/DefaultUserAgent 与 SetCLIVersionResolver(未注入时 与 CLIVersion() 等价);identity 的 defaultFingerprint 不再在包 init 固化, 指纹下限与主版本超前校验改用运行期值 - 守住一致性铁律:buildUpstreamRequest 与 count_tokens 各取一次 UA 字符串, 同时喂给出站 User-Agent 头与请求体 billing 的 cc_version,避免缓存翻转 瞬间头体版本不一致被判非正版客户端 - 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示 --- backend/cmd/server/wire.go | 5 + backend/cmd/server/wire_gen.go | 8 +- backend/cmd/server/wire_gen_test.go | 2 + .../internal/handler/admin/setting_handler.go | 3 + .../handler/admin/setting_handler_audit.go | 6 + .../handler/admin/setting_handler_update.go | 28 ++ backend/internal/handler/dto/settings.go | 3 + .../pkg/claude/cli_version_runtime.go | 43 +++ .../pkg/claude/cli_version_runtime_test.go | 73 ++++ .../internal/pkg/claude/cli_version_test.go | 2 +- backend/internal/pkg/claude/constants.go | 35 +- .../pkg/claude/constants_cli_version_test.go | 2 +- .../internal/service/account_test_service.go | 6 +- .../account_test_service_cn_adaptive.go | 4 +- .../claude_code_version_sync_service.go | 219 +++++++++++ .../claude_code_version_sync_service_test.go | 353 ++++++++++++++++++ backend/internal/service/domain_constants.go | 9 + ...teway_anthropic_apikey_passthrough_test.go | 2 +- .../service/gateway_billing_header.go | 10 +- .../service/gateway_billing_header_test.go | 2 +- .../service/gateway_claude_oauth_body.go | 9 +- .../gateway_cli_version_runtime_test.go | 120 ++++++ .../internal/service/gateway_count_tokens.go | 7 +- .../service/gateway_upstream_request.go | 21 +- backend/internal/service/identity_service.go | 59 +-- ...tity_service_user_agent_validation_test.go | 10 +- .../service/setting_gateway_runtime.go | 109 ++++++ backend/internal/service/setting_parse.go | 11 + backend/internal/service/setting_service.go | 2 + backend/internal/service/setting_update.go | 4 + backend/internal/service/settings_view.go | 3 + backend/internal/service/upstream_models.go | 4 +- backend/internal/service/wire.go | 19 + frontend/src/api/admin/settings.ts | 5 + .../src/i18n/locales/en/admin/settings.ts | 5 + .../src/i18n/locales/zh/admin/settings.ts | 5 + frontend/src/views/admin/SettingsView.vue | 70 ++++ 37 files changed, 1208 insertions(+), 70 deletions(-) create mode 100644 backend/internal/pkg/claude/cli_version_runtime.go create mode 100644 backend/internal/pkg/claude/cli_version_runtime_test.go create mode 100644 backend/internal/service/claude_code_version_sync_service.go create mode 100644 backend/internal/service/claude_code_version_sync_service_test.go create mode 100644 backend/internal/service/gateway_cli_version_runtime_test.go diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 06720610ba..f2f5bcf7f3 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -99,6 +99,7 @@ func provideCleanup( accountExpiry *service.AccountExpiryService, cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, + claudeCodeVersionSync *service.ClaudeCodeVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -272,6 +273,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"ClaudeCodeVersionSyncService", func() error { + claudeCodeVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index d851509d92..37390f92cf 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -341,6 +341,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountExpiryService := service.ProvideAccountExpiryService(accountRepository) cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig) openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient) + claudeCodeVersionSyncService := service.ProvideClaudeCodeVersionSyncService(settingRepository, settingService, gitHubReleaseClient) proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db) batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) @@ -350,7 +351,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -405,6 +406,7 @@ func provideCleanup( accountExpiry *service.AccountExpiryService, cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, + claudeCodeVersionSync *service.ClaudeCodeVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -577,6 +579,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"ClaudeCodeVersionSyncService", func() error { + claudeCodeVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index b3581641ea..dc84f6c36d 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { ) accountExpirySvc := service.NewAccountExpiryService(nil, time.Second) codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second) + claudeCodeVersionSyncSvc := service.NewClaudeCodeVersionSyncService(nil, nil, nil, time.Second) proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second) subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second) pricingSvc := service.NewPricingService(cfg, nil) @@ -68,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { accountExpirySvc, nil, // cnProviderBalanceCheck codexVersionSyncSvc, + claudeCodeVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 14e80ac56e..036fbbfca2 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -303,6 +303,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpenAICodexClientVersion: settings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled, + ClaudeCodeClientVersion: settings.ClaudeCodeClientVersion, + ClaudeCodeClientVersionSynced: settings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: settings.ClaudeCodeVersionAutoSyncEnabled, MinCodexVersion: settings.MinCodexVersion, MaxCodexVersion: settings.MaxCodexVersion, CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index edeeba61d0..b67eb88c49 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -491,6 +491,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled { changed = append(changed, "openai_codex_version_auto_sync_enabled") } + if before.ClaudeCodeClientVersion != after.ClaudeCodeClientVersion { + changed = append(changed, "claude_code_client_version") + } + if before.ClaudeCodeVersionAutoSyncEnabled != after.ClaudeCodeVersionAutoSyncEnabled { + changed = append(changed, "claude_code_version_auto_sync_enabled") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 2143cb1f28..8ca112e7f7 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -257,6 +257,8 @@ type UpdateSettingsRequest struct { OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` OpenAICodexClientVersion *string `json:"openai_codex_client_version"` OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"` + ClaudeCodeClientVersion *string `json:"claude_code_client_version"` + ClaudeCodeVersionAutoSyncEnabled *bool `json:"claude_code_version_auto_sync_enabled"` // codex_cli_only 加固(global-only) MinCodexVersion string `json:"min_codex_version"` @@ -1455,6 +1457,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } req.OpenAICodexClientVersion = &normalized } + if req.ClaudeCodeClientVersion != nil { + // 该值会被拼进出站 User-Agent 与 billing attribution,必须是合法版本号;空串表示跟随自动同步。 + normalized := strings.TrimSpace(*req.ClaudeCodeClientVersion) + if normalized != "" && service.NormalizeClaudeCodeClientVersion(normalized) == "" { + response.Error(c, http.StatusBadRequest, "claude_code_client_version must be empty or a valid version (e.g. 2.1.258)") + return + } + req.ClaudeCodeClientVersion = &normalized + } // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { @@ -1768,6 +1779,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexVersionAutoSyncEnabled }(), + ClaudeCodeClientVersion: func() string { + if req.ClaudeCodeClientVersion != nil { + return *req.ClaudeCodeClientVersion + } + return previousSettings.ClaudeCodeClientVersion + }(), + // 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。 + ClaudeCodeClientVersionSynced: previousSettings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: func() bool { + if req.ClaudeCodeVersionAutoSyncEnabled != nil { + return *req.ClaudeCodeVersionAutoSyncEnabled + } + return previousSettings.ClaudeCodeVersionAutoSyncEnabled + }(), MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), @@ -2311,6 +2336,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled, + ClaudeCodeClientVersion: updatedSettings.ClaudeCodeClientVersion, + ClaudeCodeClientVersionSynced: updatedSettings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: updatedSettings.ClaudeCodeVersionAutoSyncEnabled, MinCodexVersion: updatedSettings.MinCodexVersion, MaxCodexVersion: updatedSettings.MaxCodexVersion, CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index cac990e520..72f2a56cbc 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -217,6 +217,9 @@ type SystemSettings struct { OpenAICodexClientVersion string `json:"openai_codex_client_version"` OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` + ClaudeCodeClientVersion string `json:"claude_code_client_version"` + ClaudeCodeClientVersionSynced string `json:"claude_code_client_version_synced"` + ClaudeCodeVersionAutoSyncEnabled bool `json:"claude_code_version_auto_sync_enabled"` // codex_cli_only 加固 MinCodexVersion string `json:"min_codex_version"` diff --git a/backend/internal/pkg/claude/cli_version_runtime.go b/backend/internal/pkg/claude/cli_version_runtime.go new file mode 100644 index 0000000000..df0a2948d3 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_runtime.go @@ -0,0 +1,43 @@ +package claude + +import "sync/atomic" + +// cliVersionResolver 返回运行期生效的 Claude CLI 版本号(管理员面板手动值 → +// 后台同步值)。由 SettingService 在装配时注入(wire.go),解析器内部自带 +// TTL 缓存,热路径不触库。 +// +// 未注入时(所有不经过 wire 的单元测试)EffectiveCLIVersion() 与 CLIVersion() +// 完全等价,行为不变。 +type cliVersionResolverFunc func() string + +var cliVersionResolver atomic.Pointer[cliVersionResolverFunc] + +// SetCLIVersionResolver 注入运行期版本解析器;传 nil 可撤销注入。 +func SetCLIVersionResolver(resolver func() string) { + if resolver == nil { + cliVersionResolver.Store(nil) + return + } + r := cliVersionResolverFunc(resolver) + cliVersionResolver.Store(&r) +} + +// EffectiveCLIVersion 返回当前生效的 Claude CLI 版本号。 +// resolver 返回值必须通过 IsSupportedCLIVersion 校验(严格三段 semver 且不低于 +// 内置基线),否则回退 CLIVersion()(环境变量覆盖 → 内置基线)。 +// 运行期值恒 >= 内置基线,identity_service 的"只升不降"语义不受影响。 +func EffectiveCLIVersion() string { + if r := cliVersionResolver.Load(); r != nil { + if v := (*r)(); IsSupportedCLIVersion(v) { + return v + } + } + return CLIVersion() +} + +// DefaultUserAgent 返回 Claude Code CLI 伪装 User-Agent。 +// 所有出站路径都必须经本函数取 UA,保证 User-Agent 头与请求体 billing +// attribution 块中的 cc_version 始终源自同一个版本号。 +func DefaultUserAgent() string { + return "claude-cli/" + EffectiveCLIVersion() + " (external, cli)" +} diff --git a/backend/internal/pkg/claude/cli_version_runtime_test.go b/backend/internal/pkg/claude/cli_version_runtime_test.go new file mode 100644 index 0000000000..01ba5d0778 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_runtime_test.go @@ -0,0 +1,73 @@ +package claude + +import ( + "strings" + "testing" +) + +// withCLIVersionResolver 注入测试解析器并在用例结束时还原,避免用例间污染。 +func withCLIVersionResolver(t *testing.T, resolver func() string) { + t.Helper() + SetCLIVersionResolver(resolver) + t.Cleanup(func() { SetCLIVersionResolver(nil) }) +} + +// a. resolver 未注入时,EffectiveCLIVersion() 必须等于 CLIVersion(), +// 且 DefaultHeaders()["User-Agent"] 与改造前的 init 固化值完全一致。 +func TestEffectiveCLIVersionDefaultsToCLIVersion(t *testing.T) { + SetCLIVersionResolver(nil) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("EffectiveCLIVersion() = %q, want CLIVersion() = %q", got, CLIVersion()) + } + wantUA := "claude-cli/" + CLIVersion() + " (external, cli)" + if got := DefaultHeaders()["User-Agent"]; got != wantUA { + t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA) + } +} + +// b. 注入返回更高版本的 resolver 后,DefaultHeaders()["User-Agent"] 跟随变化。 +func TestEffectiveCLIVersionFollowsResolver(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolver(t, func() string { return upgraded }) + + if got := EffectiveCLIVersion(); got != upgraded { + t.Fatalf("EffectiveCLIVersion() = %q, want %q", got, upgraded) + } + wantUA := "claude-cli/" + upgraded + " (external, cli)" + if got := DefaultHeaders()["User-Agent"]; got != wantUA { + t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA) + } + if got := DefaultHeaders()["X-App"]; got != "cli" { + t.Fatalf("DefaultHeaders()[X-App] = %q, want cli", got) + } +} + +// c. resolver 返回非法值(空串、非 semver、低于内置基线)时回退 CLIVersion()。 +func TestEffectiveCLIVersionFallsBackOnInvalidResolverValues(t *testing.T) { + for name, invalid := range map[string]string{ + "empty": "", + "not semver": "abc", + "below baseline": "1.0.0", + "whitespace": " ", + } { + t.Run(name, func(t *testing.T) { + withCLIVersionResolver(t, func() string { return invalid }) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("resolver %q: EffectiveCLIVersion() = %q, want fallback %q", invalid, got, CLIVersion()) + } + ua := DefaultHeaders()["User-Agent"] + if !strings.HasPrefix(ua, "claude-cli/"+CLIVersion()+" ") { + t.Fatalf("UA %q does not carry fallback version %q", ua, CLIVersion()) + } + }) + } +} + +// 撤销注入(传 nil)后回到 CLIVersion()。 +func TestSetCLIVersionResolverNilRestoresFallback(t *testing.T) { + withCLIVersionResolver(t, func() string { return "9.9.9" }) + SetCLIVersionResolver(nil) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("after nil resolver: EffectiveCLIVersion() = %q, want %q", got, CLIVersion()) + } +} diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go index 89f8db33a8..72ec94cfd5 100644 --- a/backend/internal/pkg/claude/cli_version_test.go +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -60,7 +60,7 @@ func TestResolveCLIVersion(t *testing.T) { // 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。 func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) { want := "claude-cli/" + CLIVersion() + " (external, cli)" - if got := DefaultHeaders["User-Agent"]; got != want { + if got := DefaultHeaders()["User-Agent"]; got != want { t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want) } } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index ee2efcd29f..76bb367a1d 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -108,21 +108,26 @@ func FullClaudeCodeMimicryBetas() []string { } // DefaultHeaders 是 Claude Code 客户端默认请求头。 -var DefaultHeaders = map[string]string{ - // Keep these in sync with recent Claude CLI traffic to reduce the chance - // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 - "User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.94.0", - "X-Stainless-OS": "Linux", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "600", - "X-App": "cli", - "Anthropic-Dangerous-Direct-Browser-Access": "true", +// 每次调用现构造:User-Agent 走 DefaultUserAgent()(运行期可变版本号), +// 不再在包 init 时固化。同一次请求内应只取一次 UA 字符串并在出站头与 +// billing 两条路径间复用,避免版本缓存翻转瞬间头/体不一致。 +func DefaultHeaders() map[string]string { + return map[string]string{ + // Keep these in sync with recent Claude CLI traffic to reduce the chance + // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. + // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 + "User-Agent": DefaultUserAgent(), + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-OS": "Linux", + "X-Stainless-Arch": "arm64", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Timeout": "600", + "X-App": "cli", + "Anthropic-Dangerous-Direct-Browser-Access": "true", + } } // Model 表示一个 Claude 模型 diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go index 91bd12833f..0a638cab02 100644 --- a/backend/internal/pkg/claude/constants_cli_version_test.go +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -21,7 +21,7 @@ const fable51MinCLIVersion = "2.1.251" func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) { t.Parallel() - ua := DefaultHeaders["User-Agent"] + ua := DefaultHeaders()["User-Agent"] const prefix = "claude-cli/" require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index f090742f79..7d16eeaaec 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -299,7 +299,7 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) } // generateSessionString generates a Claude Code style session string. -// The output format is determined by the UA version in claude.DefaultHeaders, +// The output format is determined by the UA version in claude.DefaultHeaders(), // ensuring consistency between the user_id format and the UA sent to upstream. func generateSessionString() (string, error) { b := make([]byte, 32) @@ -308,7 +308,7 @@ func generateSessionString() (string, error) { } hex64 := hex.EncodeToString(b) sessionUUID := uuid.New().String() - uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"]) + uaVersion := ExtractCLIVersion(claude.DefaultHeaders()["User-Agent"]) return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil } @@ -562,7 +562,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account req.Header.Set("anthropic-version", "2023-06-01") // Apply Claude Code client headers - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index 4fe14c380b..f56560d53d 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -77,7 +77,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("anthropic-version", "2023-06-01") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) @@ -263,7 +263,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("anthropic-version", "2023-06-01") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 diff --git a/backend/internal/service/claude_code_version_sync_service.go b/backend/internal/service/claude_code_version_sync_service.go new file mode 100644 index 0000000000..6055b2a26d --- /dev/null +++ b/backend/internal/service/claude_code_version_sync_service.go @@ -0,0 +1,219 @@ +package service + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +const ( + // claudeCodeVersionSyncInterval 自动同步间隔。Claude Code CLI 发版频率是小时级, + // 1 小时足够跟上官方节奏,同时把对 GitHub API 的调用压到每天 24 次。 + claudeCodeVersionSyncInterval = time.Hour + // claudeCodeVersionSyncTimeout 单次同步的整体超时。 + claudeCodeVersionSyncTimeout = 30 * time.Second + // claudeCodeVersionSyncRepo 官方 Claude Code CLI 仓库。 + claudeCodeVersionSyncRepo = "anthropics/claude-code" + // claudeCodeVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见 + // fetchLatestStableVersion)。 + claudeCodeVersionSyncPerPage = 30 + // claudeCodeVersionTagPrefix 客户端 release 的 tag 前缀(如 v2.1.280)。 + // 同仓库的 tag 家族单一,但显式按前缀过滤仍能挡住上游改版或混入的其他 tag。 + claudeCodeVersionTagPrefix = "v" +) + +// ClaudeCodeVersionSyncService 周期性把官方 Claude Code CLI 的最新稳定版版本号同步到设置, +// 供出站规范身份使用,避免为了跟上游版本而发新版本。 +// +// 同步值写入 SettingKeyClaudeCodeClientVersionSynced(本服务独占写入);管理员在面板填写的 +// SettingKeyClaudeCodeClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。 +type ClaudeCodeVersionSyncService struct { + settingRepo SettingRepository + settingService *SettingService + githubClient GitHubReleaseClient + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewClaudeCodeVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, + interval time.Duration, +) *ClaudeCodeVersionSyncService { + return &ClaudeCodeVersionSyncService{ + settingRepo: settingRepo, + settingService: settingService, + githubClient: githubClient, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *ClaudeCodeVersionSyncService) Start() { + if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runInitial() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *ClaudeCodeVersionSyncService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过: +// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求, +// 而同步间隔只有 1 小时,重启后没有立刻重新拉取的必要。 +func (s *ClaudeCodeVersionSyncService) runInitial() { + if s.syncedWithinInterval() { + return + } + s.runOnce() +} + +// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。 +// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。 +// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。 +func (s *ClaudeCodeVersionSyncService) syncedWithinInterval() bool { + if s.interval <= 0 { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout) + defer cancel() + + setting, err := s.settingRepo.Get(ctx, SettingKeyClaudeCodeClientVersionSynced) + if err != nil || setting == nil || setting.UpdatedAt.IsZero() { + return false + } + if NormalizeClaudeCodeClientVersion(setting.Value) == "" { + return false + } + return time.Since(setting.UpdatedAt) < s.interval +} + +func (s *ClaudeCodeVersionSyncService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout) + defer cancel() + + if !s.autoSyncEnabled(ctx) { + return + } + + latest := s.fetchLatestStableVersion(ctx) + if latest == "" { + return + } + + current := NormalizeClaudeCodeClientVersion(s.currentSyncedVersion(ctx)) + // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。 + if current != "" && CompareVersions(latest, current) <= 0 { + return + } + if err := s.settingRepo.Set(ctx, SettingKeyClaudeCodeClientVersionSynced, latest); err != nil { + slog.Warn("claude_code_version_sync_persist_failed", "version", latest, "error", err) + return + } + s.settingService.InvalidateClaudeCodeClientVersionCache() + slog.Info("claude_code_version_synced", "previous", current, "version", latest) +} + +// fetchLatestStableVersion 取官方最新稳定版 CLI 版本号;取不到时返回空串, +// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。 +// +// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布。 +// +// 回退列表扫描:latest 抓取失败或返回值未通过过滤时,扫一页 release 继续跟随官方版本, +// 否则版本号会静默停更。两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态), +// 语义不会分叉。 +func (s *ClaudeCodeVersionSyncService) fetchLatestStableVersion(ctx context.Context) string { + release, err := s.githubClient.FetchLatestRelease(ctx, claudeCodeVersionSyncRepo) + if err != nil { + slog.Warn("claude_code_version_sync_latest_fetch_failed", "error", err) + } else if version := latestClaudeCodeStableReleaseVersion([]*GitHubRelease{release}); version != "" { + return version + } + + // 主路径没拿到可用版本(抓取失败,或 latest 未通过过滤)。 + releases, err := s.githubClient.FetchRecentReleases(ctx, claudeCodeVersionSyncRepo, claudeCodeVersionSyncPerPage) + if err != nil { + slog.Warn("claude_code_version_sync_fetch_failed", "error", err) + return "" + } + version := latestClaudeCodeStableReleaseVersion(releases) + if version == "" { + slog.Warn("claude_code_version_sync_no_stable_release", "repo", claudeCodeVersionSyncRepo) + } + return version +} + +// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致; +// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。 +func (s *ClaudeCodeVersionSyncService) autoSyncEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeVersionAutoSyncEnabled) + if err != nil { + return true + } + if strings.TrimSpace(value) == "" { + return true + } + return strings.TrimSpace(value) == "true" +} + +func (s *ClaudeCodeVersionSyncService) currentSyncedVersion(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced) + if err != nil { + return "" + } + return value +} + +// latestClaudeCodeStableReleaseVersion 从 release 列表里挑出最大的稳定版 CLI 版本号。 +// 过滤条件:tag 前缀为 v、非草稿、非预发布、版本号须通过 NormalizeClaudeCodeClientVersion +// (严格三段纯数字 semver,且不低于内置基线)。取最大值而非最新发布, +// 避免重新发布历史 tag 造成回退。 +// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。 +func latestClaudeCodeStableReleaseVersion(releases []*GitHubRelease) string { + best := "" + for _, release := range releases { + if release == nil || release.Draft || release.Prerelease { + continue + } + tag := strings.TrimSpace(release.TagName) + if !strings.HasPrefix(tag, claudeCodeVersionTagPrefix) { + continue + } + version := NormalizeClaudeCodeClientVersion(strings.TrimPrefix(tag, claudeCodeVersionTagPrefix)) + if version == "" || strings.Contains(version, "-") { + continue + } + if best == "" || CompareVersions(version, best) > 0 { + best = version + } + } + return best +} diff --git a/backend/internal/service/claude_code_version_sync_service_test.go b/backend/internal/service/claude_code_version_sync_service_test.go new file mode 100644 index 0000000000..b72e04edf2 --- /dev/null +++ b/backend/internal/service/claude_code_version_sync_service_test.go @@ -0,0 +1,353 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 --- + +type claudeCodeVersionSyncSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + mu sync.Mutex + values map[string]string + getErr error + setErr error + updatedAt time.Time + writes []string +} + +func newClaudeCodeVersionSyncSettingRepoStub(values map[string]string) *claudeCodeVersionSyncSettingRepoStub { + if values == nil { + values = map[string]string{} + } + return &claudeCodeVersionSyncSettingRepoStub{values: values} +} + +func (r *claudeCodeVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return "", r.getErr + } + return r.values[key], nil +} + +func (r *claudeCodeVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.setErr != nil { + return r.setErr + } + r.values[key] = value + r.writes = append(r.writes, value) + return nil +} + +func (r *claudeCodeVersionSyncSettingRepoStub) syncedWrites() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.writes...) +} + +func (r *claudeCodeVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return nil, r.getErr + } + value, ok := r.values[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil +} + +type claudeCodeVersionSyncGitHubStub struct { + GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用) + + // 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」, + // 使调用落到回退的列表扫描上。 + latest *GitHubRelease + latestErr error + latestCalls int + + releases []*GitHubRelease + err error + calls int +} + +func (c *claudeCodeVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) { + c.latestCalls++ + if c.latestErr != nil { + return nil, c.latestErr + } + return c.latest, nil +} + +func (c *claudeCodeVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return c.releases, nil +} + +func newClaudeCodeVersionSyncService( + repo SettingRepository, + github GitHubReleaseClient, +) *ClaudeCodeVersionSyncService { + return NewClaudeCodeVersionSyncService(repo, &SettingService{}, github, claudeCodeVersionSyncInterval) +} + +// 同仓库可能混入非 v 前缀 tag 与预发布 / 草稿,必须只认 v 前缀的稳定版, +// 否则会把无关 tag 的版本号当成客户端版本同步出去。 +func TestLatestClaudeCodeStableReleaseVersion(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v2.1.281-beta.1", Prerelease: true}, + {TagName: "v2.1.280"}, + {TagName: "v2.1.279"}, + {TagName: "v2.999.0", Draft: true}, + {TagName: "not-a-tag"}, + nil, + } + + require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion(releases)) + require.Empty(t, latestClaudeCodeStableReleaseVersion(nil)) + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "not-a-tag"}})) + // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。 + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.281-beta.1"}})) + // 低于内置基线的版本号即使形态合法也不得采信。 + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.9"}})) +} + +func TestClaudeCodeVersionSyncWritesLatestStableVersion(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{ + {TagName: "v2.1.279"}, + {TagName: "v2.1.280"}, + }} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。 +func TestClaudeCodeVersionSyncNeverMovesBackwards(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.279"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites()) +} + +func TestClaudeCodeVersionSyncSkippedWhenDisabled(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeVersionAutoSyncEnabled: "false", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游") + require.Zero(t, github.calls, "关闭自动同步后不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +// 面板开关缺失或为空一律视为开启,与设置默认值一致;读取失败同样按开启处理, +// 避免一次数据库抖动就静默停掉版本跟随。 +func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) { + for _, tt := range []struct { + name string + getErr error + value string + }{ + {name: "缺失"}, + {name: "空值", value: ""}, + {name: "显式开启", value: "true"}, + {name: "读取失败", getErr: errors.New("db down")}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeVersionAutoSyncEnabled: tt.value, + }) + repo.getErr = tt.getErr + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) + } +} + +// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。 +func TestClaudeCodeVersionSyncKeepsValueOnFetchError(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{ + latestErr: errors.New("network down"), + err: errors.New("network down"), + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Empty(t, repo.syncedWrites()) + value, err := repo.GetValue(context.Background(), SettingKeyClaudeCodeClientVersionSynced) + require.NoError(t, err) + require.Equal(t, "2.1.280", value) +} + +// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布。 +func TestClaudeCodeVersionSyncUsesLatestReleaseEndpoint(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + latest: &GitHubRelease{TagName: "v2.1.280"}, + // 列表若被调用会给出不同答案,用于证明取值确实来自主路径。 + releases: []*GitHubRelease{{TagName: "v2.1.279"}}, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Zero(t, github.calls, "主路径可用时不应再拉列表页") + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 回退列表扫描:主路径拿不到可用稳定版时必须继续扫一页 release, +// 否则版本号会静默停更。各种拿不到的形态都要回退。 +func TestClaudeCodeVersionSyncFallsBackToReleaseList(t *testing.T) { + tests := []struct { + name string + latest *GitHubRelease + latestErr error + }{ + {name: "latest 前缀不符", latest: &GitHubRelease{TagName: "cli-2.1.280"}}, + {name: "latest 是预发布", latest: &GitHubRelease{TagName: "v2.1.281-beta.1", Prerelease: true}}, + {name: "latest 是草稿", latest: &GitHubRelease{TagName: "v2.1.281", Draft: true}}, + {name: "latest 抓取失败", latestErr: errors.New("network down")}, + // 上游返回空对象:不得因此 panic,按拿不到处理。 + {name: "latest 为空", latest: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + latest: tt.latest, + latestErr: tt.latestErr, + releases: []*GitHubRelease{ + {TagName: "v2.1.281-beta.1", Prerelease: true}, + {TagName: "v2.1.280"}, + {TagName: "v2.1.279"}, + }, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) + } +} + +// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验, +// 不能因为「端点保证是正式发布」就直接采信 tag。 +func TestClaudeCodeVersionSyncLatestSharesFiltering(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + // 剥掉 v 前缀后不满足基线校验(2.1.9 低于内置基线 2.1.258)的 latest + // 必须被拒绝而不是直接采信。 + latest: &GitHubRelease{TagName: "v2.1.9"}, + releases: []*GitHubRelease{{TagName: "v2.1.280"}}, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。 +func TestClaudeCodeVersionSyncStartRequiresDependencies(t *testing.T) { + require.NotPanics(t, func() { + svc := NewClaudeCodeVersionSyncService(nil, nil, nil, claudeCodeVersionSyncInterval) + svc.Start() + svc.Stop() + }) +} + +// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步 +// 放大成对 GitHub 的连续请求。 +func TestClaudeCodeVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + repo.updatedAt = time.Now().Add(-time.Minute) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +func TestClaudeCodeVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) { + t.Run("同步值已过期", func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + repo.updatedAt = time.Now().Add(-2 * time.Hour) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.281"}, repo.syncedWrites()) + }) + + // 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。 + t.Run("尚无同步值", func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + repo.updatedAt = time.Now() + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) +} + +// 版本比较必须按段取数字:字典序会把 2.1.9 判为大于 2.1.280, +// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。 +func TestClaudeCodeVersionComparisonIsNumericNotLexical(t *testing.T) { + require.Greater(t, CompareVersions("2.1.280", "2.1.9"), 0) + + require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion([]*GitHubRelease{ + {TagName: "v2.1.9"}, + {TagName: "v2.1.280"}, + })) + + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.258"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites(), "更低的版本号不得写入") +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 883ac9f0d5..9781a1ae7e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -707,6 +707,15 @@ const ( SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" + // SettingKeyClaudeCodeClientVersion 网关对 Anthropic 上游声明的 Claude Code CLI 客户端版本号(管理员覆写)。 + // 空值表示跟随自动同步值;自动同步也没有结果时回退到 claude.CLIVersion()(环境变量覆盖 + 内置基线)。 + // 版本太旧会被 Anthropic 拒绝(claude_code_version_too_old),故该值需保持跟随官方发布。 + SettingKeyClaudeCodeClientVersion = "claude_code_client_version" + // SettingKeyClaudeCodeClientVersionSynced 自动同步任务写入的官方 Claude Code CLI 最新版本号。 + // 由同步任务独占写入,面板只读展示;管理员覆写请用 SettingKeyClaudeCodeClientVersion。 + SettingKeyClaudeCodeClientVersionSynced = "claude_code_client_version_synced" + // SettingKeyClaudeCodeVersionAutoSyncEnabled 是否启用 Claude Code 客户端版本号自动同步(默认 true)。 + SettingKeyClaudeCodeVersionAutoSyncEnabled = "claude_code_version_auto_sync_enabled" // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。 // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 34eb98d0b7..36b8f99f36 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -913,7 +913,7 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta) } require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta")) - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key) } require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id")) diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index dd1f16b2c7..a794af0d20 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -15,10 +14,13 @@ var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`) -// OAuth mimicry forces the built-in User-Agent after applying account fingerprints. -func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string { +// effectiveBillingUserAgent 选择写进 x-anthropic-billing-header 的 User-Agent。 +// OAuth mimicry 强制使用调用方传入的 mimicUserAgent(与出站 User-Agent 头同源、 +// 同一次请求内取一次复用,保证 cc_version 与出站头版本严格一致), +// 其余情况使用账号指纹 UA。 +func effectiveBillingUserAgent(mimicUserAgent, tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string { if tokenType == "oauth" && mimicClaudeCode { - return claude.DefaultHeaders["User-Agent"] + return mimicUserAgent } if fingerprint == nil { return "" diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index 6de6b4bfec..5d85b29924 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -138,7 +138,7 @@ func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) { defer func() { require.NoError(t, req.Body.Close()) }() wantUA := cachedUA if tc.mimic { - wantUA = claude.DefaultHeaders["User-Agent"] + wantUA = claude.DefaultHeaders()["User-Agent"] } require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent")) version := ExtractCLIVersion(wantUA) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index b879611dee..7956d812ca 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -767,14 +767,17 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi return "", nil } expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - billingText, err := buildBillingAttributionText(body, claude.CLIVersion()) + // 同一次展开内只取一次版本号,billing attribution / 指纹 / 占位符三处共用, + // 避免运行期版本翻转瞬间取到不同值。 + cliVersion := claude.EffectiveCLIVersion() + billingText, err := buildBillingAttributionText(body, cliVersion) if err != nil { return "", err } - fp := computeClaudeCodeFingerprint(body, claude.CLIVersion()) + fp := computeClaudeCodeFingerprint(body, cliVersion) replacer := strings.NewReplacer( "{billing_header}", billingText, - "{cc_version}", claude.CLIVersion(), + "{cc_version}", cliVersion, "{fp}", fp, "{claude_code_system_prompt}", claudeCodeSystemPrompt, "{claude_code_expansion_prompt}", expansionPrompt, diff --git a/backend/internal/service/gateway_cli_version_runtime_test.go b/backend/internal/service/gateway_cli_version_runtime_test.go new file mode 100644 index 0000000000..8175ec2981 --- /dev/null +++ b/backend/internal/service/gateway_cli_version_runtime_test.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// withCLIVersionResolverForTest 注入解析器并在用例结束时还原(置 nil),避免用例间污染。 +func withCLIVersionResolverForTest(t *testing.T, resolver func() string) { + t.Helper() + claude.SetCLIVersionResolver(resolver) + t.Cleanup(func() { claude.SetCLIVersionResolver(nil) }) +} + +// 一致性铁律(d):模拟一次 OAuth + mimic 的请求构建(messages 与 count_tokens 两条 +// 出站路径),断言出站 User-Agent 头里的版本号与请求体 x-anthropic-billing-header +// 中 cc_version 的版本号相同,且都等于运行期注入的版本号。头/体不一致会被 Anthropic +// 判为非正版客户端。 +func TestBuildOAuthMimicRequest_RuntimeVersionConsistentBetweenHeaderAndBilling(t *testing.T) { + gin.SetMode(gin.TestMode) + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + + for _, endpoint := range []string{"messages", "count_tokens"} { + t.Run(endpoint, func(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`) + billing, err := buildBillingAttributionText(body, "2.1.81") + require.NoError(t, err) + body, err = sjson.SetBytes(body, "system.0.text", billing) + require.NoError(t, err) + + svc := &GatewayService{cfg: &config.Config{}} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + + var req *http.Request + var wireBody []byte + if endpoint == "messages" { + req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", false, true) + } else { + req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", true) + } + require.NoError(t, err) + + wantUA := "claude-cli/" + upgraded + " (external, cli)" + outboundUA := getHeaderRaw(req.Header, "User-Agent") + require.Equal(t, wantUA, outboundUA) + + billingText := gjson.GetBytes(wireBody, "system.0.text").String() + require.Contains(t, billingText, "x-anthropic-billing-header") + require.Contains(t, billingText, "cc_version="+upgraded+"."+computeClaudeCodeFingerprint(wireBody, upgraded)+";") + require.NotContains(t, billingText, "cc_version=2.1.81") + + // 头/体版本号必须来自同一字符串。 + headerVersion := ExtractCLIVersion(outboundUA) + require.Equal(t, upgraded, headerVersion) + require.Contains(t, billingText, "cc_version="+headerVersion+".") + }) + } +} + +// e. 注入更高版本后,floorClaudeCLIUserAgentVersion 把存量低版本指纹抬升到新版本; +// +// 等于或高于新版本的指纹保持不动(只升不降)。 +func TestFloorClaudeCLIUserAgentVersion_UsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + + floored, changed := floorClaudeCLIUserAgentVersion("claude-cli/2.1.100 (external, cli)") + require.True(t, changed) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", floored) + + // 等于运行期版本:不动。 + same, changed := floorClaudeCLIUserAgentVersion("claude-cli/" + upgraded + " (external, cli)") + require.False(t, changed) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", same) + + // 高于运行期版本:不动。 + greater, changed := floorClaudeCLIUserAgentVersion("claude-cli/99.0.0 (external, cli)") + require.False(t, changed) + require.Equal(t, "claude-cli/99.0.0 (external, cli)", greater) + + // resolver 返回非法值时回退内置基线,floor 语义仍然成立。 + withCLIVersionResolverForTest(t, func() string { return "abc" }) + baselineFloored, changed := floorClaudeCLIUserAgentVersion("claude-cli/1.0.0 (external, cli)") + require.True(t, changed) + require.Equal(t, "claude-cli/"+claude.CLIVersion()+" (external, cli)", baselineFloored) +} + +// 注入更高版本后,isAcceptableFingerprintUserAgent 以运行期版本为基准,允许客户端 +// 上报的新版本。 +func TestIsAcceptableFingerprintUserAgent_UsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + require.True(t, isAcceptableFingerprintUserAgent("claude-cli/"+upgraded+" (external, cli)")) + // 允许的最大主版本超前量是 +2(9.9.9 → 11.x)。 + require.True(t, isAcceptableFingerprintUserAgent("claude-cli/11.0.0 (external, cli)")) + // 超前 +3 会被拒(与静态基线下的既有语义一致,只是基准换成运行期版本)。 + require.False(t, isAcceptableFingerprintUserAgent("claude-cli/12.0.0 (external, cli)")) +} + +// defaultFingerprint 现取运行期版本,不再在 init 固化。 +func TestDefaultFingerprintUsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", defaultFingerprint().UserAgent) +} diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 6a17f50b17..8a59289bd3 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -498,7 +498,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if ctEnableFP { billingFingerprint = ctFingerprint } - if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { + // 一致性铁律:同一次请求内只取一次 mimic UA,billing cc_version 与出站 + // User-Agent 头共用这一个字符串(同 buildUpstreamRequest)。 + ctMimicUserAgent := claude.DefaultUserAgent() + if billingUA := effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { body = syncBillingHeaderVersion(body, billingUA) } @@ -564,7 +567,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth + mimic Claude Code:强制注入 CLI 指纹 header if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, false) + applyClaudeCodeMimicHeaders(req, false, ctMimicUserAgent) } // 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 4d8e74c162..ff2438a615 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -84,8 +84,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + // 一致性铁律:同一次请求内只取一次 mimic UA,出站 User-Agent 头与 + // 请求体 x-anthropic-billing-header 的 cc_version 都源自这一个字符串, + // 避免运行期版本缓存翻转瞬间头/体版本自相矛盾(会被判非正版客户端)。 + mimicUserAgent := claude.DefaultUserAgent() + // Mimicry may override the cached User-Agent later, even without a fingerprint. - if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" { + if billingUA := effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint); billingUA != "" { body = syncBillingHeaderVersion(body, billingUA) } @@ -172,7 +177,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id) if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, reqStream) + applyClaudeCodeMimicHeaders(req, reqStream, mimicUserAgent) } // 写入最终 anthropic-beta header @@ -417,7 +422,7 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) { if getHeaderRaw(req.Header, "Accept") == "" { setHeaderRaw(req.Header, "Accept", "application/json") } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { if value == "" { continue } @@ -854,7 +859,9 @@ var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. // This mirrors opencode-anthropic-auth behavior: do not trust downstream // headers when using Claude Code-scoped OAuth credentials. -func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { +// mimicUserAgent 由调用方在同一请求内取一次传入,保证出站 User-Agent 头与 +// 请求体 billing attribution 的 cc_version 版本号严格一致。 +func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool, mimicUserAgent string) { if req == nil { return } @@ -862,10 +869,14 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { applyClaudeOAuthHeaderDefaults(req) // Then force key headers to match Claude Code fingerprint regardless of what the client sent. // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { if value == "" { continue } + if key == "User-Agent" { + // 版本号与 billing 路径共用同一字符串(见 mimicUserAgent 注释)。 + value = mimicUserAgent + } setHeaderRaw(req.Header, resolveWireCasing(key), value) } // Real Claude CLI uses Accept: application/json (even for streaming). diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index d117f720cc..d5b58d6f45 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -72,15 +72,17 @@ func isAcceptableFingerprintUserAgent(ua string) bool { if !ok { return false } - currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion()) + // 超前检查以运行期生效版本为基准(EffectiveCLIVersion):同步跨大版本后, + // 若仍与静态基线比较会误拒客户端上报的新版本。 + currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.EffectiveCLIVersion()) if !currentOK { return true } return major <= currentMajor+maxClaudeCLIMajorVersionSkew } -// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion -// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 +// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到运行期生效版本 +// (claude.EffectiveCLIVersion())下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 // // 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置 // 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在 @@ -98,28 +100,36 @@ func floorClaudeCLIUserAgentVersion(ua string) (string, bool) { if extractProduct(ua) != claudeCLIUserAgentProduct { return ua, false } - floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion + // 用 EffectiveCLIVersion()(面板/同步的运行期值,经 IsSupportedCLIVersion + // 校验、恒 >= 内置基线)而非静态基线 CLICurrentVersion: + // 运行期同步到更高版本后,存量低版本指纹才能被抬到新版本;"只升不降"语义不变。 + floor := claude.EffectiveCLIVersion() + floorUA := claudeCLIUserAgentProduct + "/" + floor // isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua: // ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。 if !isNewerVersion(floorUA, ua) { return ua, false } - floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion) + floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+floor) if floored == ua { return ua, false } return floored, true } -// 默认指纹值(当客户端未提供时使用) -var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.94.0", - StainlessOS: "Linux", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.3.0", +// defaultFingerprint 返回默认指纹值(当客户端未提供时使用)。 +// UserAgent 不能在包 init 时固化:必须在每次使用时经 EffectiveCLIVersion 现取, +// 否则运行期同步到新版本后,新建账号会写入过期版本作为持久身份。 +func defaultFingerprint() Fingerprint { + return Fingerprint{ + UserAgent: claude.DefaultUserAgent(), + StainlessLang: "js", + StainlessPackageVersion: "0.94.0", + StainlessOS: "Linux", + StainlessArch: "arm64", + StainlessRuntime: "node", + StainlessRuntimeVersion: "v24.3.0", + } } // Fingerprint represents account fingerprint data @@ -187,7 +197,7 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID if uaAcceptable { mergeHeadersIntoFingerprint(cached, headers) } else { - cached.UserAgent = defaultFingerprint.UserAgent + cached.UserAgent = defaultFingerprint().UserAgent } needWrite = true logger.LegacyPrintf("service.identity", @@ -262,19 +272,20 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { // 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA - // 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。 + // 落库时同样不能低于运行期生效版本(EffectiveCLIVersion),否则新账号一开始就带着过旧的持久身份。 fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua) } else { - fp.UserAgent = defaultFingerprint.UserAgent + fp.UserAgent = defaultFingerprint().UserAgent } // 获取x-stainless-*头,如果没有则使用默认值 - fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang) - fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion) - fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS) - fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch) - fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime) - fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion) + df := defaultFingerprint() + fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", df.StainlessLang) + fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", df.StainlessPackageVersion) + fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", df.StainlessOS) + fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", df.StainlessArch) + fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", df.StainlessRuntime) + fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", df.StainlessRuntimeVersion) return fp } @@ -324,7 +335,7 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { setHeaderRaw(req.Header, "User-Agent", fp.UserAgent) } - // 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写) + // 设置x-stainless-*头(保持与 claude.DefaultHeaders() 一致的大小写) if fp.StainlessLang != "" { setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang) } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 1a52d12554..51cf70fd85 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -97,7 +97,7 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent, + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent, "畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份") require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") } @@ -160,8 +160,8 @@ func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) { // 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。 func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) { - require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent), - "defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写") + require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint().UserAgent), + "defaultFingerprint().UserAgent 必须自洽,否则自愈会陷入反复重写") } // 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。 @@ -203,7 +203,7 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing ) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent) require.Equal(t, 1, cache.setCalls) } @@ -235,5 +235,5 @@ func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) { fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{}) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent) } diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index bb59ba8526..8f81c1c0bb 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "golang.org/x/sync/singleflight" ) @@ -119,6 +120,19 @@ const openAICodexClientVersionDBTimeout = 5 * time.Second // openAICodexClientVersionSFKey singleflight 键。 const openAICodexClientVersionSFKey = "openai_codex_client_version" +// cachedClaudeCodeClientVersion 缓存出站 Claude Code 客户端版本号(进程内缓存,60s TTL) +type cachedClaudeCodeClientVersion struct { + version string + expiresAt int64 // unix nano +} + +const claudeCodeClientVersionCacheTTL = 60 * time.Second +const claudeCodeClientVersionErrorTTL = 5 * time.Second +const claudeCodeClientVersionDBTimeout = 5 * time.Second + +// claudeCodeClientVersionSFKey singleflight 键。 +const claudeCodeClientVersionSFKey = "claude_code_client_version" + type cachedOpenAIQuotaAutoPauseSettings struct { settings OpsOpenAIAccountQuotaAutoPauseSettings expiresAt int64 @@ -378,6 +392,101 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() { s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil)) } +// NormalizeClaudeCodeClientVersion 校验并归一化 Claude Code 客户端版本号,非法值返回空串。 +// 容忍前导 "v" 与首尾空白;合法性复用 claude.IsSupportedCLIVersion(严格三段纯数字 semver、 +// 无预发布/构建后缀、且不低于内置基线)。 +func NormalizeClaudeCodeClientVersion(version string) string { + normalized := strings.TrimSpace(version) + normalized = strings.TrimPrefix(normalized, "v") + if normalized == "" { + return "" + } + if !claude.IsSupportedCLIVersion(normalized) { + return "" + } + return normalized +} + +// GetClaudeCodeClientVersion 返回出站声明的 Claude Code CLI 客户端版本号。 +// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新版本 → claude.CLIVersion() +// (环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖 + 内置基线)。 +// 版本太旧会被 Anthropic 拒绝(HTTP 400 claude_code_version_too_old),故该值需保持跟随官方发布。 +// +// ⚠️ 一致性约束:同一次请求里,拼 User-Agent(claude-cli/<版本>)的版本号和用于 +// billing attribution 的版本号必须是同一个值,否则 Anthropic 侧对不上、判为非正版客户端。 +// 因此调用点必须在一次请求内只取一次本函数并复用;本缓存的唯一主动变更点是 +// 同步任务写入后调用 InvalidateClaudeCodeClientVersionCache,60s TTL 不会在极短时间内抖动。 +func (s *SettingService) GetClaudeCodeClientVersion(ctx context.Context) string { + fallback := claude.CLIVersion() + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.claudeCodeVersionSF.Do(claudeCodeClientVersionSFKey, func() (any, error) { + if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), claudeCodeClientVersionDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyClaudeCodeClientVersion, + SettingKeyClaudeCodeClientVersionSynced, + }) + if err != nil { + slog.Warn("failed to get claude code client version setting", "error", err) + s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{ + version: fallback, + expiresAt: time.Now().Add(claudeCodeClientVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersion]) + if version == "" { + if raw := values[SettingKeyClaudeCodeClientVersion]; strings.TrimSpace(raw) != "" { + slog.Warn("ignoring invalid claude_code_client_version setting; falling back to the next layer", + "value", raw) + } + version = NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersionSynced]) + if version == "" && strings.TrimSpace(values[SettingKeyClaudeCodeClientVersionSynced]) != "" { + slog.Warn("ignoring invalid claude_code_client_version_synced setting; falling back to the built-in pin", + "value", values[SettingKeyClaudeCodeClientVersionSynced]) + } + } + if version == "" { + version = fallback + } + s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{ + version: version, + expiresAt: time.Now().Add(claudeCodeClientVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// InvalidateClaudeCodeClientVersionCache 丢弃版本号缓存,下次读取回源。 +// 面板保存与自动同步写入后调用。 +func (s *SettingService) InvalidateClaudeCodeClientVersionCache() { + if s == nil { + return + } + s.claudeCodeVersionSF.Forget(claudeCodeClientVersionSFKey) + s.claudeCodeVersionCache.Store((*cachedClaudeCodeClientVersion)(nil)) +} + // GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。 // 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。 // diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index d6497775cf..7c62451d33 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -246,6 +246,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOpenAICodexClientVersion: "", SettingKeyOpenAICodexClientVersionSynced: "", SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", + SettingKeyClaudeCodeClientVersion: "", + SettingKeyClaudeCodeClientVersionSynced: "", + SettingKeyClaudeCodeVersionAutoSyncEnabled: "true", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -891,6 +894,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } else { result.OpenAICodexVersionAutoSyncEnabled = true } + result.ClaudeCodeClientVersion = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersion]) + result.ClaudeCodeClientVersionSynced = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersionSynced]) + // 自动同步默认开启:缺失/空值一律视为开启,与 openai_codex_version_auto_sync_enabled 同一惯例。 + if v, ok := settings[SettingKeyClaudeCodeVersionAutoSyncEnabled]; ok && v != "" { + result.ClaudeCodeVersionAutoSyncEnabled = v == "true" + } else { + result.ClaudeCodeVersionAutoSyncEnabled = true + } // codex_cli_only 加固 result.MinCodexVersion = settings[SettingKeyMinCodexVersion] result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 369d255294..6559249ada 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -130,6 +130,8 @@ type SettingService struct { openAICodexUASF singleflight.Group openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion openAICodexVersionSF singleflight.Group + claudeCodeVersionCache atomic.Value // *cachedClaudeCodeClientVersion + claudeCodeVersionSF singleflight.Group codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 873d40849a..279867ecb6 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -487,6 +487,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled) // SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, // 否则面板保存会把同步结果清空。 + updates[SettingKeyClaudeCodeClientVersion] = NormalizeClaudeCodeClientVersion(settings.ClaudeCodeClientVersion) + updates[SettingKeyClaudeCodeVersionAutoSyncEnabled] = strconv.FormatBool(settings.ClaudeCodeVersionAutoSyncEnabled) + // SettingKeyClaudeCodeClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, + // 否则面板保存会把同步结果清空。 // codex_cli_only 加固 updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a305d390cf..883d680319 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -250,6 +250,9 @@ type SystemSettings struct { OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值 OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示) OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true) + ClaudeCodeClientVersion string // 出站声明的 Claude Code CLI 客户端版本号(管理员覆写);空值跟随自动同步值 + ClaudeCodeClientVersionSynced string // 自动同步到的官方最新版本号(只读展示) + ClaudeCodeVersionAutoSyncEnabled bool // 是否启用 Claude Code 客户端版本号自动同步(默认 true) MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 23ae22fa5d..7596dea131 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -942,7 +942,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err) } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") @@ -990,7 +990,7 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err) } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index e5aa732098..8b088e68b6 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -10,6 +10,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/google/wire" @@ -415,6 +416,18 @@ func ProvideOpenAICodexVersionSyncService( return svc } +// ProvideClaudeCodeVersionSyncService creates and starts ClaudeCodeVersionSyncService. +// 出站 Claude Code 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。 +func ProvideClaudeCodeVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, +) *ClaudeCodeVersionSyncService { + svc := NewClaudeCodeVersionSyncService(settingRepo, settingService, githubClient, claudeCodeVersionSyncInterval) + svc.Start() + return svc +} + // ProvideProxyExpiryService creates and starts ProxyExpiryService. func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService { svc := NewProxyExpiryService(proxyRepo, time.Minute) @@ -794,6 +807,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit SetCodexCanonicalUserAgentResolver(func() string { return svc.GetOpenAICodexCanonicalUserAgent(context.Background()) }) + // Claude CLI 伪装版本号同理:运行期解析(面板手动值 → 后台同步值 → 内置基线), + // 解析器内部自带 60s TTL 缓存,热路径不触库。 + claude.SetCLIVersionResolver(func() string { + return svc.GetClaudeCodeClientVersion(context.Background()) + }) return svc } @@ -920,6 +938,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)), ProvideAccountExpiryService, ProvideOpenAICodexVersionSyncService, + ProvideClaudeCodeVersionSyncService, ProvideProxyExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4f0e66b086..7e551c9c76 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -639,6 +639,9 @@ export interface SystemSettings { openai_codex_client_version: string; openai_codex_client_version_synced: string; openai_codex_version_auto_sync_enabled: boolean; + claude_code_client_version: string; + claude_code_client_version_synced: string; + claude_code_version_auto_sync_enabled: boolean; // codex_cli_only 加固 min_codex_version: string; max_codex_version: string; @@ -959,6 +962,8 @@ export interface UpdateSettingsRequest { openai_codex_user_agent?: string; openai_codex_client_version?: string; openai_codex_version_auto_sync_enabled?: boolean; + claude_code_client_version?: string; + claude_code_version_auto_sync_enabled?: boolean; // codex_cli_only 加固 min_codex_version?: string; max_codex_version?: string; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 7037d2e368..4cccec4eb7 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -542,6 +542,11 @@ export default { openaiCodexVersionAutoSync: 'Auto-sync Codex version', openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.', openaiCodexVersionSyncedValue: 'Currently synced: {version}', + claudeCodeClientVersion: 'Claude Code client version', + claudeCodeClientVersionHint: "The client version this gateway declares upstream when impersonating the official Claude Code CLI. Leave empty to use the auto-synced latest official release; setting a value pins it and stops following auto-sync. The SUB2API_CLAUDE_CLI_VERSION environment variable still acts as a fallback and takes priority over this setting.", + claudeCodeVersionAutoSync: 'Auto-sync Claude Code version', + claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the environment-variable fallback is used.', + claudeCodeVersionSyncedValue: 'Currently synced: {version}', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", codexHardeningDesc: diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index ec7614a102..564b85bf14 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -535,6 +535,11 @@ export default { openaiCodexVersionAutoSync: '自动同步 Codex 版本号', openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。', openaiCodexVersionSyncedValue: '当前同步到:{version}', + claudeCodeClientVersion: 'Claude Code 客户端版本号', + claudeCodeClientVersionHint: '网关伪装为官方 Claude Code CLI 时对上游声明的客户端版本号。留空表示使用自动同步到的官方最新版本;填写后固定为该版本,不再跟随同步。环境变量 SUB2API_CLAUDE_CLI_VERSION 仍作为兜底,优先级高于此处配置。', + claudeCodeVersionAutoSync: '自动同步 Claude Code 版本号', + claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或环境变量兜底。', + claudeCodeVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', codexHardeningDesc: diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 15ed5582b9..bc6ceebbbc 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5775,6 +5775,61 @@ + +
+ + +

+ {{ + t( + "admin.settings.gatewayForwarding.claudeCodeClientVersionHint", + ) + }} +

+
+ + +
+
+ +

+ {{ + t( + "admin.settings.gatewayForwarding.claudeCodeVersionAutoSyncHint", + ) + }} +

+

+ {{ claudeSyncedVersionLabel }} +

+
+ +
+ @@ -9840,6 +9895,10 @@ const form = reactive({ // 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造) openai_codex_client_version_synced: "", openai_codex_version_auto_sync_enabled: true, + claude_code_client_version: "", + // 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造) + claude_code_client_version_synced: "", + claude_code_version_auto_sync_enabled: true, // codex_cli_only 加固 min_codex_version: "", max_codex_version: "", @@ -10827,6 +10886,14 @@ const codexSyncedVersionLabel = computed(() => { }); }); +const claudeSyncedVersionLabel = computed(() => { + const synced = form.claude_code_client_version_synced?.trim(); + if (!synced) return ""; + return t("admin.settings.gatewayForwarding.claudeCodeVersionSyncedValue", { + version: synced, + }); +}); + async function loadSettings() { loading.value = true; loadFailed.value = false; @@ -11460,6 +11527,9 @@ async function saveSettings() { form.openai_codex_client_version?.trim() || "", openai_codex_version_auto_sync_enabled: form.openai_codex_version_auto_sync_enabled, + claude_code_client_version: form.claude_code_client_version?.trim() || "", + claude_code_version_auto_sync_enabled: + form.claude_code_version_auto_sync_enabled, min_codex_version: form.min_codex_version?.trim() || "", max_codex_version: form.max_codex_version?.trim() || "", codex_cli_only_allow_app_server_clients: From 6452aeffa1351ead5f39bf41e570e536a8e2b43b Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 23 Sep 2026 14:39:29 +0800 Subject: [PATCH 08/21] =?UTF-8?q?fix(claude):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=90=8C=E6=AD=A5=E5=A5=91=E7=BA=A6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=92=8C=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/server/api_contract_test.go | 6 ++ .../claude_code_version_sync_service.go | 17 +++-- .../claude_code_version_sync_service_test.go | 24 ++++++- .../setting_claude_code_version_test.go | 64 +++++++++++++++++++ backend/internal/service/setting_update.go | 1 + .../src/i18n/locales/en/admin/settings.ts | 4 +- .../src/i18n/locales/zh/admin/settings.ts | 4 +- 7 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 backend/internal/service/setting_claude_code_version_test.go diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 986a691a5a..219c83b149 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -953,6 +953,9 @@ func TestAPIContracts(t *testing.T) { "openai_codex_client_version": "", "openai_codex_client_version_synced": "", "openai_codex_version_auto_sync_enabled": true, + "claude_code_client_version": "", + "claude_code_client_version_synced": "", + "claude_code_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, @@ -1270,6 +1273,9 @@ func TestAPIContracts(t *testing.T) { "openai_codex_client_version": "", "openai_codex_client_version_synced": "", "openai_codex_version_auto_sync_enabled": true, + "claude_code_client_version": "", + "claude_code_client_version_synced": "", + "claude_code_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, diff --git a/backend/internal/service/claude_code_version_sync_service.go b/backend/internal/service/claude_code_version_sync_service.go index 6055b2a26d..ed5bd2bbce 100644 --- a/backend/internal/service/claude_code_version_sync_service.go +++ b/backend/internal/service/claude_code_version_sync_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "log/slog" "strings" "sync" @@ -129,7 +130,13 @@ func (s *ClaudeCodeVersionSyncService) runOnce() { return } - current := NormalizeClaudeCodeClientVersion(s.currentSyncedVersion(ctx)) + current, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + // 无法确认已有版本时跳过写入,避免数据库短暂故障导致版本降级。 + slog.Warn("claude_code_version_sync_current_read_failed", "error", err) + return + } + current = NormalizeClaudeCodeClientVersion(current) // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。 if current != "" && CompareVersions(latest, current) <= 0 { return @@ -184,14 +191,6 @@ func (s *ClaudeCodeVersionSyncService) autoSyncEnabled(ctx context.Context) bool return strings.TrimSpace(value) == "true" } -func (s *ClaudeCodeVersionSyncService) currentSyncedVersion(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced) - if err != nil { - return "" - } - return value -} - // latestClaudeCodeStableReleaseVersion 从 release 列表里挑出最大的稳定版 CLI 版本号。 // 过滤条件:tag 前缀为 v、非草稿、非预发布、版本号须通过 NormalizeClaudeCodeClientVersion // (严格三段纯数字 semver,且不低于内置基线)。取最大值而非最新发布, diff --git a/backend/internal/service/claude_code_version_sync_service_test.go b/backend/internal/service/claude_code_version_sync_service_test.go index b72e04edf2..b4c4bb3314 100644 --- a/backend/internal/service/claude_code_version_sync_service_test.go +++ b/backend/internal/service/claude_code_version_sync_service_test.go @@ -18,6 +18,7 @@ type claudeCodeVersionSyncSettingRepoStub struct { mu sync.Mutex values map[string]string getErr error + getErrKey string setErr error updatedAt time.Time writes []string @@ -33,10 +34,14 @@ func newClaudeCodeVersionSyncSettingRepoStub(values map[string]string) *claudeCo func (r *claudeCodeVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { r.mu.Lock() defer r.mu.Unlock() - if r.getErr != nil { + if r.getErr != nil && (r.getErrKey == "" || r.getErrKey == key) { return "", r.getErr } - return r.values[key], nil + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil } func (r *claudeCodeVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error { @@ -182,6 +187,7 @@ func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) { SettingKeyClaudeCodeVersionAutoSyncEnabled: tt.value, }) repo.getErr = tt.getErr + repo.getErrKey = SettingKeyClaudeCodeVersionAutoSyncEnabled github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} newClaudeCodeVersionSyncService(repo, github).runOnce() @@ -191,6 +197,20 @@ func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) { } } +func TestClaudeCodeVersionSyncKeepsValueOnCurrentVersionReadError(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.281", + }) + repo.getErr = errors.New("读取已有版本失败") + repo.getErrKey = SettingKeyClaudeCodeClientVersionSynced + github := &claudeCodeVersionSyncGitHubStub{latest: &GitHubRelease{TagName: "v2.1.280"}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites(), "无法确认已有版本时不得覆盖同步值") + require.Equal(t, "2.1.281", repo.values[SettingKeyClaudeCodeClientVersionSynced]) +} + // 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。 func TestClaudeCodeVersionSyncKeepsValueOnFetchError(t *testing.T) { repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ diff --git a/backend/internal/service/setting_claude_code_version_test.go b/backend/internal/service/setting_claude_code_version_test.go new file mode 100644 index 0000000000..73d8ca9015 --- /dev/null +++ b/backend/internal/service/setting_claude_code_version_test.go @@ -0,0 +1,64 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/stretchr/testify/require" +) + +func TestGetClaudeCodeClientVersionPriority(t *testing.T) { + for _, tt := range []struct { + name string + manual string + synced string + want string + }{ + {name: "手动值优先", manual: "2.1.280", synced: "2.1.281", want: "2.1.280"}, + {name: "归一化手动值", manual: " v2.1.280 ", synced: "2.1.281", want: "2.1.280"}, + {name: "跟随同步值", synced: "2.1.281", want: "2.1.281"}, + {name: "无效手动值回退", manual: "invalid", synced: "2.1.281", want: "2.1.281"}, + {name: "无效同步值回退", synced: "2.1.9", want: claude.CLIVersion()}, + {name: "未配置时回退", want: claude.CLIVersion()}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &authSourceDefaultsRepoStub{values: map[string]string{ + SettingKeyClaudeCodeClientVersion: tt.manual, + SettingKeyClaudeCodeClientVersionSynced: tt.synced, + }} + svc := NewSettingService(repo, &config.Config{}) + require.Equal(t, tt.want, svc.GetClaudeCodeClientVersion(context.Background())) + }) + } +} + +func TestUpdateSettingsClaudeCodeVersionTakesEffectImmediately(t *testing.T) { + ctx := context.Background() + repo := &authSourceDefaultsRepoStub{values: map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.281", + }} + svc := NewSettingService(repo, &config.Config{}) + resetGatewayForwardingSettingsCacheForTest(t) + defer svc.refreshCachedSettings(&SystemSettings{}) + require.Equal(t, "2.1.281", svc.GetClaudeCodeClientVersion(ctx)) + + settings := &SystemSettings{ + ClaudeCodeClientVersion: "2.1.280", + ClaudeCodeVersionAutoSyncEnabled: true, + } + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "2.1.280", svc.GetClaudeCodeClientVersion(ctx), "保存手动版本后应立即生效") + require.NotContains(t, repo.updates, SettingKeyClaudeCodeClientVersionSynced) + require.Equal(t, "2.1.281", repo.values[SettingKeyClaudeCodeClientVersionSynced]) + + // 清空手动值并关闭后续同步时,仍保留并使用已有同步值。 + settings.ClaudeCodeClientVersion = "" + settings.ClaudeCodeVersionAutoSyncEnabled = false + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "2.1.281", svc.GetClaudeCodeClientVersion(ctx)) + require.Equal(t, "false", repo.values[SettingKeyClaudeCodeVersionAutoSyncEnabled]) +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 279867ecb6..421b226524 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -746,6 +746,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { // 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键, // 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。 s.InvalidateOpenAICodexClientVersionCache() + s.InvalidateClaudeCodeClientVersionCache() openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled, diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 4cccec4eb7..8f7e025eb0 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -543,9 +543,9 @@ export default { openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.', openaiCodexVersionSyncedValue: 'Currently synced: {version}', claudeCodeClientVersion: 'Claude Code client version', - claudeCodeClientVersionHint: "The client version this gateway declares upstream when impersonating the official Claude Code CLI. Leave empty to use the auto-synced latest official release; setting a value pins it and stops following auto-sync. The SUB2API_CLAUDE_CLI_VERSION environment variable still acts as a fallback and takes priority over this setting.", + claudeCodeClientVersionHint: "The client version this gateway declares upstream when impersonating the official Claude Code CLI. Leave empty to use the auto-synced latest official release; setting a value pins it and stops following auto-sync. The SUB2API_CLAUDE_CLI_VERSION environment variable or built-in version is used only when neither the manual nor synced value is valid.", claudeCodeVersionAutoSync: 'Auto-sync Claude Code version', - claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the environment-variable fallback is used.', + claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, fetching stops but the previously synced version remains available. The manual version above always takes priority.', claudeCodeVersionSyncedValue: 'Currently synced: {version}', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 564b85bf14..2125ca98e6 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -536,9 +536,9 @@ export default { openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。', openaiCodexVersionSyncedValue: '当前同步到:{version}', claudeCodeClientVersion: 'Claude Code 客户端版本号', - claudeCodeClientVersionHint: '网关伪装为官方 Claude Code CLI 时对上游声明的客户端版本号。留空表示使用自动同步到的官方最新版本;填写后固定为该版本,不再跟随同步。环境变量 SUB2API_CLAUDE_CLI_VERSION 仍作为兜底,优先级高于此处配置。', + claudeCodeClientVersionHint: '网关伪装为官方 Claude Code CLI 时对上游声明的客户端版本号。留空表示使用自动同步到的官方最新版本;填写后固定为该版本,不再跟随同步。仅在手填值和同步值均不可用时,才回退到环境变量 SUB2API_CLAUDE_CLI_VERSION 或内置版本。', claudeCodeVersionAutoSync: '自动同步 Claude Code 版本号', - claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或环境变量兜底。', + claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后停止获取新版本,已同步的版本仍可使用;上方手填版本始终优先。', claudeCodeVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', From 6624947b627b6f625e0e8d068cdf655125d135f0 Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 13 Aug 2026 22:22:37 +0800 Subject: [PATCH 09/21] =?UTF-8?q?feat(opencode):=20=E6=94=AF=E6=8C=81=20Op?= =?UTF-8?q?enCode=20Go=20=E5=AE=98=E6=96=B9=E7=94=A8=E9=87=8F=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=9F=A5=E8=AF=A2=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode Go 上游(https://opencode.ai/zen/go/v1)官方提供用量端点 GET /zen/go/v1/usage(Bearer 复用消费 key),返回 rolling(5h)/weekly/monthly 三窗口的 percent 与 resetsAt。本提交为 openai apikey 账号(base_url 为 opencode.ai/zen/go/v1)接入该用量窗口: - 后端:OpenCodeGoUsageService——手动刷新(每账号 10s 节流)、 周期自动刷新(全局开关 + 账号开关双开启,interval 5-1440 分钟, leader lock + 每轮 20 账号 + 并发 4)、失败指数退避(上限 24h)、 Retry-After 尊重;401→unauthorized、403→failed;snapshot 持久化于 accounts.extra(单账号写入,无分组 CAS、无会话加密) - admin API:settings GET/PUT、/:id/opencode-go-usage GET、 auto-refresh PUT、refresh POST(与 Ollama Cloud usage 同构) - 前端:账号列表行内三窗口进度条(复用 UsageProgressBar)、 编辑弹窗内配置面板(详情+手动刷新+auto-refresh)、全局设置卡片、 en/zh i18n 验证: - 上游端点已用生产真实 Go key 实测 200,响应 {"usage":{"rolling":{"status":"ok","percent":6,...},...}}(2026-08-13) - go build ./... OK;service/handler/repo/dto/cmd 全量回归 OK - 新增单测 20 例(解析 401/403/畸形 JSON、退避、节流等)全绿 - frontend vitest 新增 7 例 + Ollama 回归 4 例全绿;vue-tsc 零错误 --- backend/cmd/server/wire.go | 7 + backend/cmd/server/wire_gen.go | 12 +- backend/cmd/server/wire_gen_test.go | 1 + .../internal/handler/admin/account_handler.go | 5 + .../admin/account_opencode_go_usage.go | 121 +++ .../admin/account_opencode_go_usage_test.go | 170 ++++ backend/internal/handler/dto/mappers.go | 9 +- backend/internal/handler/dto/types.go | 1 + backend/internal/handler/wire.go | 2 + .../account_repo_opencode_go_usage.go | 150 ++++ backend/internal/server/routes/admin.go | 5 + backend/internal/service/domain_constants.go | 3 + backend/internal/service/opencode_go_usage.go | 750 ++++++++++++++++++ .../service/opencode_go_usage_test.go | 496 ++++++++++++ backend/internal/service/wire.go | 1 + .../admin.accounts.opencodeGoUsage.spec.ts | 58 ++ frontend/src/api/admin/accounts.ts | 43 +- .../components/account/AccountUsageCell.vue | 5 + .../components/account/EditAccountModal.vue | 174 +++- .../account/OpenCodeGoUsageCell.vue | 60 ++ .../__tests__/OpenCodeGoUsageCell.spec.ts | 137 ++++ .../src/i18n/locales/en/admin/accounts.ts | 27 + .../src/i18n/locales/en/admin/settings.ts | 10 + .../src/i18n/locales/zh/admin/accounts.ts | 27 + .../src/i18n/locales/zh/admin/settings.ts | 10 + frontend/src/types/index.ts | 38 + frontend/src/views/admin/SettingsView.vue | 105 +++ 27 files changed, 2420 insertions(+), 7 deletions(-) create mode 100644 backend/internal/handler/admin/account_opencode_go_usage.go create mode 100644 backend/internal/handler/admin/account_opencode_go_usage_test.go create mode 100644 backend/internal/repository/account_repo_opencode_go_usage.go create mode 100644 backend/internal/service/opencode_go_usage.go create mode 100644 backend/internal/service/opencode_go_usage_test.go create mode 100644 frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts create mode 100644 frontend/src/components/account/OpenCodeGoUsageCell.vue create mode 100644 frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index f2f5bcf7f3..06674251af 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -125,6 +125,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, @@ -385,6 +386,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 37390f92cf..d77f515e1a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -287,7 +287,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) + openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -351,7 +352,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -432,6 +433,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, @@ -691,6 +693,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index dc84f6c36d..d14ca19db6 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -95,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // quotaFlusher nil, // upstreamBillingProbe nil, // ollamaCloudUsage + nil, // opencodeGoUsage nil, // auditLog nil, // openAIAutoReset nil, // promptAudit diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 52500c4222..8705ff1c78 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -66,6 +66,7 @@ type AccountHandler struct { upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService cfg *config.Config + opencodeGoUsage *service.OpenCodeGoUsageService } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -77,6 +78,10 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs h.ollamaCloudUsage = usage } +func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) { + h.opencodeGoUsage = usage +} + // NewAccountHandler creates a new admin account handler func NewAccountHandler( adminService service.AdminService, diff --git a/backend/internal/handler/admin/account_opencode_go_usage.go b/backend/internal/handler/admin/account_opencode_go_usage.go new file mode 100644 index 0000000000..ee19b0129d --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage.go @@ -0,0 +1,121 @@ +package admin + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type openCodeGoUsageAutoRefreshRequest struct { + Enabled *bool `json:"enabled" binding:"required"` +} + +func (h *AccountHandler) GetOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) UpdateOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + var req service.OpenCodeGoUsageSettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if err := h.opencodeGoUsage.UpdateSettings(c.Request.Context(), &req); err != nil { + response.ErrorFrom(c, err) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) GetOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.GetState(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) SetOpenCodeGoUsageAutoRefresh(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + var req openCodeGoUsageAutoRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + state, err := h.opencodeGoUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) RefreshOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.Refresh(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) requireOpenCodeGoUsage(c *gin.Context) bool { + if h != nil && h.opencodeGoUsage != nil { + return true + } + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return false +} + +func openCodeGoUsageAccountID(c *gin.Context) (int64, bool) { + if c == nil { + return 0, false + } + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || accountID <= 0 { + response.BadRequest(c, "Invalid account ID") + return 0, false + } + return accountID, true +} diff --git a/backend/internal/handler/admin/account_opencode_go_usage_test.go b/backend/internal/handler/admin/account_opencode_go_usage_test.go new file mode 100644 index 0000000000..e1ad0015fe --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage_test.go @@ -0,0 +1,170 @@ +package admin + +import ( + "bytes" + "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" +) + +type openCodeGoUsageHandlerTestRepo struct { + service.AccountRepository + account *service.Account + accounts []*service.Account +} + +func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) { + if r.account != nil && r.account.ID == id { + return r.account, nil + } + for _, account := range r.accounts { + if account.ID == id { + return account, nil + } + } + return nil, service.ErrAccountNotFound +} + +func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) { + return nil, nil +} + +func newOpenCodeGoUsageHandlerTestService(t *testing.T) *service.OpenCodeGoUsageService { + t.Helper() + svc := service.NewOpenCodeGoUsageService(nil, nil, nil) + t.Cleanup(svc.Stop) + return svc +} + +func newOpenCodeGoUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(method, target, bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = request + if id != "" { + ctx.Params = gin.Params{{Key: "id", Value: id}} + } + return ctx, recorder +} + +func TestOpenCodeGoUsageHandlersValidateRequestsAndDependencies(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newOpenCodeGoUsageHandlerTestService(t) + + t.Run("invalid account id", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/opencode-go-usage", "", "not-an-id") + (&AccountHandler{opencodeGoUsage: svc}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("missing enabled", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodPut, "/admin/accounts/7/opencode-go-usage/auto-refresh", `{}`, "7") + (&AccountHandler{opencodeGoUsage: svc}).SetOpenCodeGoUsageAutoRefresh(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("service unavailable", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/7/opencode-go-usage", "", "7") + (&AccountHandler{}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Contains(t, recorder.Body.String(), "OPENCODE_GO_USAGE_UNAVAILABLE") + }) +} + +func TestGetOpenCodeGoUsageSettingsHandlerSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/opencode-go-usage/settings", "", "") + handler := &AccountHandler{opencodeGoUsage: newOpenCodeGoUsageHandlerTestService(t)} + + handler.GetOpenCodeGoUsageSettings(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"enabled":false`) + require.Contains(t, recorder.Body.String(), `"interval_minutes":15`) +} + +func TestOpenCodeGoUsageStateEmbeddedInListAndDetail(t *testing.T) { + gin.SetMode(gin.TestMode) + now := time.Now().UTC() + account := &service.Account{ + ID: 7, Name: "opencode", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, Data: &service.OpenCodeGoUsageData{ + Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6}, + }, + LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour), + }, + }, + Status: service.StatusActive, + } + repo := &openCodeGoUsageHandlerTestRepo{account: account} + adminService := newStubAdminService() + adminService.accounts = []service.Account{*account} + adminService.getAccountResult = account + usageService := service.NewOpenCodeGoUsageService(repo, nil, nil) + t.Cleanup(usageService.Stop) + handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler.SetOpenCodeGoUsageService(usageService) + router := gin.New() + router.GET("/accounts", handler.List) + router.GET("/accounts/:id", handler.GetByID) + router.GET("/accounts/:id/opencode-go-usage", handler.GetOpenCodeGoUsage) + + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil)) + require.Equal(t, http.StatusOK, listRecorder.Code) + var listPayload struct { + Data struct { + Items []struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload)) + require.Len(t, listPayload.Data.Items, 1) + require.NotNil(t, listPayload.Data.Items[0].OpenCodeGoUsage) + require.True(t, listPayload.Data.Items[0].OpenCodeGoUsage.AutoRefreshEnabled) + require.Equal(t, 6.0, listPayload.Data.Items[0].OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + detailRecorder := httptest.NewRecorder() + router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil)) + require.Equal(t, http.StatusOK, detailRecorder.Code) + var detailPayload struct { + Data struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload)) + require.NotNil(t, detailPayload.Data.OpenCodeGoUsage) + require.Equal(t, 6.0, detailPayload.Data.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + stateRecorder := httptest.NewRecorder() + router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/opencode-go-usage", nil)) + require.Equal(t, http.StatusOK, stateRecorder.Code) + var statePayload struct { + Data service.OpenCodeGoUsageState `json:"data"` + } + require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload)) + require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OpenCodeGoUsage.Snapshot) + + for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} { + require.NotContains(t, body, "test-key") + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index bb29600c67..c30ce7d0a9 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -241,6 +241,10 @@ func AccountFromServiceShallow(a *service.Account) *Account { if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible { ollamaCloudUsage = state } + var openCodeGoUsage *service.OpenCodeGoUsageState + if state := service.OpenCodeGoUsageStateFromAccount(a); state.Eligible { + openCodeGoUsage = state + } out := &Account{ ID: a.ID, Name: a.Name, @@ -251,6 +255,7 @@ func AccountFromServiceShallow(a *service.Account) *Account { CredentialsStatus: credsStatus, Extra: extra, OllamaCloudUsage: ollamaCloudUsage, + OpenCodeGoUsage: openCodeGoUsage, ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, @@ -417,7 +422,9 @@ func redactAccountManagedExtra(extra map[string]any) map[string]any { switch key { case service.OllamaCloudUsageSessionExtraKey, service.OllamaCloudUsageAutoRefreshExtraKey, - service.OllamaCloudUsageSnapshotExtraKey: + service.OllamaCloudUsageSnapshotExtraKey, + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey: continue default: redacted[key] = value diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7c952af6d2..1177f68737 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -216,6 +216,7 @@ type Account struct { CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` Extra map[string]any `json:"extra"` OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index d35245ecbf..bac8e0a3a0 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -49,9 +49,11 @@ func ProvideAdminHandlers( auditLogHandler *admin.AuditLogHandler, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) + accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage) return &AdminHandlers{ Dashboard: dashboardHandler, User: userHandler, diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go new file mode 100644 index 0000000000..ef645301ca --- /dev/null +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -0,0 +1,150 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const ( + opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI]/[zZ][eE][nN]/[gG][oO]/[vV]1/?$` + opencodeGoBaseURLMatchSQLPrefix = "btrim(" + opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'" + opencodeGoUsageEligibleSQL = ` + platform = 'openai' + AND type = 'apikey' + AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + ` + AND jsonb_typeof(credentials -> 'api_key') = 'string' +` +) + +// SetOpenCodeGoUsageAutoRefresh persists the per-account auto-refresh switch. +func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: enabled, + }) +} + +// UpdateOpenCodeGoUsageSnapshot persists the per-account usage snapshot. +func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error { + if account == nil || snapshot == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ + service.OpenCodeGoUsageSnapshotExtraKey: snapshot, + }) +} + +// updateOpenCodeGoUsageExtra atomically merges managed extra keys onto the +// account row. Snapshots are written per account (no api_key group CAS). +func (r *accountRepository) updateOpenCodeGoUsageExtra(ctx context.Context, account *service.Account, payload map[string]any) error { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + apply := func(txCtx context.Context, client *dbent.Client) error { + result, err := client.ExecContext(txCtx, ` + UPDATE accounts + SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, + updated_at = NOW() + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND id = $2 + `, string(encoded), account.ID) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return service.ErrOpenCodeGoUsageIdentityChanged + } + return nil + } + if dbent.TxFromContext(ctx) != nil { + return apply(ctx, clientFromContext(ctx, r.client)) + } + tx, err := r.client.Tx(ctx) + if errors.Is(err, dbent.ErrTxStarted) { + return apply(ctx, r.client) + } + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(ctx, tx) + if err := apply(txCtx, tx.Client()); err != nil { + return err + } + return tx.Commit() +} + +// ListDueOpenCodeGoUsageAccounts returns at most limit eligible accounts whose +// auto-refresh is enabled and whose snapshot is missing or due (next_refresh_at +// at or before now). Invalid/missing next_refresh_at values fail open to due. +func (r *accountRepository) ListDueOpenCodeGoUsageAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { + if limit <= 0 { + return []service.Account{}, nil + } + if r == nil || r.sql == nil { + return nil, errors.New("account repository SQL executor not configured") + } + nextRefreshExpr := "extra -> 'opencode_go_usage_snapshot' #>> '{next_refresh_at}'" + rows, err := r.sql.QueryContext(ctx, ` + SELECT id + FROM accounts + WHERE deleted_at IS NULL + AND status = 'active' + AND `+opencodeGoUsageEligibleSQL+` + AND extra @> '{"opencode_go_usage_auto_refresh": true}'::jsonb + AND ( + extra -> 'opencode_go_usage_snapshot' IS NULL + OR extra -> 'opencode_go_usage_snapshot' = 'null'::jsonb + OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+` IS NULL + OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+`::timestamptz <= $1 + ) + ORDER BY id + LIMIT $2 + `, now.UTC(), limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + hydrated, err := r.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + result := make([]service.Account, 0, len(hydrated)) + for _, account := range hydrated { + if account != nil { + result = append(result, *account) + } + } + return result, nil +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ff9ffa4943..d0737f0397 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -364,6 +364,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch) accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings) accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings) + accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings) + accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) @@ -381,6 +383,9 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.DELETE("/:id/ollama-cloud-usage/session", h.Admin.Account.DeleteOllamaCloudUsageSession) accounts.PUT("/:id/ollama-cloud-usage/auto-refresh", h.Admin.Account.SetOllamaCloudUsageAutoRefresh) accounts.POST("/:id/ollama-cloud-usage/refresh", h.Admin.Account.RefreshOllamaCloudUsage) + accounts.GET("/:id/opencode-go-usage", h.Admin.Account.GetOpenCodeGoUsage) + accounts.PUT("/:id/opencode-go-usage/auto-refresh", h.Admin.Account.SetOpenCodeGoUsageAutoRefresh) + accounts.POST("/:id/opencode-go-usage/refresh", h.Admin.Account.RefreshOpenCodeGoUsage) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9781a1ae7e..9118d28316 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -570,6 +570,9 @@ const ( // SettingKeyOllamaCloudUsageSettings stores the opt-in global runner switch and interval. SettingKeyOllamaCloudUsageSettings = "ollama_cloud_usage_settings" + // SettingKeyOpenCodeGoUsageSettings stores the opt-in global runner switch and interval. + SettingKeyOpenCodeGoUsageSettings = "opencode_go_usage_settings" + // ========================= // Overload Cooldown (529) // ========================= diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go new file mode 100644 index 0000000000..88564c36d4 --- /dev/null +++ b/backend/internal/service/opencode_go_usage.go @@ -0,0 +1,750 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" +) + +const ( + OpenCodeGoUsageAutoRefreshExtraKey = "opencode_go_usage_auto_refresh" + OpenCodeGoUsageSnapshotExtraKey = "opencode_go_usage_snapshot" + + opencodeGoUsageAPIURL = "https://opencode.ai/zen/go/v1/usage" + opencodeGoUsageDefaultIntervalMinutes = 15 + opencodeGoUsageMinIntervalMinutes = 5 + opencodeGoUsageMaxIntervalMinutes = 24 * 60 + opencodeGoUsageCycleInterval = time.Minute + opencodeGoUsageManualRefreshInterval = 10 * time.Second + opencodeGoUsageRequestTimeout = 15 * time.Second + opencodeGoUsageMaxBodyBytes = 512 * 1024 + opencodeGoUsageMaxPerCycle = 20 + opencodeGoUsageConcurrency = 4 + opencodeGoUsageMaxDelay = 24 * time.Hour + opencodeGoUsageLeaderLockKey = "opencode:go:usage:leader" + opencodeGoUsageLeaderLockTTL = 2 * time.Minute +) + +var ( + ErrOpenCodeGoUsageUnavailable = infraerrors.ServiceUnavailable( + "OPENCODE_GO_USAGE_UNAVAILABLE", "OpenCode Go usage is unavailable", + ) + ErrOpenCodeGoUsageAccountInvalid = infraerrors.BadRequest( + "OPENCODE_GO_USAGE_ACCOUNT_INVALID", "account must be an OpenAI API key account using https://opencode.ai/zen/go/v1", + ) + ErrOpenCodeGoUsageIdentityChanged = infraerrors.Conflict( + "OPENCODE_GO_USAGE_IDENTITY_CHANGED", "account identity or proxy changed during refresh; retry", + ) + ErrOpenCodeGoUsageRefreshRateLimited = infraerrors.TooManyRequests( + "OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED", "OpenCode Go usage can be refreshed manually once every 10 seconds", + ) +) + +const ( + OpenCodeGoUsageStatusOK = "ok" + OpenCodeGoUsageStatusUnauthorized = "unauthorized" + OpenCodeGoUsageStatusFailed = "failed" +) + +// OpenCodeGoUsageSettings controls the opt-in periodic refresh runner. +type OpenCodeGoUsageSettings struct { + Enabled bool `json:"enabled"` + IntervalMinutes int `json:"interval_minutes"` +} + +// OpenCodeGoUsageWindow is a narrow, sanitized view of one official usage window. +type OpenCodeGoUsageWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt time.Time `json:"resets_at"` +} + +// OpenCodeGoUsageData intentionally excludes raw upstream payload details. +type OpenCodeGoUsageData struct { + Rolling OpenCodeGoUsageWindow `json:"rolling"` + Weekly OpenCodeGoUsageWindow `json:"weekly"` + Monthly OpenCodeGoUsageWindow `json:"monthly"` +} + +// OpenCodeGoUsageSnapshot is the only usage observation persisted in account extra. +type OpenCodeGoUsageSnapshot struct { + Status string `json:"status"` + Data *OpenCodeGoUsageData `json:"data,omitempty"` + FetchedAt *time.Time `json:"fetched_at,omitempty"` + LastAttemptAt time.Time `json:"last_attempt_at"` + NextRefreshAt time.Time `json:"next_refresh_at"` + FailureCount int `json:"failure_count,omitempty"` + HTTPStatus int `json:"http_status,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// OpenCodeGoUsageState is the dedicated DTO exposed to administrators. +type OpenCodeGoUsageState struct { + AccountID int64 `json:"account_id"` + Eligible bool `json:"eligible"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + Snapshot *OpenCodeGoUsageSnapshot `json:"snapshot,omitempty"` +} + +type openCodeGoUsageRepository interface { + SetOpenCodeGoUsageAutoRefresh(context.Context, *Account, bool) error + UpdateOpenCodeGoUsageSnapshot(context.Context, *Account, *OpenCodeGoUsageSnapshot) error + ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]Account, error) +} + +// GetOpenCodeGoUsageSettings returns fail-safe defaults when the setting is absent. +func (s *SettingService) GetOpenCodeGoUsageSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + defaults := defaultOpenCodeGoUsageSettings() + if s == nil || s.settingRepo == nil { + return defaults, nil + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpenCodeGoUsageSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return defaults, nil + } + return nil, fmt.Errorf("get OpenCode Go usage settings: %w", err) + } + if strings.TrimSpace(raw) == "" { + return defaults, nil + } + settings := *defaults + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + return nil, fmt.Errorf("parse OpenCode Go usage settings: %w", err) + } + if settings.IntervalMinutes == 0 { + settings.IntervalMinutes = defaults.IntervalMinutes + } + normalizeOpenCodeGoUsageSettings(&settings) + return &settings, nil +} + +func (s *SettingService) SetOpenCodeGoUsageSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingRepo == nil { + return ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + return infraerrors.BadRequest("INVALID_OPENCODE_GO_USAGE_SETTINGS", "settings cannot be nil") + } + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes || settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_INTERVAL", + fmt.Sprintf("interval_minutes must be between %d and %d", opencodeGoUsageMinIntervalMinutes, opencodeGoUsageMaxIntervalMinutes), + ) + } + normalizeOpenCodeGoUsageSettings(settings) + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal OpenCode Go usage settings: %w", err) + } + return s.settingRepo.Set(ctx, SettingKeyOpenCodeGoUsageSettings, string(data)) +} + +func defaultOpenCodeGoUsageSettings() *OpenCodeGoUsageSettings { + return &OpenCodeGoUsageSettings{ + Enabled: false, + IntervalMinutes: opencodeGoUsageDefaultIntervalMinutes, + } +} + +func normalizeOpenCodeGoUsageSettings(settings *OpenCodeGoUsageSettings) { + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMinIntervalMinutes + } + if settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMaxIntervalMinutes + } +} + +// openCodeGoUsageIsAutoRefreshDue decides whether a configured auto-refresh +// account should fetch now. Missing or invalid snapshots fail open to a first +// fetch; otherwise the next_refresh_at horizon (success interval or failure +// backoff) decides. +func openCodeGoUsageIsAutoRefreshDue(snapshot *OpenCodeGoUsageSnapshot, now time.Time) bool { + if snapshot == nil { + return true + } + if snapshot.NextRefreshAt.IsZero() { + return true + } + return !now.Before(snapshot.NextRefreshAt) +} + +// OpenCodeGoUsageService refreshes the official usage JSON without affecting routing state. +type OpenCodeGoUsageService struct { + accountRepo AccountRepository + httpUpstream HTTPUpstream + settingService *SettingService + + parentCtx context.Context + parentCancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + started bool + stopped bool + cycleMu sync.Mutex + refreshGroup singleflight.Group + refreshSlots chan struct{} + now func() time.Time + lockCache LeaderLockCache + db *sql.DB + instanceID string +} + +func NewOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, +) *OpenCodeGoUsageService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenCodeGoUsageService{ + accountRepo: accountRepo, + httpUpstream: httpUpstream, + settingService: settingService, + parentCtx: ctx, + parentCancel: cancel, + refreshSlots: make(chan struct{}, opencodeGoUsageConcurrency), + now: time.Now, + instanceID: uuid.NewString(), + } +} + +func ProvideOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, + lockCache LeaderLockCache, + db *sql.DB, +) *OpenCodeGoUsageService { + svc := NewOpenCodeGoUsageService(accountRepo, httpUpstream, settingService) + svc.lockCache = lockCache + svc.db = db + svc.Start() + return svc +} + +func (s *OpenCodeGoUsageService) Start() { + if s == nil { + return + } + s.mu.Lock() + if s.started || s.stopped { + s.mu.Unlock() + return + } + s.started = true + s.wg.Add(1) + s.mu.Unlock() + go s.runLoop() +} + +func (s *OpenCodeGoUsageService) Stop() { + if s == nil { + return + } + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.stopped = true + s.parentCancel() + s.mu.Unlock() + s.wg.Wait() +} + +func (s *OpenCodeGoUsageService) runLoop() { + defer s.wg.Done() + _ = s.RunDue(s.parentCtx) + ticker := time.NewTicker(opencodeGoUsageCycleInterval) + defer ticker.Stop() + for { + select { + case <-s.parentCtx.Done(): + return + case <-ticker.C: + if err := s.RunDue(s.parentCtx); err != nil { + logger.LegacyPrintf("service.opencode_go_usage", "run_due_failed: err=%v", err) + } + } + } +} + +func (s *OpenCodeGoUsageService) GetSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + if s == nil || s.settingService == nil { + return defaultOpenCodeGoUsageSettings(), nil + } + return s.settingService.GetOpenCodeGoUsageSettings(ctx) +} + +func (s *OpenCodeGoUsageService) UpdateSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingService == nil { + return ErrOpenCodeGoUsageUnavailable + } + return s.settingService.SetOpenCodeGoUsageSettings(ctx, settings) +} + +func (s *OpenCodeGoUsageService) GetState(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + return OpenCodeGoUsageStateFromAccount(account), nil +} + +func (s *OpenCodeGoUsageService) SetAutoRefresh(ctx context.Context, accountID int64, enabled bool) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if !IsOpenCodeGoUsageAccount(account) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return nil, ErrOpenCodeGoUsageUnavailable + } + if err := writer.SetOpenCodeGoUsageAutoRefresh(ctx, account, enabled); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) Refresh(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + settings, err := s.GetSettings(ctx) + if err != nil { + return nil, err + } + if _, err := s.refreshAccount(ctx, accountID, settings, false); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) RunDue(ctx context.Context) error { + if s == nil || s.accountRepo == nil { + return nil + } + s.cycleMu.Lock() + defer s.cycleMu.Unlock() + settings, err := s.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + release, acquired := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, opencodeGoUsageLeaderLockKey, s.instanceID, opencodeGoUsageLeaderLockTTL) + if !acquired { + return nil + } + defer release() + + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + now := s.currentTime() + accounts, err := writer.ListDueOpenCodeGoUsageAccounts(ctx, now, opencodeGoUsageMaxPerCycle) + if err != nil { + return fmt.Errorf("list due OpenCode Go usage accounts: %w", err) + } + var group errgroup.Group + for index := range accounts { + account := accounts[index] + if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(&account) { + continue + } + snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra) + if !openCodeGoUsageIsAutoRefreshDue(snapshot, now) { + continue + } + accountID := account.ID + group.Go(func() error { + if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { + logger.LegacyPrintf("service.opencode_go_usage", "refresh_due_failed: account_id=%d err=%v", accountID, refreshErr) + } + return nil + }) + } + return group.Wait() +} + +func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID int64, settings *OpenCodeGoUsageSettings, requireEnabled bool) (*OpenCodeGoUsageSnapshot, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + settings = defaultOpenCodeGoUsageSettings() + } + intervalMinutes := settings.IntervalMinutes + anchor, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if !IsOpenCodeGoUsageAccount(anchor) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + key := strconv.FormatInt(accountID, 10) + value, err, _ := s.refreshGroup.Do(key, func() (any, error) { + select { + case s.refreshSlots <- struct{}{}: + defer func() { <-s.refreshSlots }() + case <-ctx.Done(): + return nil, ctx.Err() + } + account, loadErr := s.accountRepo.GetByID(ctx, accountID) + if loadErr != nil { + return nil, loadErr + } + if !IsOpenCodeGoUsageAccount(account) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if !requireEnabled { + if snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra); snapshot != nil && !snapshot.LastAttemptAt.IsZero() { + retryAt := snapshot.LastAttemptAt.Add(opencodeGoUsageManualRefreshInterval) + if now := s.currentTime(); now.Before(retryAt) { + remaining := retryAt.Sub(now) + seconds := int((remaining + time.Second - 1) / time.Second) + return nil, ErrOpenCodeGoUsageRefreshRateLimited.WithMetadata(map[string]string{ + "retry_after_seconds": strconv.Itoa(seconds), + }) + } + } + } + if requireEnabled { + if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(account) { + return nil, nil + } + if !openCodeGoUsageIsAutoRefreshDue(decodeOpenCodeGoUsageSnapshot(account.Extra), s.currentTime()) { + return nil, nil + } + } + return s.refreshLoadedAccount(ctx, account, intervalMinutes) + }) + if err != nil || value == nil { + return nil, err + } + snapshot, ok := value.(*OpenCodeGoUsageSnapshot) + if !ok { + return nil, fmt.Errorf("invalid OpenCode Go usage refresh result") + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) refreshLoadedAccount(ctx context.Context, account *Account, intervalMinutes int) (*OpenCodeGoUsageSnapshot, error) { + now := s.currentTime().UTC() + apiKey, _ := account.Credentials["api_key"].(string) + if apiKey == "" { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if s.httpUpstream == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + proxyURL := "" + if account.ProxyID != nil { + if account.Proxy == nil || account.Proxy.ID != *account.ProxyID { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + proxyURL = account.Proxy.URL() + } + requestCtx, cancel := context.WithTimeout(WithHTTPUpstreamRedirectsDisabled(ctx), opencodeGoUsageRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, opencodeGoUsageAPIURL, nil) + if err != nil || !isExactOpenCodeGoUsageURL(req.URL) { + return nil, ErrOpenCodeGoUsageUnavailable + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", "sub2api-opencode-go-usage/1") + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "request_failed", 0, false) + } + if resp == nil || resp.Body == nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "empty_response", 0, false) + } + defer func() { _ = resp.Body.Close() }() + if resp.Request != nil && !isExactOpenCodeGoUsageURL(resp.Request.URL) { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_host_mismatch", 0, false) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "redirect_blocked", retryAfter(resp.Header, now), false) + } + if resp.StatusCode == http.StatusUnauthorized { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "unauthorized", retryAfter(resp.Header, now), true) + } + if resp.StatusCode == http.StatusForbidden { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "OpenCode Go subscription required (403)", retryAfter(resp.Header, now), false) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "http_error", retryAfter(resp.Header, now), false) + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, opencodeGoUsageMaxBodyBytes+1)) + if readErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_read_failed", 0, false) + } + if len(body) > opencodeGoUsageMaxBodyBytes { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_too_large", 0, false) + } + data, parseErr := parseOpenCodeGoUsageJSON(body) + if parseErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "invalid_json", 0, false) + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + Data: data, + FetchedAt: &now, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, 0, 0)), + HTTPStatus: resp.StatusCode, + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) persistFailure( + ctx context.Context, + account *Account, + intervalMinutes int, + now time.Time, + httpStatus int, + reason string, + retryAfterDuration time.Duration, + unauthorized bool, +) (*OpenCodeGoUsageSnapshot, error) { + previous := decodeOpenCodeGoUsageSnapshot(account.Extra) + failureCount := 1 + if previous != nil { + failureCount = previous.FailureCount + 1 + } + status := OpenCodeGoUsageStatusFailed + if unauthorized { + status = OpenCodeGoUsageStatusUnauthorized + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: status, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, failureCount, retryAfterDuration)), + FailureCount: failureCount, + HTTPStatus: httpStatus, + LastError: reason, + } + if previous != nil { + snapshot.Data = previous.Data + snapshot.FetchedAt = previous.FetchedAt + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) updateSnapshot(ctx context.Context, account *Account, snapshot *OpenCodeGoUsageSnapshot) error { + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + return writer.UpdateOpenCodeGoUsageSnapshot(ctx, account, snapshot) +} + +func OpenCodeGoUsageStateFromAccount(account *Account) *OpenCodeGoUsageState { + state := &OpenCodeGoUsageState{} + if account == nil { + return state + } + state.AccountID = account.ID + state.Eligible = IsOpenCodeGoUsageAccount(account) + if !state.Eligible { + return state + } + state.AutoRefreshEnabled = openCodeGoUsageAutoRefreshEnabled(account) + state.Snapshot = decodeOpenCodeGoUsageSnapshot(account.Extra) + return state +} + +func IsOpenCodeGoUsageAccount(account *Account) bool { + if account == nil || account.Type != AccountTypeAPIKey || account.Platform != PlatformOpenAI { + return false + } + baseURL, _ := account.Credentials["base_url"].(string) + return isOpenCodeGoBaseURL(baseURL) +} + +func isOpenCodeGoBaseURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || strings.ContainsAny(raw, "?#") { + return false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Opaque != "" || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil || parsed.ForceQuery || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.RawFragment != "" { + return false + } + hostname := strings.ToLower(parsed.Hostname()) + if hostname != "opencode.ai" { + return false + } + authority := strings.ToLower(parsed.Host) + if authority != hostname && authority != hostname+":443" { + return false + } + if parsed.RawPath != "" { + return false + } + return strings.EqualFold(strings.TrimSuffix(parsed.Path, "/"), "/zen/go/v1") +} + +func isExactOpenCodeGoUsageURL(parsed *url.URL) bool { + return parsed != nil && parsed.Scheme == "https" && parsed.Host == "opencode.ai" && parsed.Path == "/zen/go/v1/usage" && + parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" && parsed.RawPath == "" +} + +func openCodeGoUsageAutoRefreshEnabled(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + enabled, ok := account.Extra[OpenCodeGoUsageAutoRefreshExtraKey].(bool) + return ok && enabled +} + +func decodeOpenCodeGoUsageSnapshot(extra map[string]any) *OpenCodeGoUsageSnapshot { + if extra == nil { + return nil + } + value, ok := extra[OpenCodeGoUsageSnapshotExtraKey] + if !ok || value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var snapshot OpenCodeGoUsageSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + return nil + } + if snapshot.Status != OpenCodeGoUsageStatusOK && snapshot.Status != OpenCodeGoUsageStatusUnauthorized && snapshot.Status != OpenCodeGoUsageStatusFailed { + return nil + } + return &snapshot +} + +// nextOpenCodeGoUsageDelay computes the not-before delay for the next refresh: +// interval * 2^min(failureCount-1, 6) capped at 24h, ±10% jitter capped at 5min, +// never below a Retry-After hint or one minute. +func nextOpenCodeGoUsageDelay(intervalMinutes, failureCount int, retryAfterDuration time.Duration) time.Duration { + minimumDelay := retryAfterDuration + base := time.Duration(intervalMinutes) * time.Minute + if base < opencodeGoUsageMinIntervalMinutes*time.Minute { + base = opencodeGoUsageMinIntervalMinutes * time.Minute + } + if failureCount > 0 { + shift := min(failureCount-1, 6) + base *= time.Duration(1 << shift) + } + if base > opencodeGoUsageMaxDelay { + base = opencodeGoUsageMaxDelay + } + if retryAfterDuration > base { + base = retryAfterDuration + } + jitterRange := base / 10 + if jitterRange > 5*time.Minute { + jitterRange = 5 * time.Minute + } + if jitterRange > 0 { + base += time.Duration(rand.Int64N(int64(jitterRange)*2+1)) - jitterRange + } + if base < minimumDelay { + return minimumDelay + } + if base < time.Minute { + return time.Minute + } + return base +} + +func (s *OpenCodeGoUsageService) currentTime() time.Time { + if s != nil && s.now != nil { + return s.now() + } + return time.Now() +} + +type openCodeGoUsageAPIWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resetsAt"` +} + +type openCodeGoUsageAPIUsage struct { + Rolling *openCodeGoUsageAPIWindow `json:"rolling"` + Weekly *openCodeGoUsageAPIWindow `json:"weekly"` + Monthly *openCodeGoUsageAPIWindow `json:"monthly"` +} + +type openCodeGoUsageAPIResponse struct { + Usage *openCodeGoUsageAPIUsage `json:"usage"` +} + +// parseOpenCodeGoUsageJSON parses the upstream usage payload leniently: the top +// level may wrap the windows in a "usage" object or expose them directly, and +// missing windows/fields degrade to zero values instead of failing the whole +// parse. Only structurally invalid JSON is an error. +func parseOpenCodeGoUsageJSON(body []byte) (*OpenCodeGoUsageData, error) { + var wrapped openCodeGoUsageAPIResponse + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + usage := wrapped.Usage + if usage == nil { + var direct openCodeGoUsageAPIUsage + if err := json.Unmarshal(body, &direct); err != nil { + return nil, err + } + usage = &direct + } + data := &OpenCodeGoUsageData{} + if usage.Rolling != nil { + data.Rolling = openCodeGoUsageWindowFromAPI(usage.Rolling) + } + if usage.Weekly != nil { + data.Weekly = openCodeGoUsageWindowFromAPI(usage.Weekly) + } + if usage.Monthly != nil { + data.Monthly = openCodeGoUsageWindowFromAPI(usage.Monthly) + } + return data, nil +} + +func openCodeGoUsageWindowFromAPI(window *openCodeGoUsageAPIWindow) OpenCodeGoUsageWindow { + out := OpenCodeGoUsageWindow{Status: window.Status, Percent: window.Percent} + if resetsAt, err := time.Parse(time.RFC3339, window.ResetsAt); err == nil { + out.ResetsAt = resetsAt.UTC() + } + return out +} diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go new file mode 100644 index 0000000000..15065ea0b0 --- /dev/null +++ b/backend/internal/service/opencode_go_usage_test.go @@ -0,0 +1,496 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// openCodeGoUsageFixture is the verified upstream 200 response (2026-08-13). +const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"},"weekly":{"status":"ok","percent":2,"resetsAt":"2026-08-17T00:00:00.281Z"},"monthly":{"status":"ok","percent":1,"resetsAt":"2026-09-13T13:24:47.281Z"}}}` + +type openCodeGoUsageTestRepo struct { + AccountRepository + mu sync.Mutex + accounts map[int64]*Account + due []Account +} + +func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[id] + if account == nil { + return nil, ErrAccountNotFound + } + clone := *account + clone.Credentials = mergeMap(nil, account.Credentials) + clone.Extra = mergeMap(nil, account.Extra) + return &clone, nil +} + +func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]*Account, 0, len(ids)) + for _, id := range ids { + if account := r.accounts[id]; account != nil { + result = append(result, account) + } + } + return result, nil +} + +func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[expected.ID] + if account == nil { + return ErrAccountNotFound + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled + return nil +} + +func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[expected.ID] + if account == nil { + return ErrAccountNotFound + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot + return nil +} + +func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.due) > 0 { + out := make([]Account, 0, min(limit, len(r.due))) + for _, account := range r.due[:min(limit, len(r.due))] { + out = append(out, cloneOpenCodeGoUsageTestAccount(account)) + } + return out, nil + } + out := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + out = append(out, cloneOpenCodeGoUsageTestAccount(*account)) + if len(out) == limit { + break + } + } + return out, nil +} + +func cloneOpenCodeGoUsageTestAccount(account Account) Account { + account.Credentials = mergeMap(nil, account.Credentials) + account.Extra = mergeMap(nil, account.Extra) + return account +} + +type openCodeGoUsageHTTPStub struct { + status int + body []byte + header http.Header + calls atomic.Int64 + lastRequest *http.Request + lastProxy string + mu sync.Mutex +} + +func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) { + s.calls.Add(1) + s.mu.Lock() + s.lastRequest = req + s.lastProxy = proxyURL + s.mu.Unlock() + status := s.status + if status == 0 { + status = http.StatusOK + } + header := s.header + if header == nil { + header = http.Header{"Content-Type": []string{"application/json"}} + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(bytes.NewReader(s.body)), Request: req}, nil +} + +func (s *openCodeGoUsageHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return s.Do(req, proxyURL, accountID, concurrency) +} + +func openCodeGoUsageAccount(id int64) *Account { + return &Account{ + ID: id, Name: fmt.Sprintf("opencode-%d", id), Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": fmt.Sprintf("key-%d", id)}, + Extra: map[string]any{}, Status: StatusActive, Schedulable: true, Concurrency: 1, + } +} + +func newOpenCodeGoUsageTestService(t *testing.T, repo *openCodeGoUsageTestRepo, upstream HTTPUpstream, settingsRepo SettingRepository) *OpenCodeGoUsageService { + t.Helper() + svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(settingsRepo, nil)) + t.Cleanup(svc.Stop) + return svc +} + +func TestOpenCodeGoUsageParseJSON200(t *testing.T) { + data, err := parseOpenCodeGoUsageJSON([]byte(openCodeGoUsageFixture)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Equal(t, "ok", data.Weekly.Status) + require.Equal(t, 2.0, data.Weekly.Percent) + require.Equal(t, "ok", data.Monthly.Status) + require.Equal(t, 1.0, data.Monthly.Percent) + rollingReset, err := time.Parse(time.RFC3339, "2026-08-13T18:26:39.281Z") + require.NoError(t, err) + require.Equal(t, rollingReset.UTC(), data.Rolling.ResetsAt) + weeklyReset, err := time.Parse(time.RFC3339, "2026-08-17T00:00:00.281Z") + require.NoError(t, err) + require.Equal(t, weeklyReset.UTC(), data.Weekly.ResetsAt) +} + +func TestOpenCodeGoUsageParseJSONLenient(t *testing.T) { + // top-level windows without a usage wrapper + data, err := parseOpenCodeGoUsageJSON([]byte(`{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"}}`)) + require.NoError(t, err) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Zero(t, data.Weekly.Percent) + + // missing windows/fields degrade to zero values instead of failing + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"rolling":{"status":"ok"}}}`)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Zero(t, data.Rolling.Percent) + require.True(t, data.Rolling.ResetsAt.IsZero()) + + // integer percent parses as float + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"weekly":{"status":"ok","percent":2}}}`)) + require.NoError(t, err) + require.Equal(t, 2.0, data.Weekly.Percent) + + // malformed JSON is an error + _, err = parseOpenCodeGoUsageJSON([]byte(`{"usage": {broken`)) + require.Error(t, err) +} + +func TestOpenCodeGoUsageRefresh200Success(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.True(t, state.Eligible) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) + require.Equal(t, 2.0, state.Snapshot.Data.Weekly.Percent) + require.Equal(t, 1.0, state.Snapshot.Data.Monthly.Percent) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, 0, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.FetchedAt) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + require.Equal(t, "Bearer key-7", stub.lastRequest.Header.Get("Authorization")) + require.Equal(t, "https://opencode.ai/zen/go/v1/usage", stub.lastRequest.URL.String()) + require.Equal(t, "application/json", stub.lastRequest.Header.Get("Accept")) +} + +func TestOpenCodeGoUsageRefresh401Unauthorized(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusUnauthorized} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusUnauthorized, state.Snapshot.Status) + require.Equal(t, http.StatusUnauthorized, state.Snapshot.HTTPStatus) + require.Equal(t, "unauthorized", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + // data is kept and auto-refresh stays enabled + require.True(t, openCodeGoUsageAutoRefreshEnabled(account)) +} + +func TestOpenCodeGoUsageRefresh403SubscriptionRequired(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusForbidden} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusForbidden, state.Snapshot.HTTPStatus) + require.Equal(t, "OpenCode Go subscription required (403)", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshMalformedJSON(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusOK, body: []byte(`{"usage": {broken`)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, "invalid_json", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshFailureKeepsPreviousData(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + FetchedAt: &now, LastAttemptAt: now.Add(-30 * time.Second), NextRefreshAt: now.Add(time.Hour), FailureCount: 1, + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusInternalServerError} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, "http_error", state.Snapshot.LastError) + require.Equal(t, 2, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.Data, "previous data must be retained on failure") + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) +} + +func TestNextOpenCodeGoUsageDelayBackoff(t *testing.T) { + // success: base interval with ±10% jitter + delay := nextOpenCodeGoUsageDelay(15, 0, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 1: 1x interval (2^min(0,6)) + delay = nextOpenCodeGoUsageDelay(15, 1, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 2: 2x interval + delay = nextOpenCodeGoUsageDelay(15, 2, 0) + require.GreaterOrEqual(t, delay, 27*time.Minute) + require.LessOrEqual(t, delay, 33*time.Minute) + + // failure 3: 4x interval + delay = nextOpenCodeGoUsageDelay(15, 3, 0) + require.GreaterOrEqual(t, delay, 55*time.Minute) + require.LessOrEqual(t, delay, 65*time.Minute) + + // failure 8: exponent capped at 6 → 64x interval (16h) + delay = nextOpenCodeGoUsageDelay(15, 8, 0) + require.GreaterOrEqual(t, delay, 15*time.Hour) + require.LessOrEqual(t, delay, 17*time.Hour) + + // hard cap at 24h even for huge intervals + delay = nextOpenCodeGoUsageDelay(1440, 8, 0) + require.GreaterOrEqual(t, delay, 23*time.Hour) + require.LessOrEqual(t, delay, 25*time.Hour) + + // Retry-After hint wins over the computed backoff + delay = nextOpenCodeGoUsageDelay(15, 0, 2*time.Hour) + require.GreaterOrEqual(t, delay, 2*time.Hour) + + // floor of one minute + delay = nextOpenCodeGoUsageDelay(5, 0, 0) + require.GreaterOrEqual(t, delay, time.Minute) +} + +func TestOpenCodeGoUsageManualRefreshThrottle(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-2 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageRefreshRateLimited)) + require.Equal(t, int64(0), stub.calls.Load(), "throttled refresh must not hit upstream") + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.NotEmpty(t, appErr.Metadata["retry_after_seconds"]) +} + +func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-30 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, int64(1), stub.calls.Load()) +} + +func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) { + now := time.Now().UTC() + require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, now)) + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{}, now)) + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(-time.Minute)}, now)) + require.False(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(time.Minute)}, now)) +} + +func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + settingsRepo := &upstreamBillingProbeSettingRepo{} + require.NoError(t, settingsRepo.Set(context.Background(), SettingKeyOpenCodeGoUsageSettings, `{"enabled":true,"interval_minutes":15}`)) + svc := newOpenCodeGoUsageTestService(t, repo, stub, settingsRepo) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), stub.calls.Load()) + require.Equal(t, OpenCodeGoUsageStatusOK, decodeOpenCodeGoUsageSnapshot(account.Extra).Status) +} + +func TestOpenCodeGoUsageRunDueSkipsWhenDisabled(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(0), stub.calls.Load()) +} + +func TestIsOpenCodeGoUsageAccount(t *testing.T) { + base := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}} + } + require.True(t, IsOpenCodeGoUsageAccount(base())) + + // trailing slash on the path is allowed + account := base() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // scheme/host/path are case-insensitive + account = base() + account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // wrong path + account = base() + account.Credentials["base_url"] = "https://opencode.ai/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // wrong host + account = base() + account.Credentials["base_url"] = "https://ollama.com/zen/go/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // wrong platform + account = base() + account.Platform = PlatformAnthropic + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // non-apikey type + account = base() + account.Type = "oauth" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // query string rejected + account = base() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1?x=1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // missing base_url + account = base() + account.Credentials = map[string]any{"api_key": "k"} + require.False(t, IsOpenCodeGoUsageAccount(account)) +} + +func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { + repo := &upstreamBillingProbeSettingRepo{} + settingsService := NewSettingService(repo, nil) + settings, err := settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 15, settings.IntervalMinutes) + + // below the minimum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1}) + require.Error(t, err) + // above the maximum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000}) + require.Error(t, err) + // valid update round-trips + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30}) + require.NoError(t, err) + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 30, settings.IntervalMinutes) +} + +func TestOpenCodeGoUsageStateFromAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + state := OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.Eligible) + require.False(t, state.AutoRefreshEnabled) + require.Nil(t, state.Snapshot) + + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{Status: OpenCodeGoUsageStatusOK} + state = OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.AutoRefreshEnabled) + require.NotNil(t, state.Snapshot) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + + // ineligible account exposes no managed state + account.Platform = PlatformAnthropic + state = OpenCodeGoUsageStateFromAccount(account) + require.False(t, state.Eligible) + require.Nil(t, state.Snapshot) +} + +func TestOpenCodeGoUsageRefreshRejectsIneligibleAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Credentials["base_url"] = "https://opencode.ai/v1" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageAccountInvalid)) + require.Equal(t, int64(0), stub.calls.Load()) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 8b088e68b6..31362723b5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -907,6 +907,7 @@ var ProviderSet = wire.NewSet( ProvideAccountTestService, ProvideUpstreamBillingProbeService, ProvideOllamaCloudUsageService, + ProvideOpenCodeGoUsageService, ProvideSettingService, NewDataManagementService, ProvideBackupService, diff --git a/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts new file mode 100644 index 0000000000..845f2656e2 --- /dev/null +++ b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post, put } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { get, post, put } +})) + +import { + getOpenCodeGoUsage, + getOpenCodeGoUsageSettings, + refreshOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + updateOpenCodeGoUsageSettings +} from '@/api/admin/accounts' + +const state = { + account_id: 7, + eligible: true, + auto_refresh_enabled: false +} + +describe('admin OpenCode Go usage API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + put.mockReset() + }) + + it('uses dedicated global settings endpoints', async () => { + const settings = { enabled: false, interval_minutes: 60 } + get.mockResolvedValueOnce({ data: settings }) + put.mockResolvedValueOnce({ data: settings }) + + await expect(getOpenCodeGoUsageSettings()).resolves.toEqual(settings) + await expect(updateOpenCodeGoUsageSettings(settings)).resolves.toEqual(settings) + expect(get).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings') + expect(put).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings', settings) + }) + + it('reads account state, toggles auto-refresh and triggers a manual refresh', async () => { + get.mockResolvedValueOnce({ data: state }) + put.mockResolvedValueOnce({ data: { ...state, auto_refresh_enabled: true } }) + post.mockResolvedValueOnce({ data: state }) + + await expect(getOpenCodeGoUsage(7)).resolves.toEqual(state) + await expect(setOpenCodeGoUsageAutoRefresh(7, true)).resolves.toMatchObject({ auto_refresh_enabled: true }) + await expect(refreshOpenCodeGoUsage(7)).resolves.toEqual(state) + + expect(get).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage') + expect(put).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/auto-refresh', { enabled: true }) + expect(post).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/refresh') + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 349f4328f4..e504775fa3 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -29,7 +29,9 @@ import type { OllamaCloudUsageSettings, OllamaCloudUsageState, GrokMediaEligibilityMode, - GrokMediaEligibilityState + GrokMediaEligibilityState, + OpenCodeGoUsageSettings, + OpenCodeGoUsageState } from '@/types' /** @@ -1097,6 +1099,38 @@ export async function refreshOllamaCloudUsage(id: number): Promise { + const { data } = await apiClient.get('/admin/accounts/opencode-go-usage/settings') + return data +} + +export async function updateOpenCodeGoUsageSettings( + settings: OpenCodeGoUsageSettings +): Promise { + const { data } = await apiClient.put( + '/admin/accounts/opencode-go-usage/settings', + settings + ) + return data +} + +export async function getOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/opencode-go-usage`) + return data +} + +export async function setOpenCodeGoUsageAutoRefresh(id: number, enabled: boolean): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/opencode-go-usage/auto-refresh`, { + enabled + }) + return data +} + +export async function refreshOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/opencode-go-usage/refresh`) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -1160,7 +1194,12 @@ export const accountsAPI = { saveOllamaCloudUsageSession, deleteOllamaCloudUsageSession, setOllamaCloudUsageAutoRefresh, - refreshOllamaCloudUsage + refreshOllamaCloudUsage, + getOpenCodeGoUsageSettings, + updateOpenCodeGoUsageSettings, + getOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + refreshOpenCodeGoUsage } export default accountsAPI diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 72a92dd865..e3ce9bc74f 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -581,6 +581,10 @@ :account="account" @updated="handleOllamaCloudUsageUpdated" /> +
() diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 4e9bbff7a1..8228103dd0 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1992,6 +1992,91 @@ @updated="handleOllamaCloudUsageUpdated" /> +
+
+
+

+ {{ t('admin.accounts.opencodeGo.title') }} +

+

+ {{ t('admin.accounts.opencodeGo.panelHint') }} +

+
+ + {{ opencodeGoStatusLabel }} + +
+ +
+ +
+ +
+
diff --git a/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts new file mode 100644 index 0000000000..0f7227993d --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts @@ -0,0 +1,137 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import OpenCodeGoUsageCell from '../OpenCodeGoUsageCell.vue' +import UsageProgressBar from '../UsageProgressBar.vue' +import type { Account, OpenCodeGoUsageState } from '@/types' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => { + const short: Record = { + 'admin.accounts.opencodeGo.rollingShort': '5h', + 'admin.accounts.opencodeGo.weeklyShort': 'W', + 'admin.accounts.opencodeGo.monthlyShort': 'M', + 'admin.accounts.opencodeGo.unauthorized': 'unauthorized', + 'admin.accounts.opencodeGo.failed': 'failed', + 'admin.accounts.opencodeGo.ok': 'ok' + } + return short[key] ?? key + } + }) + } +}) + +const usageState = (overrides: Partial = {}): OpenCodeGoUsageState => ({ + account_id: 7, + eligible: true, + auto_refresh_enabled: false, + snapshot: { + status: 'ok', + fetched_at: '2026-07-22T12:00:00Z', + last_attempt_at: '2026-07-22T12:00:00Z', + next_refresh_at: '2026-07-22T13:00:00Z', + data: { + rolling: { percent: 5.6, resets_at: '2026-07-23T03:00:00Z' }, + weekly: { percent: 14.2, resets_at: '2026-07-29T00:00:00Z' }, + monthly: { percent: 33.3, resets_at: '2026-08-01T00:00:00Z' } + } + }, + ...overrides +}) + +const account = (state = usageState()): Account => ({ + id: 7, + name: 'opencode', + platform: 'openai', + type: 'apikey', + opencode_go_usage: state, + proxy_id: null, + concurrency: 1, + priority: 1, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: false, + created_at: '2026-07-22T00:00:00Z', + updated_at: '2026-07-22T00:00:00Z', + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null, + session_window_end: null, + session_window_status: null +}) + +describe('OpenCodeGoUsageCell', () => { + it('renders rolling, weekly and monthly windows in a shrinkable mobile-safe cell', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const cell = wrapper.get('[data-testid="opencode-go-usage-cell"]') + expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full'])) + expect(cell.classes()).not.toContain('min-w-[12rem]') + + const bars = wrapper.findAllComponents(UsageProgressBar) + expect(bars).toHaveLength(3) + expect(bars[0].props()).toMatchObject({ + label: '5h', + utilization: 5.6, + resetsAt: '2026-07-23T03:00:00Z', + color: 'indigo' + }) + expect(bars[1].props()).toMatchObject({ + label: 'W', + utilization: 14.2, + resetsAt: '2026-07-29T00:00:00Z', + color: 'emerald' + }) + expect(bars[2].props()).toMatchObject({ + label: 'M', + utilization: 33.3, + resetsAt: '2026-08-01T00:00:00Z', + color: 'amber' + }) + + expect(wrapper.find('[data-testid="opencode-go-status-badge"]').exists()).toBe(false) + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('shows a status badge when the snapshot status is not ok', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'unauthorized', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('unauthorized') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-amber-100', 'text-amber-700'])) + }) + + it('shows a red badge for failed snapshots', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'failed', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z', last_error: 'http_error' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('failed') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-red-100', 'text-red-700'])) + }) + + it('renders a dash when the account is not eligible or has no state', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account(usageState({ eligible: false })) } }) + expect(wrapper.find('[data-testid="opencode-go-usage-cell"]').exists()).toBe(false) + expect(wrapper.text()).toBe('-') + }) + + it('reacts to an account snapshot update', async () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const next = usageState() + next.snapshot!.data!.rolling!.percent = 43 + + await wrapper.setProps({ account: account(next) }) + + expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index b25f0f8c87..01f86e9c6c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -299,6 +299,33 @@ export default { OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.' } }, + opencodeGo: { + title: 'OpenCode Go usage', + panelHint: 'Usage windows reported by the upstream OpenCode Go account. Refreshed on demand or automatically when enabled.', + notRefreshed: 'Not refreshed', + refreshNow: 'Refresh usage', + autoRefresh: 'Automatic usage refresh', + autoRefreshHint: 'Runs only when the account switch and the global switch are both enabled.', + rolling: '5 hour', + rollingShort: '5h', + weekly: 'Week', + weeklyShort: 'W', + monthly: 'Month', + monthlyShort: 'M', + status: 'Status', + updatedAt: 'Updated', + ok: 'Current', + unauthorized: 'Session expired', + failed: 'Refresh failed', + windowWithReset: '{percent} used, resets {reset}', + loadFailed: 'Failed to load OpenCode Go usage settings', + autoRefreshFailed: 'Failed to update automatic usage refresh', + refreshSuccess: 'OpenCode Go usage refreshed', + refreshFailed: 'Failed to refresh OpenCode Go usage', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.' + } + }, upstreamBilling: { trustWarning: 'This rate is declared by the upstream site for the current API key. Sub2API cannot verify that it matches actual charges. The upstream site or an intermediary may return forged, stale, or modified data. Verify it against bills, balance changes, and actual usage.', autoProbe: 'Automatically probe upstream declared rate', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 8f7e025eb0..8cdc7a31af 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -477,6 +477,16 @@ export default { saved: 'Ollama Cloud usage refresh settings saved', saveFailed: 'Failed to save Ollama Cloud usage refresh settings' }, + opencodeGoUsage: { + title: 'OpenCode Go Usage Refresh', + description: 'Refresh usage windows reported by the upstream OpenCode Go account for individually opted-in accounts. Disabled by default.', + enabled: 'Enable global automatic refresh', + enabledHint: 'Only accounts with their own automatic refresh switch enabled are refreshed. Manual refresh remains available.', + intervalMinutes: 'Refresh interval (minutes)', + intervalHint: 'Range: 5–1440 minutes.', + saved: 'OpenCode Go usage refresh settings saved', + saveFailed: 'Failed to save OpenCode Go usage refresh settings' + }, gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index fc4bb94426..ebf65505d2 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -175,6 +175,33 @@ export default { OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。' } }, + opencodeGo: { + title: 'OpenCode Go 用量', + panelHint: '上游 OpenCode Go 账号上报的用量窗口。可手动刷新,或开启自动刷新。', + notRefreshed: '尚未刷新', + refreshNow: '刷新用量', + autoRefresh: '自动刷新用量', + autoRefreshHint: '只有账号开关和全局开关同时启用时才会定时刷新。', + rolling: '5 小时', + rollingShort: '5h', + weekly: '周', + weeklyShort: '周', + monthly: '月', + monthlyShort: '月', + status: '状态', + updatedAt: '更新时间', + ok: '正常', + unauthorized: '会话已过期', + failed: '刷新失败', + windowWithReset: '已用 {percent},{reset} 重置', + loadFailed: '加载 OpenCode Go 用量设置失败', + autoRefreshFailed: '更新自动刷新设置失败', + refreshSuccess: 'OpenCode Go 用量已刷新', + refreshFailed: '刷新 OpenCode Go 用量失败', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。' + } + }, upstreamBilling: { trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。', autoProbe: '自动探测上游声明倍率', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 2125ca98e6..01ba55f317 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -470,6 +470,16 @@ export default { saved: 'Ollama Cloud 用量刷新设置已保存', saveFailed: '保存 Ollama Cloud 用量刷新设置失败' }, + opencodeGoUsage: { + title: 'OpenCode Go 用量刷新', + description: '刷新上游 OpenCode Go 账号上报的用量窗口;默认关闭,仅对单独开启的账号生效。', + enabled: '启用全局自动刷新', + enabledHint: '仅刷新账号自身也开启自动刷新的账号。手动刷新不受影响。', + intervalMinutes: '刷新间隔(分钟)', + intervalHint: '范围 5–1440 分钟。', + saved: 'OpenCode Go 用量刷新设置已保存', + saveFailed: '保存 OpenCode Go 用量刷新设置失败' + }, gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80cef707bd..afbfd2a1bd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1155,6 +1155,43 @@ export interface OllamaCloudUsageSettings { debounce_minutes: number } +export type OpenCodeGoUsageStatus = 'ok' | 'unauthorized' | 'failed' + +export interface OpenCodeGoUsageWindow { + status?: string + percent: number + resets_at?: string +} + +export interface OpenCodeGoUsageData { + rolling?: OpenCodeGoUsageWindow + weekly?: OpenCodeGoUsageWindow + monthly?: OpenCodeGoUsageWindow +} + +export interface OpenCodeGoUsageSnapshot { + status: OpenCodeGoUsageStatus + data?: OpenCodeGoUsageData + fetched_at?: string + last_attempt_at?: string + next_refresh_at?: string + failure_count?: number + http_status?: number + last_error?: string +} + +export interface OpenCodeGoUsageState { + account_id: number + eligible: boolean + auto_refresh_enabled: boolean + snapshot?: OpenCodeGoUsageSnapshot +} + +export interface OpenCodeGoUsageSettings { + enabled: boolean + interval_minutes: number +} + export interface Account { id: number name: string @@ -1168,6 +1205,7 @@ export interface Account { credentials?: Record credentials_status?: Record ollama_cloud_usage?: OllamaCloudUsageState + opencode_go_usage?: OpenCodeGoUsageState // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index bc6ceebbbc..c273aa26c1 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4910,6 +4910,72 @@
+ +
+
+

+ {{ t("admin.settings.opencodeGoUsage.title") }} +

+

+ {{ t("admin.settings.opencodeGoUsage.description") }} +

+
+
+
+
+ {{ t("common.loading") }} +
+ +
+
+
{ loadAdminApiKey(); loadUpstreamBillingProbeSettings(); loadOllamaCloudUsageSettings(); + loadOpenCodeGoUsageSettings(); loadOverloadCooldownSettings(); loadRateLimit429CooldownSettings(); loadPanelRateLimitSettings(); From ae2735cdb6861ef372b43e2ddb4aab5989b18fd9 Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 13 Aug 2026 23:41:01 +0800 Subject: [PATCH 10/21] =?UTF-8?q?feat(opencode):=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=94=AF=E6=8C=81=E5=90=8C=20Key=20=E7=BB=84?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E3=80=81=E6=B4=BB=E5=8A=A8=E9=98=B2=E6=8A=96?= =?UTF-8?q?=E4=B8=8E=E8=B6=85=E7=AA=97=E5=BC=BA=E5=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 Ollama Cloud usage 对齐:OpenCode Go 作为同 Key 聚合订阅, 多个 openai/apikey/base_url=opencode.ai/zen/go/v1 账号共享同一份 用量状态与刷新节奏: - 组共享:组指纹 sha256("opencode.ai\0"+api_key);auto_refresh 开关 与 snapshot 按组写(事务 + FOR NO KEY UPDATE + 锚点 CAS + 纯合并, 写 snapshot 不会抹掉开关);列表/详情经 ResolveAccounts 组内共享; 账号换 Key → IdentityChanged → 组级关闭 auto_refresh 并清快照 - 活动防抖 + 超窗强刷:settings 新增 debounce_minutes(默认 1, 1-60,必须小于 interval_minutes);模型请求活动经网关 5 处调用点 驱动刷新,due = min(lastUsed+debounce, fetchedAt+maxWait), 成功路径有 5 分钟最小抓取间隔,失败路径退避优先 - 手动刷新限频 10s→30s 且按组;singleflight/RunDue 按组去重; ListDue 在 SQL 内按组算 due(CTE 分组取组内 MAX(last_used_at)) 验证:go build + 五包回归 OK;新增组共享/三态 due/组单飞/ IdentityChanged/debounce 校验单测全绿;vitest 11 例全绿; vue-tsc 零错误。 --- .../internal/handler/admin/account_handler.go | 22 +- .../admin/account_opencode_go_usage_test.go | 38 +- .../account_repo_opencode_go_usage.go | 514 +++++++++++++++--- .../service/gateway_upstream_response.go | 3 +- .../openai_account_runtime_block_fastpath.go | 1 + .../openai_upstream_transport_error.go | 3 +- backend/internal/service/opencode_go_usage.go | 342 +++++++++++- .../service/opencode_go_usage_test.go | 458 +++++++++++++++- .../src/i18n/locales/en/admin/settings.ts | 6 +- .../src/i18n/locales/zh/admin/settings.ts | 6 +- frontend/src/types/index.ts | 3 + frontend/src/views/admin/SettingsView.vue | 21 +- 12 files changed, 1270 insertions(+), 147 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8705ff1c78..8780fcded3 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -679,14 +679,22 @@ func (h *AccountHandler) List(c *gin.Context) { response.ErrorFrom(c, err) return } - if h.ollamaCloudUsage != nil && len(accounts) > 0 { + if len(accounts) > 0 { accountPointers := make([]*service.Account, len(accounts)) for index := range accounts { accountPointers[index] = &accounts[index] } - if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil { - response.ErrorFrom(c, err) - return + if h.ollamaCloudUsage != nil { + if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil { + response.ErrorFrom(c, err) + return + } + } + if h.opencodeGoUsage != nil { + if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), accountPointers); err != nil { + response.ErrorFrom(c, err) + return + } } } @@ -947,6 +955,12 @@ func (h *AccountHandler) GetByID(c *gin.Context) { return } } + if h.opencodeGoUsage != nil { + if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), []*service.Account{account}); err != nil { + response.ErrorFrom(c, err) + return + } + } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } diff --git a/backend/internal/handler/admin/account_opencode_go_usage_test.go b/backend/internal/handler/admin/account_opencode_go_usage_test.go index e1ad0015fe..b763367d27 100644 --- a/backend/internal/handler/admin/account_opencode_go_usage_test.go +++ b/backend/internal/handler/admin/account_opencode_go_usage_test.go @@ -32,13 +32,49 @@ func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (* return nil, service.ErrAccountNotFound } +func (r *openCodeGoUsageHandlerTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*service.Account) ([]service.Account, error) { + wanted := make(map[string]struct{}, len(anchors)) + for _, anchor := range anchors { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(anchor); ok { + wanted[apiKey] = struct{}{} + } + } + result := make([]service.Account, 0, len(r.accounts)+1) + if r.account != nil { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(r.account); ok { + if _, match := wanted[apiKey]; match { + result = append(result, *r.account) + } + } + } + for _, account := range r.accounts { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(account); ok { + if _, match := wanted[apiKey]; match { + result = append(result, *account) + } + } + } + return result, nil +} + +func openCodeGoUsageHandlerTestAPIKey(account *service.Account) (string, bool) { + if account == nil || account.Credentials == nil { + return "", false + } + apiKey, ok := account.Credentials["api_key"].(string) + return apiKey, ok && apiKey != "" +} + func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error { return nil } func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error { return nil } -func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) { +func (r *openCodeGoUsageHandlerTestRepo) DisableOpenCodeGoUsageAutoRefresh(context.Context, *service.Account) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) { return nil, nil } diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go index ef645301ca..63de8e3ed6 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage.go +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -8,6 +8,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" ) const ( @@ -22,110 +23,45 @@ const ( ` ) -// SetOpenCodeGoUsageAutoRefresh persists the per-account auto-refresh switch. -func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error { - if account == nil { - return service.ErrAccountNilInput +// ListOpenCodeGoUsageGroupAccounts resolves every sibling for all supplied +// identities with one ID query and one batch hydration. API keys are query +// parameters only; no derived shared key is persisted. +func (r *accountRepository) ListOpenCodeGoUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) { + if r == nil || r.sql == nil { + return nil, service.ErrOpenCodeGoUsageUnavailable } - if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { - return service.ErrOpenCodeGoUsageUnavailable - } - return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ - service.OpenCodeGoUsageAutoRefreshExtraKey: enabled, - }) -} - -// UpdateOpenCodeGoUsageSnapshot persists the per-account usage snapshot. -func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error { - if account == nil || snapshot == nil { - return service.ErrAccountNilInput - } - if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { - return service.ErrOpenCodeGoUsageUnavailable - } - return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ - service.OpenCodeGoUsageSnapshotExtraKey: snapshot, - }) -} - -// updateOpenCodeGoUsageExtra atomically merges managed extra keys onto the -// account row. Snapshots are written per account (no api_key group CAS). -func (r *accountRepository) updateOpenCodeGoUsageExtra(ctx context.Context, account *service.Account, payload map[string]any) error { - encoded, err := json.Marshal(payload) - if err != nil { - return err - } - apply := func(txCtx context.Context, client *dbent.Client) error { - result, err := client.ExecContext(txCtx, ` - UPDATE accounts - SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, - updated_at = NOW() - WHERE deleted_at IS NULL - AND `+opencodeGoUsageEligibleSQL+` - AND id = $2 - `, string(encoded), account.ID) - if err != nil { - return err + keys := make([]string, 0, len(accounts)) + seen := make(map[string]struct{}, len(accounts)) + for _, account := range accounts { + if !service.IsOpenCodeGoUsageAccount(account) || account.Credentials == nil { + continue } - affected, err := result.RowsAffected() - if err != nil { - return err + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + continue } - if affected != 1 { - return service.ErrOpenCodeGoUsageIdentityChanged + if _, duplicate := seen[apiKey]; duplicate { + continue } - return nil + seen[apiKey] = struct{}{} + keys = append(keys, apiKey) } - if dbent.TxFromContext(ctx) != nil { - return apply(ctx, clientFromContext(ctx, r.client)) - } - tx, err := r.client.Tx(ctx) - if errors.Is(err, dbent.ErrTxStarted) { - return apply(ctx, r.client) - } - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - txCtx := dbent.NewTxContext(ctx, tx) - if err := apply(txCtx, tx.Client()); err != nil { - return err - } - return tx.Commit() -} - -// ListDueOpenCodeGoUsageAccounts returns at most limit eligible accounts whose -// auto-refresh is enabled and whose snapshot is missing or due (next_refresh_at -// at or before now). Invalid/missing next_refresh_at values fail open to due. -func (r *accountRepository) ListDueOpenCodeGoUsageAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { - if limit <= 0 { + if len(keys) == 0 { return []service.Account{}, nil } - if r == nil || r.sql == nil { - return nil, errors.New("account repository SQL executor not configured") - } - nextRefreshExpr := "extra -> 'opencode_go_usage_snapshot' #>> '{next_refresh_at}'" rows, err := r.sql.QueryContext(ctx, ` SELECT id FROM accounts WHERE deleted_at IS NULL - AND status = 'active' AND `+opencodeGoUsageEligibleSQL+` - AND extra @> '{"opencode_go_usage_auto_refresh": true}'::jsonb - AND ( - extra -> 'opencode_go_usage_snapshot' IS NULL - OR extra -> 'opencode_go_usage_snapshot' = 'null'::jsonb - OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+` IS NULL - OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+`::timestamptz <= $1 - ) + AND credentials ->> 'api_key' = ANY($1) ORDER BY id - LIMIT $2 - `, now.UTC(), limit) + `, pq.Array(keys)) if err != nil { return nil, err } defer func() { _ = rows.Close() }() - ids := make([]int64, 0, limit) + ids := make([]int64, 0, len(keys)) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { @@ -148,3 +84,407 @@ func (r *accountRepository) ListDueOpenCodeGoUsageAccounts(ctx context.Context, } return result, nil } + +// SetOpenCodeGoUsageAutoRefresh persists the group-scoped auto-refresh switch. +func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageGroup(ctx, account, map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: enabled, + }, nil, true) +} + +// UpdateOpenCodeGoUsageSnapshot persists the group-scoped usage snapshot. +func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error { + if account == nil || snapshot == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + payload := openCodeGoUsageManagedPayload(account) + payload[service.OpenCodeGoUsageSnapshotExtraKey] = snapshot + return r.updateOpenCodeGoUsageGroup(ctx, account, payload, nil, true) +} + +// DisableOpenCodeGoUsageAutoRefresh is group-scoped and retains the loaded +// identity CAS. It cannot disable a new group after the account changes key. +func (r *accountRepository) DisableOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + payload := openCodeGoUsageManagedPayload(account) + payload[service.OpenCodeGoUsageAutoRefreshExtraKey] = false + delete(payload, service.OpenCodeGoUsageSnapshotExtraKey) + return r.updateOpenCodeGoUsageGroup(ctx, account, payload, + []string{service.OpenCodeGoUsageSnapshotExtraKey}, true) +} + +func openCodeGoUsageManagedPayload(account *service.Account) map[string]any { + payload := make(map[string]any, 2) + if account == nil || account.Extra == nil { + return payload + } + for _, key := range []string{ + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey, + } { + if value, ok := account.Extra[key]; ok { + payload[key] = value + } + } + return payload +} + +type lockedOpenCodeGoUsageMember struct { + id int64 + anchorMatches bool + autoJSON string + snapshotJSON string +} + +// updateOpenCodeGoUsageGroup locks every member of the exact api_key group and +// merges the payload onto each member's extra. The merge is pure +// (COALESCE(extra,'{}') || payload) unless deleteKeys is non-empty, in which +// case those keys are removed first — used only by the group-level disable path +// to clear the stale snapshot. Managed keys are never removed on ordinary +// writes, so a snapshot write can never wipe the auto-refresh switch or vice versa. +func (r *accountRepository) updateOpenCodeGoUsageGroup( + ctx context.Context, + account *service.Account, + payload map[string]any, + deleteKeys []string, + requireExpectedState bool, +) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + return service.ErrOpenCodeGoUsageAccountInvalid + } + apply := func(txCtx context.Context, client *dbent.Client) error { + matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account) + if err != nil { + return err + } + if !matchesProxy { + return service.ErrOpenCodeGoUsageIdentityChanged + } + members, err := lockOpenCodeGoUsageGroup(txCtx, client, account, apiKey) + if err != nil { + return err + } + anchorMatches := false + for _, member := range members { + anchorMatches = anchorMatches || member.anchorMatches + } + if !anchorMatches { + return service.ErrOpenCodeGoUsageIdentityChanged + } + if requireExpectedState { + expectedAuto, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageAutoRefreshExtraKey) + if err != nil { + return err + } + expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageSnapshotExtraKey) + if err != nil { + return err + } + stateMatches := false + for _, member := range members { + if canonicalJSON(member.autoJSON) == expectedAuto && + canonicalJSON(member.snapshotJSON) == expectedSnapshot { + stateMatches = true + break + } + } + if !stateMatches { + return service.ErrOpenCodeGoUsageIdentityChanged + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + memberIDs := make([]int64, len(members)) + for index := range members { + memberIDs[index] = members[index].id + } + mergeExpr := "COALESCE(extra, '{}'::jsonb)" + for _, key := range deleteKeys { + mergeExpr += " - " + pq.QuoteLiteral(key) + } + mergeExpr += " || $1::jsonb" + result, err := client.ExecContext(txCtx, ` + UPDATE accounts + SET extra = `+mergeExpr+`, + updated_at = NOW() + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND credentials ->> 'api_key' = $2 + AND id = ANY($3) + `, string(encoded), apiKey, pq.Array(memberIDs)) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != int64(len(members)) { + return service.ErrOpenCodeGoUsageIdentityChanged + } + return nil + } + if dbent.TxFromContext(ctx) != nil { + return apply(ctx, clientFromContext(ctx, r.client)) + } + tx, err := r.client.Tx(ctx) + if errors.Is(err, dbent.ErrTxStarted) { + return apply(ctx, r.client) + } + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(ctx, tx) + if err := apply(txCtx, tx.Client()); err != nil { + return err + } + return tx.Commit() +} + +func lockOpenCodeGoUsageGroup( + ctx context.Context, + client *dbent.Client, + account *service.Account, + apiKey string, +) ([]lockedOpenCodeGoUsageMember, error) { + credentials, err := json.Marshal(normalizeJSONMap(account.Credentials)) + if err != nil { + return nil, err + } + var proxyID any + if account.ProxyID != nil { + proxyID = *account.ProxyID + } + rows, err := client.QueryContext(ctx, ` + SELECT + id, + id = $2 + AND platform = $3 + AND type = $4 + AND credentials = $5::jsonb + AND proxy_id IS NOT DISTINCT FROM $6, + COALESCE((extra -> 'opencode_go_usage_auto_refresh')::text, 'null'), + COALESCE((extra -> 'opencode_go_usage_snapshot')::text, 'null') + FROM accounts + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND credentials ->> 'api_key' = $1 + ORDER BY id + FOR NO KEY UPDATE + `, apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + members := make([]lockedOpenCodeGoUsageMember, 0, 1) + for rows.Next() { + var member lockedOpenCodeGoUsageMember + if err := rows.Scan(&member.id, &member.anchorMatches, &member.autoJSON, &member.snapshotJSON); err != nil { + return nil, err + } + members = append(members, member) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(members) == 0 { + return nil, service.ErrOpenCodeGoUsageIdentityChanged + } + return members, nil +} + +// ListDueOpenCodeGoUsageAccounts returns at most one truly-due activity-driven +// candidate per exact API key. Due timing (debounce, max-wait, failure backoff) +// is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones. +// Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service +// pure-function recheck against races between list and refresh. +// +// Rules mirror service.openCodeGoUsageAutoRefreshDueAt (keep both in sync): +// - missing/invalid snapshot or times → fail-open first due +// - success: activity after fetched_at; +// due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval) +// - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...); +// final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open) +func (r *accountRepository) ListDueOpenCodeGoUsageAccounts( + ctx context.Context, + now time.Time, + debounce, maxWait time.Duration, + limit int, +) ([]service.Account, error) { + if limit <= 0 { + return []service.Account{}, nil + } + if r == nil || r.sql == nil { + return nil, errors.New("account repository SQL executor not configured") + } + if debounce <= 0 { + debounce = time.Minute + } + if maxWait <= 0 { + maxWait = 15 * time.Minute + } + debounceSeconds := debounce.Seconds() + maxWaitSeconds := maxWait.Seconds() + minFetchIntervalSeconds := service.OpenCodeGoUsageMinFetchInterval.Seconds() + rows, err := r.sql.QueryContext(ctx, ` + WITH eligible AS ( + SELECT id, + credentials ->> 'api_key' AS api_key, + last_used_at, + extra -> 'opencode_go_usage_snapshot' AS snapshot + FROM accounts + WHERE deleted_at IS NULL + AND status = 'active' + AND `+opencodeGoUsageEligibleSQL+` + AND extra @> '{"opencode_go_usage_auto_refresh": true}'::jsonb + ), group_activity AS ( + SELECT credentials ->> 'api_key' AS api_key, + MAX(last_used_at) AS group_last_used_at + FROM accounts + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND jsonb_typeof(credentials -> 'api_key') = 'string' + GROUP BY credentials ->> 'api_key' + ), joined AS ( + SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at, + e.snapshot #>> '{status}' AS status, + e.snapshot #>> '{fetched_at}' AS fetched_at, + e.snapshot #>> '{last_attempt_at}' AS last_attempt_at, + e.snapshot #>> '{next_refresh_at}' AS next_refresh_at + FROM eligible e + JOIN group_activity g ON g.api_key = e.api_key + ), parsed AS MATERIALIZED ( + SELECT id, api_key, snapshot, group_last_used_at, status, + `+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at, + `+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at, + `+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at + FROM joined + ), timed AS ( + SELECT *, + CASE + WHEN status = 'ok' + AND parsed_fetched_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_fetched_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision) + ), + parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision) + ) + WHEN status IN ('failed', 'unauthorized') + AND parsed_last_attempt_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_last_attempt_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision) + ), + COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz) + ) + ELSE NULL + END AS activity_due_at + FROM parsed + ), candidates AS ( + SELECT *, + CASE + WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL + OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0 + WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0 + WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0 + WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1 + ELSE NULL + END AS due_class, + activity_due_at AS due_at + FROM timed + ), ranked AS ( + SELECT id, api_key, group_last_used_at, due_class, due_at, + row_number() OVER ( + PARTITION BY api_key + ORDER BY due_class, + due_at NULLS FIRST, + id + ) AS group_rank + FROM candidates + WHERE due_class IS NOT NULL + ) + SELECT id, group_last_used_at + FROM ranked + WHERE group_rank = 1 + ORDER BY due_class, due_at NULLS FIRST, id + LIMIT $4 + `, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + type dueRow struct { + id int64 + groupLastUsed *time.Time + } + rowsOut := make([]dueRow, 0, limit) + ids := make([]int64, 0, limit) + for rows.Next() { + var row dueRow + if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil { + return nil, err + } + rowsOut = append(rowsOut, row) + ids = append(ids, row.id) + } + if err := rows.Err(); err != nil { + return nil, err + } + accounts, err := r.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + byID := make(map[int64]*service.Account, len(accounts)) + for _, account := range accounts { + if account != nil { + byID[account.ID] = account + } + } + result := make([]service.Account, 0, len(rowsOut)) + for _, row := range rowsOut { + account := byID[row.id] + if account == nil { + continue + } + // Stamp group MAX(last_used_at) for service due evaluation. + if row.groupLastUsed != nil { + ts := row.groupLastUsed.UTC() + account.LastUsedAt = &ts + } else { + account.LastUsedAt = nil + } + result = append(result, *account) + } + return result, nil +} diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5b1307b40e..6aaaaf4b16 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -362,8 +362,9 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { - // Upstream returned a non-success HTTP status; count Ollama Cloud activity. + // Upstream returned a non-success HTTP status; count Ollama Cloud / OpenCode Go activity. scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) body, readErr := s.readUpstreamErrorBody(resp) if readErr != nil { // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 1e0303a7d1..cc8b51cfd4 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -99,6 +99,7 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont // Any non-2xx upstream HTTP response means the model request was actually sent. if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) } // Capacity shedding describes this request, not account health. Keep the // account schedulable while the request-local retry budget handles recovery. diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 752b6624ae..d72685b3a9 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -126,9 +126,10 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co return err } - // Transport attempt reached the network path; count as Ollama Cloud activity. + // Transport attempt reached the network path; count as Ollama Cloud / OpenCode Go activity. if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) } // 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。 diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go index 88564c36d4..eba93d4db3 100644 --- a/backend/internal/service/opencode_go_usage.go +++ b/backend/internal/service/opencode_go_usage.go @@ -2,11 +2,14 @@ package service import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "maps" "math/rand/v2" "net/http" "net/url" @@ -26,12 +29,22 @@ const ( OpenCodeGoUsageAutoRefreshExtraKey = "opencode_go_usage_auto_refresh" OpenCodeGoUsageSnapshotExtraKey = "opencode_go_usage_snapshot" + // OpenCodeGoUsageMinFetchInterval is the hard floor between two successful + // fetches of the same group, mirroring the floor nextOpenCodeGoUsageDelay + // applies to next_refresh_at. Activity may bring a refresh forward to this + // bound but never past it. Exported so the repository can apply the same + // floor inside the SQL due filter. + OpenCodeGoUsageMinFetchInterval = opencodeGoUsageMinIntervalMinutes * time.Minute + opencodeGoUsageAPIURL = "https://opencode.ai/zen/go/v1/usage" opencodeGoUsageDefaultIntervalMinutes = 15 opencodeGoUsageMinIntervalMinutes = 5 opencodeGoUsageMaxIntervalMinutes = 24 * 60 + opencodeGoUsageDefaultDebounceMinutes = 1 + opencodeGoUsageMinDebounceMinutes = 1 + opencodeGoUsageMaxDebounceMinutes = 60 opencodeGoUsageCycleInterval = time.Minute - opencodeGoUsageManualRefreshInterval = 10 * time.Second + opencodeGoUsageManualRefreshInterval = 30 * time.Second opencodeGoUsageRequestTimeout = 15 * time.Second opencodeGoUsageMaxBodyBytes = 512 * 1024 opencodeGoUsageMaxPerCycle = 20 @@ -52,7 +65,7 @@ var ( "OPENCODE_GO_USAGE_IDENTITY_CHANGED", "account identity or proxy changed during refresh; retry", ) ErrOpenCodeGoUsageRefreshRateLimited = infraerrors.TooManyRequests( - "OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED", "OpenCode Go usage can be refreshed manually once every 10 seconds", + "OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED", "OpenCode Go usage can be refreshed manually once every 30 seconds", ) ) @@ -62,10 +75,15 @@ const ( OpenCodeGoUsageStatusFailed = "failed" ) -// OpenCodeGoUsageSettings controls the opt-in periodic refresh runner. +// OpenCodeGoUsageSettings controls the opt-in request-driven refresh runner. +// +// IntervalMinutes is the max-wait bound: when model requests keep arriving and +// the trailing debounce keeps sliding, a refresh is forced after this long. +// DebounceMinutes is the quiet period after the latest request in a group. type OpenCodeGoUsageSettings struct { Enabled bool `json:"enabled"` - IntervalMinutes int `json:"interval_minutes"` + IntervalMinutes int `json:"interval_minutes"` // max wait while requests continue + DebounceMinutes int `json:"debounce_minutes"` // trailing quiet period after last request } // OpenCodeGoUsageWindow is a narrow, sanitized view of one official usage window. @@ -83,6 +101,12 @@ type OpenCodeGoUsageData struct { } // OpenCodeGoUsageSnapshot is the only usage observation persisted in account extra. +// +// NextRefreshAt remains a persisted compatibility field. For status=ok it is a +// max-wait horizon marker only; automatic success refreshes are driven by model +// request activity (group last_used_at + debounce/max-wait), not by this field +// alone. For failed/unauthorized snapshots it is the failure not-before time +// (Retry-After / exponential backoff) and is enforced as max(activityDue, NextRefreshAt). type OpenCodeGoUsageSnapshot struct { Status string `json:"status"` Data *OpenCodeGoUsageData `json:"data,omitempty"` @@ -103,9 +127,11 @@ type OpenCodeGoUsageState struct { } type openCodeGoUsageRepository interface { + ListOpenCodeGoUsageGroupAccounts(context.Context, []*Account) ([]Account, error) SetOpenCodeGoUsageAutoRefresh(context.Context, *Account, bool) error UpdateOpenCodeGoUsageSnapshot(context.Context, *Account, *OpenCodeGoUsageSnapshot) error - ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]Account, error) + DisableOpenCodeGoUsageAutoRefresh(context.Context, *Account) error + ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]Account, error) } // GetOpenCodeGoUsageSettings returns fail-safe defaults when the setting is absent. @@ -131,6 +157,9 @@ func (s *SettingService) GetOpenCodeGoUsageSettings(ctx context.Context) (*OpenC if settings.IntervalMinutes == 0 { settings.IntervalMinutes = defaults.IntervalMinutes } + if settings.DebounceMinutes == 0 { + settings.DebounceMinutes = defaults.DebounceMinutes + } normalizeOpenCodeGoUsageSettings(&settings) return &settings, nil } @@ -142,12 +171,31 @@ func (s *SettingService) SetOpenCodeGoUsageSettings(ctx context.Context, setting if settings == nil { return infraerrors.BadRequest("INVALID_OPENCODE_GO_USAGE_SETTINGS", "settings cannot be nil") } + if settings.DebounceMinutes == 0 { + // Legacy clients that omit debounce_minutes keep the fail-safe default. + settings.DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes + } if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes || settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { return infraerrors.BadRequest( "INVALID_OPENCODE_GO_USAGE_INTERVAL", fmt.Sprintf("interval_minutes must be between %d and %d", opencodeGoUsageMinIntervalMinutes, opencodeGoUsageMaxIntervalMinutes), ) } + if settings.DebounceMinutes < opencodeGoUsageMinDebounceMinutes || settings.DebounceMinutes > opencodeGoUsageMaxDebounceMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes must be between %d and %d", opencodeGoUsageMinDebounceMinutes, opencodeGoUsageMaxDebounceMinutes), + ) + } + // The due time is min(lastUsed+debounce, fetchedAt+maxWait). Once the debounce + // reaches the max wait the debounce term can never win, so the knob would be + // silently inert instead of doing what the operator asked for. + if settings.DebounceMinutes >= settings.IntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes (%d) must be less than interval_minutes (%d)", settings.DebounceMinutes, settings.IntervalMinutes), + ) + } normalizeOpenCodeGoUsageSettings(settings) data, err := json.Marshal(settings) if err != nil { @@ -160,6 +208,7 @@ func defaultOpenCodeGoUsageSettings() *OpenCodeGoUsageSettings { return &OpenCodeGoUsageSettings{ Enabled: false, IntervalMinutes: opencodeGoUsageDefaultIntervalMinutes, + DebounceMinutes: opencodeGoUsageDefaultDebounceMinutes, } } @@ -170,20 +219,125 @@ func normalizeOpenCodeGoUsageSettings(settings *OpenCodeGoUsageSettings) { if settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { settings.IntervalMinutes = opencodeGoUsageMaxIntervalMinutes } + if settings.DebounceMinutes <= 0 { + settings.DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes + } + if settings.DebounceMinutes < opencodeGoUsageMinDebounceMinutes { + settings.DebounceMinutes = opencodeGoUsageMinDebounceMinutes + } + if settings.DebounceMinutes > opencodeGoUsageMaxDebounceMinutes { + settings.DebounceMinutes = opencodeGoUsageMaxDebounceMinutes + } +} + +func openCodeGoUsageDurations(settings *OpenCodeGoUsageSettings) (debounce, maxWait time.Duration) { + normalized := defaultOpenCodeGoUsageSettings() + if settings != nil { + *normalized = *settings + } + normalizeOpenCodeGoUsageSettings(normalized) + return time.Duration(normalized.DebounceMinutes) * time.Minute, + time.Duration(normalized.IntervalMinutes) * time.Minute } // openCodeGoUsageIsAutoRefreshDue decides whether a configured auto-refresh -// account should fetch now. Missing or invalid snapshots fail open to a first -// fetch; otherwise the next_refresh_at horizon (success interval or failure -// backoff) decides. -func openCodeGoUsageIsAutoRefreshDue(snapshot *OpenCodeGoUsageSnapshot, now time.Time) bool { +// group should fetch now. groupLastUsedAt must be MAX(last_used_at) across the +// exact api_key group so shared multi-account groups do not miss activity. +// +// Success: a request must be newer than fetched_at; dueAt = min(lastUsed+debounce, fetchedAt+maxWait). +// Failure: a request must be newer than last_attempt_at; activity due uses the same min formula, +// then dueAt = max(activityDue, next_refresh_at) so Retry-After / exponential backoff win. +// Missing or invalid snapshots fail open to a first fetch. +func openCodeGoUsageIsAutoRefreshDue( + snapshot *OpenCodeGoUsageSnapshot, + groupLastUsedAt *time.Time, + now time.Time, + debounce, maxWait time.Duration, +) bool { + dueAt, ok := openCodeGoUsageAutoRefreshDueAt(snapshot, groupLastUsedAt, debounce, maxWait) + if !ok { + return false + } + return !now.Before(dueAt) +} + +func openCodeGoUsageAutoRefreshDueAt( + snapshot *OpenCodeGoUsageSnapshot, + groupLastUsedAt *time.Time, + debounce, maxWait time.Duration, +) (time.Time, bool) { + if debounce <= 0 { + debounce = time.Duration(opencodeGoUsageDefaultDebounceMinutes) * time.Minute + } + if maxWait <= 0 { + maxWait = time.Duration(opencodeGoUsageDefaultIntervalMinutes) * time.Minute + } if snapshot == nil { - return true + return time.Time{}, true } - if snapshot.NextRefreshAt.IsZero() { - return true + switch snapshot.Status { + case OpenCodeGoUsageStatusOK: + if snapshot.FetchedAt == nil || snapshot.FetchedAt.IsZero() { + return time.Time{}, true + } + fetchedAt := snapshot.FetchedAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(fetchedAt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + dueAt := minTime(lastUsed.Add(debounce), fetchedAt.Add(maxWait)) + // Keep the pre-existing hard floor between successful fetches. The success + // path no longer consults next_refresh_at, which is where + // nextOpenCodeGoUsageDelay used to apply opencodeGoUsageMinIntervalMinutes; + // without this, request traffic spaced slightly wider than the debounce + // drives the group's outbound rate far above the previous minimum. + if floor := fetchedAt.Add(OpenCodeGoUsageMinFetchInterval); dueAt.Before(floor) { + return floor, true + } + return dueAt, true + case OpenCodeGoUsageStatusFailed, OpenCodeGoUsageStatusUnauthorized: + if snapshot.LastAttemptAt.IsZero() { + return time.Time{}, true + } + lastAttempt := snapshot.LastAttemptAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(lastAttempt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + activityDue := minTime(lastUsed.Add(debounce), lastAttempt.Add(maxWait)) + if !snapshot.NextRefreshAt.IsZero() && snapshot.NextRefreshAt.UTC().After(activityDue) { + return snapshot.NextRefreshAt.UTC(), true + } + return activityDue, true + default: + return time.Time{}, true } - return !now.Before(snapshot.NextRefreshAt) +} + +// maxOpenCodeGoUsageGroupLastUsed returns the newest last_used_at among group members. +func maxOpenCodeGoUsageGroupLastUsed(accounts []Account) *time.Time { + var latest *time.Time + for i := range accounts { + candidate := accounts[i].LastUsedAt + if candidate == nil || candidate.IsZero() { + continue + } + if latest == nil || candidate.After(*latest) { + ts := candidate.UTC() + latest = &ts + } + } + return latest +} + +// scheduleOpenCodeGoUsageActivity records that an OpenCode Go API-key account +// actually attempted an upstream model request (including 429/5xx/transport errors). +// Local auth/validation failures must not call this. DeferredService dedupes writes. +func scheduleOpenCodeGoUsageActivity(deferred *DeferredService, account *Account) { + if deferred == nil || account == nil || !IsOpenCodeGoUsageAccount(account) { + return + } + deferred.ScheduleLastUsedUpdate(account.ID) } // OpenCodeGoUsageService refreshes the official usage JSON without affecting routing state. @@ -308,9 +462,96 @@ func (s *OpenCodeGoUsageService) GetState(ctx context.Context, accountID int64) if err != nil { return nil, err } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } return OpenCodeGoUsageStateFromAccount(account), nil } +// ResolveOpenCodeGoUsageAccounts overlays group-owned managed state onto the +// supplied account objects. The repository resolves all matching siblings in one +// bounded query, so account-list responses do not issue one query per row. +func (s *OpenCodeGoUsageService) ResolveOpenCodeGoUsageAccounts(ctx context.Context, accounts []*Account) error { + if s == nil || s.accountRepo == nil || len(accounts) == 0 { + return nil + } + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return nil + } + eligible := make([]*Account, 0, len(accounts)) + for _, account := range accounts { + if _, ok := openCodeGoUsageGroupFingerprint(account); ok { + eligible = append(eligible, account) + } + } + if len(eligible) == 0 { + return nil + } + siblings, err := writer.ListOpenCodeGoUsageGroupAccounts(ctx, eligible) + if err != nil { + return fmt.Errorf("resolve OpenCode Go usage groups: %w", err) + } + sources := make(map[string]*Account) + for index := range siblings { + candidate := &siblings[index] + fingerprint, valid := openCodeGoUsageGroupFingerprint(candidate) + if !valid { + continue + } + current := sources[fingerprint] + if current == nil || candidate.UpdatedAt.After(current.UpdatedAt) || + (candidate.UpdatedAt.Equal(current.UpdatedAt) && candidate.ID < current.ID) { + sources[fingerprint] = candidate + } + } + resolvedSources := make(map[string]*Account, len(sources)) + for fingerprint, source := range sources { + clone := *source + clone.Extra = make(map[string]any, len(source.Extra)) + maps.Copy(clone.Extra, source.Extra) + resolvedSources[fingerprint] = &clone + } + for index := range siblings { + candidate := &siblings[index] + fingerprint, valid := openCodeGoUsageGroupFingerprint(candidate) + source := resolvedSources[fingerprint] + if !valid || source == nil { + continue + } + candidateSnapshot := decodeOpenCodeGoUsageSnapshot(candidate.Extra) + currentSnapshot := decodeOpenCodeGoUsageSnapshot(source.Extra) + if candidateSnapshot != nil && (currentSnapshot == nil || candidateSnapshot.LastAttemptAt.After(currentSnapshot.LastAttemptAt)) { + source.Extra[OpenCodeGoUsageSnapshotExtraKey] = candidate.Extra[OpenCodeGoUsageSnapshotExtraKey] + } + } + for _, account := range eligible { + fingerprint, _ := openCodeGoUsageGroupFingerprint(account) + applyOpenCodeGoUsageManagedExtra(account, resolvedSources[fingerprint]) + } + return nil +} + +func applyOpenCodeGoUsageManagedExtra(target, source *Account) { + if target == nil { + return + } + if target.Extra == nil { + target.Extra = make(map[string]any) + } + for _, key := range []string{ + OpenCodeGoUsageAutoRefreshExtraKey, + OpenCodeGoUsageSnapshotExtraKey, + } { + delete(target.Extra, key) + if source != nil && source.Extra != nil { + if value, ok := source.Extra[key]; ok { + target.Extra[key] = value + } + } + } +} + func (s *OpenCodeGoUsageService) SetAutoRefresh(ctx context.Context, accountID int64, enabled bool) (*OpenCodeGoUsageState, error) { if s == nil || s.accountRepo == nil { return nil, ErrOpenCodeGoUsageUnavailable @@ -322,6 +563,9 @@ func (s *OpenCodeGoUsageService) SetAutoRefresh(ctx context.Context, accountID i if !IsOpenCodeGoUsageAccount(account) { return nil, ErrOpenCodeGoUsageAccountInvalid } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } writer, ok := s.accountRepo.(openCodeGoUsageRepository) if !ok { return nil, ErrOpenCodeGoUsageUnavailable @@ -367,23 +611,38 @@ func (s *OpenCodeGoUsageService) RunDue(ctx context.Context) error { return ErrOpenCodeGoUsageUnavailable } now := s.currentTime() - accounts, err := writer.ListDueOpenCodeGoUsageAccounts(ctx, now, opencodeGoUsageMaxPerCycle) + debounce, maxWait := openCodeGoUsageDurations(settings) + accounts, err := writer.ListDueOpenCodeGoUsageAccounts(ctx, now, debounce, maxWait, opencodeGoUsageMaxPerCycle) if err != nil { return fmt.Errorf("list due OpenCode Go usage accounts: %w", err) } var group errgroup.Group + seenGroups := make(map[string]struct{}, len(accounts)) for index := range accounts { account := accounts[index] - if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(&account) { + fingerprint, valid := openCodeGoUsageGroupFingerprint(&account) + if !valid || !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(&account) { continue } + if _, duplicate := seenGroups[fingerprint]; duplicate { + continue + } + seenGroups[fingerprint] = struct{}{} snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra) - if !openCodeGoUsageIsAutoRefreshDue(snapshot, now) { + // ListDue stamps Account.LastUsedAt with the api_key group MAX(last_used_at). + if !openCodeGoUsageIsAutoRefreshDue(snapshot, account.LastUsedAt, now, debounce, maxWait) { continue } accountID := account.ID + expected := account group.Go(func() error { if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { + if errors.Is(refreshErr, ErrOpenCodeGoUsageIdentityChanged) { + if disableErr := writer.DisableOpenCodeGoUsageAutoRefresh(ctx, &expected); disableErr != nil { + logger.LegacyPrintf("service.opencode_go_usage", "disable_auto_refresh_failed: account_id=%d err=%v", accountID, disableErr) + } + return nil + } logger.LegacyPrintf("service.opencode_go_usage", "refresh_due_failed: account_id=%d err=%v", accountID, refreshErr) } return nil @@ -400,14 +659,15 @@ func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID i settings = defaultOpenCodeGoUsageSettings() } intervalMinutes := settings.IntervalMinutes + debounce, maxWait := openCodeGoUsageDurations(settings) anchor, err := s.accountRepo.GetByID(ctx, accountID) if err != nil { return nil, err } - if !IsOpenCodeGoUsageAccount(anchor) { + key, valid := openCodeGoUsageGroupFingerprint(anchor) + if !valid { return nil, ErrOpenCodeGoUsageAccountInvalid } - key := strconv.FormatInt(accountID, 10) value, err, _ := s.refreshGroup.Do(key, func() (any, error) { select { case s.refreshSlots <- struct{}{}: @@ -419,9 +679,16 @@ func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID i if loadErr != nil { return nil, loadErr } - if !IsOpenCodeGoUsageAccount(account) { + currentKey, currentValid := openCodeGoUsageGroupFingerprint(account) + if !currentValid { return nil, ErrOpenCodeGoUsageAccountInvalid } + if currentKey != key { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } if !requireEnabled { if snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra); snapshot != nil && !snapshot.LastAttemptAt.IsZero() { retryAt := snapshot.LastAttemptAt.Add(opencodeGoUsageManualRefreshInterval) @@ -438,7 +705,21 @@ func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID i if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(account) { return nil, nil } - if !openCodeGoUsageIsAutoRefreshDue(decodeOpenCodeGoUsageSnapshot(account.Extra), s.currentTime()) { + groupLastUsed := account.LastUsedAt + if writer, ok := s.accountRepo.(openCodeGoUsageRepository); ok { + siblings, listErr := writer.ListOpenCodeGoUsageGroupAccounts(ctx, []*Account{account}) + if listErr != nil { + // Fall back to this account's own last_used_at. That is a narrower + // activity signal than the group maximum, so the due check may skip a + // refresh it would otherwise have run; surface it rather than + // silently changing the due semantics. + logger.LegacyPrintf("service.opencode_go_usage", + "group_last_used_lookup_failed: account_id=%d err=%v", account.ID, listErr) + } else { + groupLastUsed = maxOpenCodeGoUsageGroupLastUsed(siblings) + } + } + if !openCodeGoUsageIsAutoRefreshDue(decodeOpenCodeGoUsageSnapshot(account.Extra), groupLastUsed, s.currentTime(), debounce, maxWait) { return nil, nil } } @@ -618,6 +899,27 @@ func isOpenCodeGoBaseURL(raw string) bool { return strings.EqualFold(strings.TrimSuffix(parsed.Path, "/"), "/zen/go/v1") } +func openCodeGoUsageIdentity(account *Account) map[string]any { + if !IsOpenCodeGoUsageAccount(account) { + return nil + } + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + return nil + } + return map[string]any{"host": "opencode.ai", "api_key": apiKey} +} + +func openCodeGoUsageGroupFingerprint(account *Account) (string, bool) { + identity := openCodeGoUsageIdentity(account) + if identity == nil { + return "", false + } + apiKey, _ := identity["api_key"].(string) + sum := sha256.Sum256([]byte("opencode.ai\x00" + apiKey)) + return hex.EncodeToString(sum[:]), true +} + func isExactOpenCodeGoUsageURL(parsed *url.URL) bool { return parsed != nil && parsed.Scheme == "https" && parsed.Host == "opencode.ai" && parsed.Path == "/zen/go/v1/usage" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" && parsed.RawPath == "" diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go index 15065ea0b0..e173a4cc28 100644 --- a/backend/internal/service/opencode_go_usage_test.go +++ b/backend/internal/service/opencode_go_usage_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "reflect" "sync" "sync/atomic" "testing" @@ -22,12 +23,17 @@ const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6," type openCodeGoUsageTestRepo struct { AccountRepository - mu sync.Mutex - accounts map[int64]*Account - due []Account + mu sync.Mutex + accounts map[int64]*Account + due []Account + groupResolveCalls atomic.Int64 + getByIDCalls atomic.Int64 + disableAutoCalls atomic.Int64 + disableAutoAttempt atomic.Int64 } func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.getByIDCalls.Add(1) r.mu.Lock() defer r.mu.Unlock() account := r.accounts[id] @@ -52,35 +58,109 @@ func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*A return result, nil } +func (r *openCodeGoUsageTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) { + r.groupResolveCalls.Add(1) + r.mu.Lock() + defer r.mu.Unlock() + wanted := make(map[string]struct{}, len(anchors)) + for _, anchor := range anchors { + if fingerprint, ok := openCodeGoUsageGroupFingerprint(anchor); ok { + wanted[fingerprint] = struct{}{} + } + } + result := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + fingerprint, ok := openCodeGoUsageGroupFingerprint(account) + if _, match := wanted[fingerprint]; !ok || !match { + continue + } + result = append(result, cloneOpenCodeGoUsageTestAccount(*account)) + } + return result, nil +} + func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error { r.mu.Lock() defer r.mu.Unlock() - account := r.accounts[expected.ID] - if account == nil { - return ErrAccountNotFound + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err } - if account.Extra == nil { - account.Extra = make(map[string]any) + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled } - account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled return nil } func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error { r.mu.Lock() defer r.mu.Unlock() - account := r.accounts[expected.ID] - if account == nil { - return ErrAccountNotFound + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err } - if account.Extra == nil { - account.Extra = make(map[string]any) + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot } - account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot return nil } -func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) { +func (r *openCodeGoUsageTestRepo) DisableOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account) error { + r.disableAutoAttempt.Add(1) + r.mu.Lock() + defer r.mu.Unlock() + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err + } + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = false + delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey) + } + r.disableAutoCalls.Add(1) + return nil +} + +func (r *openCodeGoUsageTestRepo) openCodeGoGroupMembersLocked(expected *Account) ([]*Account, error) { + anchor := r.accounts[expected.ID] + if !sameOpenCodeGoUsageTestIdentity(anchor, expected) { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + fingerprint, ok := openCodeGoUsageGroupFingerprint(expected) + if !ok { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + members := make([]*Account, 0, len(r.accounts)) + for _, account := range r.accounts { + candidate, valid := openCodeGoUsageGroupFingerprint(account) + if valid && candidate == fingerprint { + if account.Extra == nil { + account.Extra = make(map[string]any) + } + members = append(members, account) + } + } + return members, nil +} + +func applyOpenCodeGoUsageTestManagedExtra(account, source *Account) { + for _, key := range []string{OpenCodeGoUsageAutoRefreshExtraKey, OpenCodeGoUsageSnapshotExtraKey} { + delete(account.Extra, key) + if value, ok := source.Extra[key]; ok { + account.Extra[key] = value + } + } +} + +func sameOpenCodeGoUsageTestIdentity(left, right *Account) bool { + return left != nil && right != nil && left.Platform == right.Platform && left.Type == right.Type && + reflect.DeepEqual(left.Credentials, right.Credentials) && reflect.DeepEqual(left.ProxyID, right.ProxyID) +} + +func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, _, _ time.Duration, limit int) ([]Account, error) { r.mu.Lock() defer r.mu.Unlock() if len(r.due) > 0 { @@ -107,13 +187,14 @@ func cloneOpenCodeGoUsageTestAccount(account Account) Account { } type openCodeGoUsageHTTPStub struct { - status int - body []byte - header http.Header - calls atomic.Int64 - lastRequest *http.Request - lastProxy string - mu sync.Mutex + status int + body []byte + header http.Header + calls atomic.Int64 + beforeResponse func(*http.Request) + lastRequest *http.Request + lastProxy string + mu sync.Mutex } func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) { @@ -122,6 +203,9 @@ func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64 s.lastRequest = req s.lastProxy = proxyURL s.mu.Unlock() + if s.beforeResponse != nil { + s.beforeResponse(req) + } status := s.status if status == 0 { status = http.StatusOK @@ -359,11 +443,264 @@ func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) { } func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + ptr := func(ts time.Time) *time.Time { return &ts } + + require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due") + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due") + + okSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(fetched), + LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait), + } + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success") + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at") + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed") + require.True(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce") + + // Continuous requests: last used is now, but max-wait from old fetch forces due. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), + LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait), + } + require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue") + // First request after a very old snapshot is immediately due because fetched+maxWait is past. + require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate") + + failSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusFailed, FetchedAt: ptr(fetched), + LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute), + } + require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request") + require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff") + failSnap.NextRefreshAt = now.Add(-time.Second) + require.True(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request") + + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now, + }, nil, now, debounce, maxWait), "ok without fetched_at fails open") +} + +// The success path stopped consulting next_refresh_at, which is where +// nextOpenCodeGoUsageDelay used to apply the minimum interval. Activity may pull +// a refresh forward only as far as that floor, otherwise request traffic spaced +// just wider than the debounce drives the group's outbound rate far above the +// pre-existing minimum. +func TestOpenCodeGoUsageAutoRefreshDueAtHonoursMinFetchInterval(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + ptr := func(ts time.Time) *time.Time { return &ts } + + // Debounce elapsed, but the last successful fetch is inside the floor. + recent := now.Add(-2 * time.Minute) + recentSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(recent), LastAttemptAt: recent, + } + dueAt, ok := openCodeGoUsageAutoRefreshDueAt(recentSnap, ptr(now.Add(-time.Minute)), debounce, maxWait) + require.True(t, ok) + require.Equal(t, recent.Add(OpenCodeGoUsageMinFetchInterval), dueAt, + "due time must be clamped to fetched_at + min fetch interval") + require.False(t, openCodeGoUsageIsAutoRefreshDue(recentSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), + "debounce alone must not refresh within the min fetch interval") + + // Once the floor has passed the debounce governs again. + atFloor := now.Add(-OpenCodeGoUsageMinFetchInterval) + floorSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(atFloor), LastAttemptAt: atFloor, + } + require.True(t, openCodeGoUsageIsAutoRefreshDue(floorSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait), + "past the floor a quiet debounce window is due") + + // The floor never delays a refresh that max-wait has already forced. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), LastAttemptAt: oldFetched, + } + dueAt, ok = openCodeGoUsageAutoRefreshDueAt(oldSnap, ptr(now), debounce, maxWait) + require.True(t, ok) + require.Equal(t, oldFetched.Add(maxWait), dueAt, "max-wait due time must not be pushed out by the floor") +} + +func TestOpenCodeGoUsageGroupSharesStateAcrossSiblings(t *testing.T) { + source := openCodeGoUsageAccount(71) + source.Credentials["api_key"] = "shared-key" + source.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + source.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + } + source.UpdatedAt = time.Now().Add(-time.Minute) + sibling := openCodeGoUsageAccount(72) + sibling.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"} + sibling.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + sibling.UpdatedAt = time.Now() + different := openCodeGoUsageAccount(73) + different.Credentials["api_key"] = "different-key" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{ + source.ID: source, sibling.ID: sibling, different.ID: different, + }} + svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.GetState(context.Background(), sibling.ID) + require.NoError(t, err) + require.True(t, state.Eligible) + require.True(t, state.AutoRefreshEnabled) + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) + + differentState, err := svc.GetState(context.Background(), different.ID) + require.NoError(t, err) + require.False(t, differentState.AutoRefreshEnabled) + require.Nil(t, differentState.Snapshot) + + newSibling := openCodeGoUsageAccount(74) + newSibling.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "shared-key"} + repo.mu.Lock() + repo.accounts[newSibling.ID] = newSibling + repo.mu.Unlock() + newState, err := svc.GetState(context.Background(), newSibling.ID) + require.NoError(t, err) + require.True(t, newState.AutoRefreshEnabled) + require.Equal(t, state.Snapshot, newState.Snapshot) + + before := repo.groupResolveCalls.Load() + require.NoError(t, svc.ResolveOpenCodeGoUsageAccounts(context.Background(), []*Account{source, sibling, different, newSibling})) + require.Equal(t, before+1, repo.groupResolveCalls.Load(), "one list batch must issue one group lookup") +} + +func TestOpenCodeGoUsageSetAutoRefreshAndSnapshotAreGroupScoped(t *testing.T) { + first := openCodeGoUsageAccount(81) + first.Credentials["api_key"] = "shared-key" + second := openCodeGoUsageAccount(82) + second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"} + different := openCodeGoUsageAccount(83) + different.Credentials["api_key"] = "different-key" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{ + first.ID: first, second.ID: second, different.ID: different, + }} + svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.SetAutoRefresh(context.Background(), second.ID, true) + require.NoError(t, err) + require.True(t, state.AutoRefreshEnabled) + require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.NotContains(t, different.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + + // A snapshot write must not wipe the auto-refresh switch (pure merge). now := time.Now().UTC() - require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, now)) - require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{}, now)) - require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(-time.Minute)}, now)) - require.False(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(time.Minute)}, now)) + _, err = svc.Refresh(context.Background(), first.ID) + require.NoError(t, err) + require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh") + require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh on siblings") + require.NotNil(t, decodeOpenCodeGoUsageSnapshot(second.Extra), "snapshot must be shared with siblings") + require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra)) + require.NotNil(t, now) +} + +func TestOpenCodeGoUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *testing.T) { + first := openCodeGoUsageAccount(91) + first.Credentials["api_key"] = "shared-key" + first.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + second := openCodeGoUsageAccount(92) + second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"} + second.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{ + accounts: map[int64]*Account{first.ID: first, second.ID: second}, + due: []Account{*first, *second}, + } + settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{ + SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`, + }} + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture), beforeResponse: func(*http.Request) { + once.Do(func() { close(started) }) + <-release + }} + svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo) + + errs := make(chan error, 2) + go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }() + <-started + // The first caller is now parked in the stub, having loaded the account twice + // (once to build the group key, once inside the singleflight function). + loadsBeforeSecond := repo.getByIDCalls.Load() + go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }() + // Only release the first caller once the second one has loaded its own + // account, which happens immediately before it joins the singleflight group. + require.Eventually(t, func() bool { + return repo.getByIDCalls.Load() > loadsBeforeSecond + }, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released") + close(release) + require.NoError(t, <-errs) + require.NoError(t, <-errs) + require.Equal(t, int64(1), upstream.calls.Load()) + require.NotNil(t, decodeOpenCodeGoUsageSnapshot(first.Extra)) + require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra)) + + delete(first.Extra, OpenCodeGoUsageSnapshotExtraKey) + delete(second.Extra, OpenCodeGoUsageSnapshotExtraKey) + upstream.beforeResponse = nil + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(2), upstream.calls.Load(), "RunDue must issue one request for the shared group") +} + +func TestOpenCodeGoUsageRefreshRejectsGroupChangeBeforeUpstreamRequest(t *testing.T) { + account := openCodeGoUsageAccount(94) + base := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{account.ID: account}} + repo := &openCodeGoRefreshPreflightIdentityChangeRepo{openCodeGoUsageTestRepo: base} + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil)) + t.Cleanup(svc.Stop) + + _, err := svc.Refresh(context.Background(), account.ID) + + require.ErrorIs(t, err, ErrOpenCodeGoUsageIdentityChanged) + require.Zero(t, upstream.calls.Load()) + require.NotContains(t, account.Extra, OpenCodeGoUsageSnapshotExtraKey) +} + +type openCodeGoRefreshPreflightIdentityChangeRepo struct { + *openCodeGoUsageTestRepo + getCalls atomic.Int64 +} + +func (r *openCodeGoRefreshPreflightIdentityChangeRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + if r.getCalls.Add(1) == 2 { + r.mu.Lock() + r.accounts[id].Credentials["api_key"] = "rotated-before-refresh" + r.mu.Unlock() + } + return r.openCodeGoUsageTestRepo.GetByID(ctx, id) +} + +func TestOpenCodeGoUsageRunnerDisablesAutoRefreshAfterIdentityError(t *testing.T) { + account := openCodeGoUsageAccount(14) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + missingProxyID := int64(99) + account.ProxyID = &missingProxyID + account.Proxy = nil + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{14: account}} + settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{ + SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`, + }} + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), repo.disableAutoCalls.Load()) + require.Equal(t, false, account.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.Zero(t, upstream.calls.Load()) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), repo.disableAutoCalls.Load()) + require.Zero(t, upstream.calls.Load()) } func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) { @@ -438,6 +775,43 @@ func TestIsOpenCodeGoUsageAccount(t *testing.T) { require.False(t, IsOpenCodeGoUsageAccount(account)) } +func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) { + first := openCodeGoUsageAccount(1) + first.Credentials["api_key"] = "shared-key" + second := openCodeGoUsageAccount(2) + second.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"} + third := openCodeGoUsageAccount(3) + third.Credentials["api_key"] = "other-key" + + firstFP, firstOK := openCodeGoUsageGroupFingerprint(first) + secondFP, secondOK := openCodeGoUsageGroupFingerprint(second) + thirdFP, thirdOK := openCodeGoUsageGroupFingerprint(third) + require.True(t, firstOK) + require.True(t, secondOK) + require.True(t, thirdOK) + require.Equal(t, firstFP, secondFP, "same api_key across base_url variants must share a group") + require.NotEqual(t, firstFP, thirdFP, "different api_key must not share a group") + + // ineligible accounts have no fingerprint + account := openCodeGoUsageAccount(4) + account.Credentials["base_url"] = "https://opencode.ai/v1" + _, ok := openCodeGoUsageGroupFingerprint(account) + require.False(t, ok) +} + +func TestScheduleOpenCodeGoUsageActivityOnlyForOpenCode(t *testing.T) { + deferred := &DeferredService{} + openCode := openCodeGoUsageAccount(1) + openCode.Credentials["api_key"] = "k" + other := openCodeGoUsageAccount(2) + other.Credentials["base_url"] = "https://opencode.ai/v1" + + scheduleOpenCodeGoUsageActivity(deferred, openCode) + scheduleOpenCodeGoUsageActivity(deferred, other) + scheduleOpenCodeGoUsageActivity(nil, openCode) + scheduleOpenCodeGoUsageActivity(deferred, nil) +} + func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { repo := &upstreamBillingProbeSettingRepo{} settingsService := NewSettingService(repo, nil) @@ -445,6 +819,7 @@ func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { require.NoError(t, err) require.False(t, settings.Enabled) require.Equal(t, 15, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) // below the minimum err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1}) @@ -452,13 +827,40 @@ func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { // above the maximum err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000}) require.Error(t, err) + // debounce out of range + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 61}) + require.Error(t, err) + // DebounceMinutes=0 (legacy omit) defaults to 1 on write. + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 0}) + require.NoError(t, err) + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, settings.DebounceMinutes) // valid update round-trips - err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30}) + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 2}) require.NoError(t, err) settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) require.NoError(t, err) require.True(t, settings.Enabled) require.Equal(t, 30, settings.IntervalMinutes) + require.Equal(t, 2, settings.DebounceMinutes) + + // debounce >= interval would make the debounce term unreachable in + // min(lastUsed+debounce, fetchedAt+maxWait), silently ignoring the operator's + // setting, so it is rejected rather than accepted and dropped. + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 5}) + require.Error(t, err, "debounce equal to interval must be rejected") + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 60}) + require.Error(t, err, "debounce greater than interval must be rejected") + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 6, DebounceMinutes: 5}) + require.NoError(t, err, "debounce below interval stays valid") + + // Legacy JSON without debounce_minutes defaults to 1. + repo.values[SettingKeyOpenCodeGoUsageSettings] = `{"enabled":true,"interval_minutes":45}` + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 45, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) } func TestOpenCodeGoUsageStateFromAccount(t *testing.T) { diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 8cdc7a31af..ced6771230 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -482,8 +482,10 @@ export default { description: 'Refresh usage windows reported by the upstream OpenCode Go account for individually opted-in accounts. Disabled by default.', enabled: 'Enable global automatic refresh', enabledHint: 'Only accounts with their own automatic refresh switch enabled are refreshed. Manual refresh remains available.', - intervalMinutes: 'Refresh interval (minutes)', - intervalHint: 'Range: 5–1440 minutes.', + intervalMinutes: 'Max wait while requests continue (minutes)', + intervalHint: 'Range: 5–1440 minutes. When continuous requests keep sliding the debounce, force a refresh after this wait.', + debounceMinutes: 'Quiet period after last request (minutes)', + debounceHint: 'Range: 1–60 minutes, and must be less than the refresh interval. Refresh after the latest model request has been quiet for this long.', saved: 'OpenCode Go usage refresh settings saved', saveFailed: 'Failed to save OpenCode Go usage refresh settings' }, diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 01ba55f317..39007377c4 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -475,8 +475,10 @@ export default { description: '刷新上游 OpenCode Go 账号上报的用量窗口;默认关闭,仅对单独开启的账号生效。', enabled: '启用全局自动刷新', enabledHint: '仅刷新账号自身也开启自动刷新的账号。手动刷新不受影响。', - intervalMinutes: '刷新间隔(分钟)', - intervalHint: '范围 5–1440 分钟。', + intervalMinutes: '请求持续时的最长等待(分钟)', + intervalHint: '范围 5–1440 分钟。请求持续不断导致 debounce 一直后移时,最晚在此时间强制刷新。', + debounceMinutes: '请求安静等待(分钟)', + debounceHint: '范围 1–60 分钟,且必须小于刷新间隔。最后一次模型请求安静满此时长后再抓取用量。', saved: 'OpenCode Go 用量刷新设置已保存', saveFailed: '保存 OpenCode Go 用量刷新设置失败' }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index afbfd2a1bd..bc7fbf2ac8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1189,7 +1189,10 @@ export interface OpenCodeGoUsageState { export interface OpenCodeGoUsageSettings { enabled: boolean + /** Max wait while model requests keep arriving (minutes). */ interval_minutes: number + /** Trailing quiet period after the latest model request (minutes). */ + debounce_minutes: number } export interface Account { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c273aa26c1..9f04a341da 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4942,6 +4942,24 @@ />
+
+ + +

+ {{ t("admin.settings.opencodeGoUsage.debounceHint") }} +

+