From 89ba561a6f5ee3b6488128c38f537adf65664425 Mon Sep 17 00:00:00 2001 From: Nick <126941599+coo1white@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:57:51 +0700 Subject: [PATCH 001/142] fix(backup): keep the S3 secret encrypted when a save inherits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateS3Config only encrypted the SecretAccessKey on the branch where the caller supplied a new one. The other branch inherits the stored secret through loadS3Config, which *decrypts* it — so that path wrote the plaintext straight back to the settings row and overwrote the ciphertext. It is the normal path, not an edge case. UpdateS3Config clears the secret in its own return value, so the admin form's field is empty afterwards. Any later save — changing the endpoint, saving the schedule, pressing save twice — sends an empty secret and takes the inheriting branch. One extra save is enough to leave the secret in plaintext at rest. Nothing surfaced because loadS3Config falls back to the raw value when decryption fails, for compatibility with pre-encryption rows. Backups kept working and the only trace was a line per read: [Backup] S3 SecretAccessKey 解密失败(可能是旧的未加密数据): decrypt: cipher: message authentication failed Seen on a live install: the stored value was 64 characters, exactly the plaintext length of an R2 secret, where AES-GCM + base64 would have been 124. The compatibility fallback turns a one-time write bug into a permanent plaintext secret. It also feeds itself: pg_dump includes the settings table, so the backup uploaded to the bucket carries the credentials for that same bucket. Fix: hoist the encryption out of the if/else so it runs for a non-empty secret whichever way it arrived. The ephemeral-key guard (#4524) moves with it, and the no-secret-at-all case still skips encryption entirely, so an update with no stored secret keeps working under an auto-generated key. The existing KeepExistingSecret test passed either way, because it only asserted loadS3Config returns the plaintext — true with the fallback too. The new test asserts on the row itself: still ciphertext after a second save, not double-wrapped after a third. Checked the new test fails without the fix and passes with it. --- backend/internal/service/backup_service.go | 12 +++-- .../internal/service/backup_service_test.go | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 14080ecde8bf..0682ba0e365d 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -276,19 +276,25 @@ func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error } func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) { - // 如果没提供 secret,保留原有值 + // 如果没提供 secret,保留原有值。loadS3Config 会解密,所以这里拿到的是明文, + // 和调用方新填的 secret 一样,都必须走下面的加密再落库。 if cfg.SecretAccessKey == "" { old, _ := s.loadS3Config(ctx) if old != nil { cfg.SecretAccessKey = old.SecretAccessKey } - } else { + } + + // 加密 SecretAccessKey。这一步不能只在“调用方填了新 secret”时做:表单保存成功 + // 后本函数会清空返回值里的 secret,所以第二次保存(改端点、存定时配置)送上来的 + // secret 是空的,会走上面的分支继承旧值。若那条路径跳过加密,明文就会覆盖掉库里 + // 的密文,而读取侧“兼容未加密旧数据”的回退会把这件事永久掩盖成一条日志。 + if cfg.SecretAccessKey != "" { // 拒绝用自动生成的临时密钥加密:该密钥每次重启都会变化,落库的密文在 // 重启/升级后无法解密(#4524)。与支付、TOTP 的处理保持一致。 if !s.encryptionKeyConfigured { return nil, ErrSecretEncryptionKeyNotConfigured } - // 加密 SecretAccessKey encrypted, err := s.encryptor.Encrypt(cfg.SecretAccessKey) if err != nil { return nil, fmt.Errorf("encrypt secret: %w", err) diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index c39fa6b098aa..e395d6b94792 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -304,6 +304,53 @@ func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) { require.Equal(t, "AKID-NEW", internal.AccessKeyID) } +// 一次不带 secret 的保存(表单第二次提交、改端点、存定时配置)继承的是 loadS3Config +// 解密后的明文。若那条路径跳过加密,明文就会覆盖库里的密文,而读取侧“兼容未加密旧 +// 数据”的回退会把它掩盖成一条日志,功能照常,密钥却是明文落库的。 +func TestBackupService_S3ConfigStaysEncryptedAfterSecondSave(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + + _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID", + SecretAccessKey: "original-secret", + }) + require.NoError(t, err) + + storedSecret := func() string { + raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config) + var stored BackupS3Config + require.NoError(t, json.Unmarshal([]byte(raw), &stored)) + return stored.SecretAccessKey + } + require.Equal(t, "ENC:original-secret", storedSecret()) + + // 第二次保存不带 secret,只改别的字段。 + _, err = svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID-NEW", + }) + require.NoError(t, err) + + require.Equal(t, "ENC:original-secret", storedSecret(), + "secret must stay encrypted at rest after a save that inherits it") + require.NotEqual(t, "original-secret", storedSecret(), "secret must never be stored as plaintext") + + // 第三次保存,确认不会反复套壳加密。 + _, err = svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID-THIRD", + }) + require.NoError(t, err) + require.Equal(t, "ENC:original-secret", storedSecret(), "secret must not be double-encrypted") + + internal, err := svc.loadS3Config(context.Background()) + require.NoError(t, err) + require.Equal(t, "original-secret", internal.SecretAccessKey) + require.Equal(t, "AKID-THIRD", internal.AccessKeyID) +} + func TestBackupService_UpdateS3Config_RejectsEphemeralKey(t *testing.T) { repo := newMockSettingRepo() svc := newTestBackupServiceEphemeralKey(repo) From 277aa1411dd0f14bfdd11f4de31d130e661aabcb Mon Sep 17 00:00:00 2001 From: cxx-cod <206033780+cxx-cod@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:32:24 +0000 Subject: [PATCH 002/142] fix(openai): end streaming at terminal event instead of waiting for upstream EOF The streaming forward loops treated upstream EOF as the only normal exit condition; response.completed/[DONE] only set a flag. With keep-alive / HTTP/2 connection reuse to the upstream, the upstream may hold the stream open for 8-46s after the terminal event, during which the gateway could only emit downstream keepalives, inflating tail latency. Finish the stream as soon as the terminal event frame has been fully written downstream (usage is already parsed from the terminal event), and close the upstream body to unblock the reader goroutine. Applied to the guarded/async, sync, and passthrough streaming paths. The WS HTTP bridge already behaved this way. Adds a regression test with an upstream that hangs after response.completed. --- .../service/openai_gateway_passthrough.go | 8 ++++ .../openai_gateway_response_flush_test.go | 44 +++++++++++++++++++ .../openai_gateway_response_handling.go | 15 +++++++ 3 files changed, 67 insertions(+) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index ecca15478114..0705d897b1d9 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -2186,6 +2186,14 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( responseFailedPending = false failureDelivered = true } + // Terminal 事件(response.completed / [DONE] 等)随空行完整刷出后不再等上游 + // EOF:上游在 keep-alive/HTTP2 复用连接上可能拖延关闭连接(观测到 8~46s 不等), + // 空等期间只能靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。 + // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) + // 必须继续读取,不适用提前结束。 + if (sawDone || sawTerminalEvent) && line == "" && !(codexFailureTerminal && sawBareError) { + break + } } ensureResponseFailedTerminal() if err := documentScanner.Err(); err != nil { diff --git a/backend/internal/service/openai_gateway_response_flush_test.go b/backend/internal/service/openai_gateway_response_flush_test.go index fe6e3ea6b204..56e071041ad2 100644 --- a/backend/internal/service/openai_gateway_response_flush_test.go +++ b/backend/internal/service/openai_gateway_response_flush_test.go @@ -644,3 +644,47 @@ func waitOpenAIResponseFlushSignal(t *testing.T, signal <-chan struct{}) { t.Fatal("timed out waiting for stream signal") } } + +// hangingOpenAISSEAfterTerminal 模拟上游在发完 terminal 事件后拖延关闭连接 +// (keep-alive/HTTP2 复用连接上观测到 8~46s 不 EOF)。 +type hangingOpenAISSEAfterTerminal struct { + payload []byte + sent bool + release chan struct{} + closeOnce sync.Once +} + +func (r *hangingOpenAISSEAfterTerminal) Read(data []byte) (int, error) { + if !r.sent { + r.sent = true + return copy(data, r.payload), nil + } + <-r.release + return 0, io.EOF +} + +func (r *hangingOpenAISSEAfterTerminal) Close() error { + r.closeOnce.Do(func() { close(r.release) }) + return nil +} + +func TestOpenAIResponseFlush_TerminalEventEndsStreamWithoutEOF(t *testing.T) { + body := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5}}}\n\n" + reader := &hangingOpenAISSEAfterTerminal{payload: []byte(body), release: make(chan struct{})} + recorder := newOpenAIResponseFlushRecorder() + resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1, StreamDataIntervalTimeout: 30}) + t.Cleanup(func() { _ = reader.Close() }) + + select { + case err := <-errCh: + require.NoError(t, err) + result := <-resultCh + require.NotNil(t, result) + require.Equal(t, 7, result.usage.InputTokens) + require.Equal(t, 5, result.usage.OutputTokens) + case <-time.After(3 * time.Second): + t.Fatal("stream did not end after terminal event; still waiting for upstream EOF") + } + gotBody, _ := recorder.snapshot() + require.Equal(t, body, gotBody) +} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 104f319dc56a..021b060c73a5 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -798,6 +798,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if streamEarlyErr != nil { return resultWithUsage(), streamEarlyErr } + // Terminal 事件完整写出后直接结束,不等上游 EOF(见下方异步循环同款说明)。 + // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) + // 必须继续读取,不适用提前结束。 + if sawTerminalEvent && !eventInProgress && !(codexFailureTerminal && sawBareError) { + return finalizeStream() + } } if result, err, done := handleScanErr(documentScanner.Err()); done { return result, err @@ -877,6 +883,15 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if streamEarlyErr != nil { return resultWithUsage(), streamEarlyErr } + // Terminal 事件(response.completed 等)已完整写出后不再等上游 EOF: + // 上游在 keep-alive/HTTP2 复用连接上可能拖延关闭连接,空等期间只能 + // 靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。 + // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) + // 必须继续读取,不适用提前结束。 + if sawTerminalEvent && !eventInProgress && !(codexFailureTerminal && sawBareError) { + _ = resp.Body.Close() + return finalizeStream() + } case <-intervalCh: if failureDelivered { From 6f9fde093fd4c6e19981d9ace2d35af9e90b74cd Mon Sep 17 00:00:00 2001 From: cxx-cod <206033780+cxx-cod@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:30:14 +0000 Subject: [PATCH 003/142] style(openai): apply De Morgan's law to satisfy staticcheck QF1001 --- backend/internal/service/openai_gateway_passthrough.go | 2 +- backend/internal/service/openai_gateway_response_handling.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 0705d897b1d9..429bc19a78f3 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -2191,7 +2191,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( // 空等期间只能靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。 // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) // 必须继续读取,不适用提前结束。 - if (sawDone || sawTerminalEvent) && line == "" && !(codexFailureTerminal && sawBareError) { + if (sawDone || sawTerminalEvent) && line == "" && (!codexFailureTerminal || !sawBareError) { break } } diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 021b060c73a5..6ea830b7b385 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -801,7 +801,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // Terminal 事件完整写出后直接结束,不等上游 EOF(见下方异步循环同款说明)。 // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) // 必须继续读取,不适用提前结束。 - if sawTerminalEvent && !eventInProgress && !(codexFailureTerminal && sawBareError) { + if sawTerminalEvent && !eventInProgress && (!codexFailureTerminal || !sawBareError) { return finalizeStream() } } @@ -888,7 +888,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // 靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。 // Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed) // 必须继续读取,不适用提前结束。 - if sawTerminalEvent && !eventInProgress && !(codexFailureTerminal && sawBareError) { + if sawTerminalEvent && !eventInProgress && (!codexFailureTerminal || !sawBareError) { _ = resp.Body.Close() return finalizeStream() } From ac770d98fba6ea02e52395e76c2e0035e16a107c Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 8 Sep 2026 21:16:53 +0800 Subject: [PATCH 004/142] test(groups): initialize Pinia in Codex manifest regression --- .../admin/__tests__/GroupsView.codexManifest.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts index 9595d27ed311..0aef73f4fc5c 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts @@ -1,5 +1,6 @@ import { defineComponent, h, type PropType } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AdminGroup, CodexModelsManifestConfig } from "@/types"; @@ -7,13 +8,13 @@ import GroupsView from "@/views/admin/GroupsView.vue"; const { listGroups, - getModelsListCandidates, + getModelAllowlistCandidates, getUsageSummary, getCapacitySummary, getLiveCapability, } = vi.hoisted(() => ({ listGroups: vi.fn(), - getModelsListCandidates: vi.fn(), + getModelAllowlistCandidates: vi.fn(), getUsageSummary: vi.fn(), getCapacitySummary: vi.fn(), getLiveCapability: vi.fn(), @@ -24,7 +25,7 @@ vi.mock("@/api/admin", () => ({ groups: { list: listGroups, getAll: vi.fn(), - getModelsListCandidates, + getModelAllowlistCandidates, getUsageSummary, getCapacitySummary, getLiveCapability, @@ -204,6 +205,7 @@ const CodexManifestAccountsFieldStub = defineComponent({ const mountView = () => mount(GroupsView, { global: { + plugins: [createPinia()], stubs: { AppLayout: AppLayoutStub, TablePageLayout: TablePageLayoutStub, @@ -230,7 +232,7 @@ describe("GroupsView Codex manifest binding", () => { beforeEach(() => { localStorage.clear(); listGroups.mockReset(); - getModelsListCandidates.mockReset(); + getModelAllowlistCandidates.mockReset(); getUsageSummary.mockReset(); getCapacitySummary.mockReset(); getLiveCapability.mockReset(); @@ -242,7 +244,7 @@ describe("GroupsView Codex manifest binding", () => { page_size: 20, pages: 1, }); - getModelsListCandidates.mockResolvedValue([]); + getModelAllowlistCandidates.mockResolvedValue([]); getUsageSummary.mockResolvedValue([]); getCapacitySummary.mockResolvedValue([]); getLiveCapability.mockResolvedValue({ supported: false }); From d4e8ddb0c5cc6d45ab969f08b62bd1ebcc005b39 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:09:29 +0800 Subject: [PATCH 005/142] fix(grok): allow quota queries during account cooldowns --- .../service/grok_quota_cooldown_test.go | 83 +++++++++++++++++++ .../internal/service/grok_quota_service.go | 5 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/grok_quota_cooldown_test.go diff --git a/backend/internal/service/grok_quota_cooldown_test.go b/backend/internal/service/grok_quota_cooldown_test.go new file mode 100644 index 000000000000..d43af796f991 --- /dev/null +++ b/backend/internal/service/grok_quota_cooldown_test.go @@ -0,0 +1,83 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGrokQuotaQueryRemainsAvailableWhileSchedulingIsPaused(t *testing.T) { + future := time.Now().Add(time.Hour) + cases := []struct { + name string + pause func(*Account) + }{ + {"payment cooldown", func(a *Account) { + a.TempUnschedulableUntil = &future + a.TempUnschedulableReason = "grok payment required" + }}, + {"rate limited", func(a *Account) { a.RateLimitResetAt = &future }}, + {"overloaded", func(a *Account) { a.OverloadUntil = &future }}, + {"manually paused", func(a *Account) { a.Schedulable = false }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := healthyGrokQuotaOAuthAccount(100) + tc.pause(account) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}}} + provider := NewGrokTokenProvider(repo, nil) + used := 100.0 + upstream := &grokHybridUpstream{weeklyUsagePercent: &used} + svc := NewGrokQuotaService(repo, nil, provider, upstream, nil) + + result, err := svc.QueryQuota(context.Background(), account.ID) + require.NoError(t, err) + require.NotNil(t, result.Billing) + require.Equal(t, used, *result.Billing.UsagePercent) + requests, _ := upstream.quotaSnapshot() + require.Len(t, requests, 2) + for _, req := range requests { + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "/v1/billing", req.URL.Path) + } + // Observing quota must not resume model traffic or alter cooldowns. + require.False(t, account.IsSchedulable()) + _, err = provider.GetAccessToken(context.Background(), account) + require.ErrorIs(t, err, errOAuthRefreshAccountStateChanged) + require.Zero(t, repo.tempUnschedCalls) + require.Zero(t, repo.rateLimitedCalls) + require.Zero(t, repo.recoveryClearCalls) + }) + } +} + +func TestGrokQuotaBillingStillRejectsUnavailableCredentialsDuringCooldown(t *testing.T) { + cases := []struct { + name string + invalidate func(*Account) + }{ + {"missing refresh token", func(a *Account) { delete(a.Credentials, "refresh_token") }}, + {"expired access token without refresh service", func(a *Account) { a.Credentials["expires_at"] = time.Now().Add(-time.Hour).Format(time.RFC3339) }}, + {"missing configured proxy", func(a *Account) { id := int64(42); a.ProxyID = &id }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := healthyGrokQuotaOAuthAccount(101) + future := time.Now().Add(time.Hour) + account.TempUnschedulableUntil = &future + tc.invalidate(account) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}}} + upstream := &grokHybridUpstream{} + svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil) + _, err := svc.ProbeBilling(context.Background(), account.ID) + require.ErrorContains(t, err, "GROK_QUOTA_TOKEN_UNAVAILABLE") + requests, _ := upstream.snapshot() + require.Empty(t, requests) + }) + } +} diff --git a/backend/internal/service/grok_quota_service.go b/backend/internal/service/grok_quota_service.go index 293dbcceabd4..391ba8c6abee 100644 --- a/backend/internal/service/grok_quota_service.go +++ b/backend/internal/service/grok_quota_service.go @@ -503,7 +503,10 @@ func (s *GrokQuotaService) prepareProbe(ctx context.Context, accountID int64) (* } proxyURL := s.resolveProxyURL(ctx, account) - token, err := s.tokenProvider.GetAccessToken(ctx, account) + // Quota diagnostics must remain available while scheduling is paused (for + // example after a 402). Use the same credential checks and refresh protocol + // as an admin connection test, without the model-request scheduling gate. + token, err := s.tokenProvider.GetAccessTokenForManualTest(ctx, account) if err != nil { return nil, "", "", infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err) } From f744375a4c77c61aa864a1734e89881ab41b8458 Mon Sep 17 00:00:00 2001 From: zeng gang Date: Fri, 11 Sep 2026 18:27:28 +0800 Subject: [PATCH 006/142] fix(openai): preserve GPT-5.5 requests carrying Responses Lite markers --- .../service/openai_gateway_forward.go | 3 + .../service/openai_gateway_passthrough.go | 3 + .../service/openai_lite_mapped_gpt55.go | 42 +++++++++ .../service/openai_lite_mapped_gpt55_test.go | 94 +++++++++++++++++++ .../internal/service/openai_ws_http_bridge.go | 3 + 5 files changed, 145 insertions(+) create mode 100644 backend/internal/service/openai_lite_mapped_gpt55.go create mode 100644 backend/internal/service/openai_lite_mapped_gpt55_test.go diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 2a459cc286be..e3197dbcadcd 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1517,6 +1517,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") + if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { + return nil, err + } return req, nil } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index dd8f390079a2..33f9a2c3e82e 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -728,6 +728,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") + if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { + return nil, err + } return req, nil } diff --git a/backend/internal/service/openai_lite_mapped_gpt55.go b/backend/internal/service/openai_lite_mapped_gpt55.go new file mode 100644 index 000000000000..e84ed8d7f0f8 --- /dev/null +++ b/backend/internal/service/openai_lite_mapped_gpt55.go @@ -0,0 +1,42 @@ +package service + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// The account mapping changes the model, not the upstream capability header. +// Apply at the final HTTP request boundary, after account/model resolution. +// Never mutate the ingress headers/body: failover may select a native account. +func applyMappedGPT55LiteCompatibility(req *http.Request, account *Account, body []byte) error { + if req == nil || account == nil || !account.IsOpenAIOAuthLike() { + return nil + } + if strings.TrimSpace(gjson.GetBytes(body, "model").String()) != "gpt-5.5" { + return nil + } + if !isOpenAIResponsesLiteHeader(req.Header.Get(responsesLiteHeader)) && !isOpenAIResponsesLiteWebSocketPayload(body) { + return nil + } + // The Codex non-Lite endpoint accepts additional_tools, namespaces and + // reasoning.context=all_turns. Preserve them and all history/tool results. + if isOpenAIResponsesLiteWebSocketPayload(body) { + var err error + body, err = sjson.DeleteBytes(body, "client_metadata."+responsesLiteWSMetadataKey) + if err != nil { + return fmt.Errorf("remove mapped GPT-5.5 Lite metadata: %w", err) + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + savedBody := append([]byte(nil), body...) + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(savedBody)), nil } + } + req.Header.Del(responsesLiteHeader) + return nil +} diff --git a/backend/internal/service/openai_lite_mapped_gpt55_test.go b/backend/internal/service/openai_lite_mapped_gpt55_test.go new file mode 100644 index 000000000000..6c403f218abd --- /dev/null +++ b/backend/internal/service/openai_lite_mapped_gpt55_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMappedGPT55LiteCompatibility(t *testing.T) { + for _, tc := range []struct { + name string + id int64 + model string + mapped bool + wantLite bool + }{ + {"mapped", 12, "gpt-5.5", true, false}, + {"account13Mapped", 13, "gpt-5.5", true, false}, + {"account14Mapped", 14, "gpt-5.5", true, false}, + {"account13Native", 13, "gpt-6-astra", false, true}, + {"account14Native", 14, "gpt-6-astra", false, true}, + {"nativeSol", 12, "gpt-5.6-sol", true, true}, + {"noMapping", 12, "gpt-5.5", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + a := &Account{ID: tc.id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}} + if tc.mapped { + a.Credentials["model_mapping"] = map[string]any{"gpt-5.6-sol": "gpt-5.5"} + } + body := []byte(`{"model":"` + tc.model + `","input":[{"type":"additional_tools","role":"developer","tools":[]},{"type":"function_call_output","call_id":"call1","output":"12345678901234567890"}],"reasoning":{"context":"all_turns"},"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true","keep":"yes"}}`) + r, err := http.NewRequest("POST", "http://localhost", bytes.NewReader(body)) + require.NoError(t, err) + r.Header.Set(responsesLiteHeader, "true") + require.NoError(t, applyMappedGPT55LiteCompatibility(r, a, body)) + require.Equal(t, tc.wantLite, isOpenAIResponsesLiteHeader(r.Header.Get(responsesLiteHeader))) + got, err := io.ReadAll(r.Body) + require.NoError(t, err) + if tc.wantLite { + require.Equal(t, body, got) + } else { + require.False(t, isOpenAIResponsesLiteWebSocketPayload(got)) + require.Equal(t, gjson.GetBytes(body, "input").Raw, gjson.GetBytes(got, "input").Raw) + require.Equal(t, "all_turns", gjson.GetBytes(got, "reasoning.context").String()) + require.Equal(t, "yes", gjson.GetBytes(got, "client_metadata.keep").String()) + require.Equal(t, int64(len(got)), r.ContentLength) + replay, err := r.GetBody() + require.NoError(t, err) + again, err := io.ReadAll(replay) + require.NoError(t, err) + require.Equal(t, got, again) + } + require.True(t, isOpenAIResponsesLiteWebSocketPayload(body)) + }) + } +} + +func TestMappedGPT55LiteBuildersPreserveIngressForFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, passthrough := range []bool{false, true} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + c.Request.Header.Set(responsesLiteHeader, "true") + s := &OpenAIGatewayService{cfg: &config.Config{}} + a := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.5"}}} + body := []byte(`{"model":"gpt-5.5","stream":true,"input":[]}`) + var r *http.Request + var err error + if passthrough { + r, err = s.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, a, body, "test-token") + } else { + r, err = s.buildUpstreamRequest(context.Background(), c, a, body, "test-token", true, "", true) + } + require.NoError(t, err) + require.Empty(t, r.Header.Get(responsesLiteHeader)) + require.Equal(t, "true", c.GetHeader(responsesLiteHeader)) + a.ID = 14 + body = []byte(`{"model":"gpt-6-astra","stream":true,"input":[]}`) + if passthrough { + r, err = s.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, a, body, "test-token") + } else { + r, err = s.buildUpstreamRequest(context.Background(), c, a, body, "test-token", true, "", true) + } + require.NoError(t, err) + require.Equal(t, "true", r.Header.Get(responsesLiteHeader)) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 52420c584aeb..0d00dacb79f7 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -504,6 +504,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { upstreamReq.Header.Set(responsesLiteHeader, "true") } + if err := applyMappedGPT55LiteCompatibility(upstreamReq, account, requestBody); err != nil { + return nil, err + } return upstreamReq, nil } if account.Platform == PlatformGrok { From 8e23e131589ff09773819fcf898cb3a623fb27a4 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 12 Sep 2026 14:29:47 +0800 Subject: [PATCH 007/142] test(images): reproduce compatible Gemini image rejection --- .../service/openai_images_compatible_test.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 backend/internal/service/openai_images_compatible_test.go diff --git a/backend/internal/service/openai_images_compatible_test.go b/backend/internal/service/openai_images_compatible_test.go new file mode 100644 index 000000000000..4adc7c208af9 --- /dev/null +++ b/backend/internal/service/openai_images_compatible_test.go @@ -0,0 +1,148 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCompatibleImagesGeminiModels(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, model := range []string{"gemini-2.5-flash-image", "gemini-2.5-flash-image-preview", "gemini-3-pro-image", "gemini-3.1-flash-image"} { + t.Run(model, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw"}`, model)) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, openAIImagesGenerationsEndpoint, bytes.NewReader(body)) + parsed, err := (&OpenAIGatewayService{}).ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.True(t, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).SupportsOpenAIImageCapability(parsed.RequiredCapability)) + for _, typ := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + require.False(t, (&Account{Platform: PlatformOpenAI, Type: typ}).SupportsOpenAIImageCapability(parsed.RequiredCapability)) + } + require.False(t, isOpenAIImageGenerationModel(model), "compatible image IDs must not enter native Responses normalization") + }) + } + for _, model := range []string{"gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro-imageless", "unknown-image", "gpt-5.5"} { + t.Run("reject_"+model, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw"}`, model)) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, openAIImagesGenerationsEndpoint, bytes.NewReader(body)) + _, err := (&OpenAIGatewayService{}).ParseOpenAIImagesRequest(c, body) + require.ErrorContains(t, err, "images endpoint requires an image model") + }) + } +} + +func TestCompatibleImagesForwardGemini(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, kind := range []string{"generation", "json_edit", "multipart_edit", "composite_multipart_alias", "channel_mapping", "account_mapping"} { + t.Run(kind, func(t *testing.T) { + model := "gemini-3.1-flash-image" + endpoint := openAIImagesGenerationsEndpoint + contentType := "application/json" + channelModel := "" + credentials := map[string]any{"api_key": "image-key", "base_url": "https://compatible.example/v1"} + requestModel := model + if kind == "channel_mapping" { + requestModel, channelModel = "gpt-image-2", model + } + if kind == "account_mapping" { + requestModel = "gpt-image-2" + credentials["model_mapping"] = map[string]any{requestModel: model} + } + body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw","size":"1024x1024","custom_field":"preserved"}`, requestModel)) + if kind == "json_edit" { + endpoint = openAIImagesEditsEndpoint + body = []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw","images":[{"image_url":"https://source.example/input.png"}],"custom_field":"preserved"}`, model)) + } + if strings.Contains(kind, "multipart") { + endpoint = openAIImagesEditsEndpoint + if kind == "composite_multipart_alias" { + requestModel = "public-image" + } + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", requestModel)) + require.NoError(t, writer.WriteField("prompt", "draw")) + require.NoError(t, writer.WriteField("custom_field", "preserved")) + part, err := writer.CreateFormFile("image", "input.png") + require.NoError(t, err) + _, err = part.Write([]byte("original-image-bytes")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + body, contentType = buf.Bytes(), writer.FormDataContentType() + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", contentType) + if kind == "composite_multipart_alias" { + ctx := WithResolvedTargetPlatform(c.Request.Context(), PlatformOpenAI) + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{Matched: true, TargetPlatform: PlatformOpenAI, UpstreamModel: model, PublicModel: requestModel}) + c.Request = c.Request.WithContext(ctx) + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":[{"b64_json":"aW1hZ2U="}]}`))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + result, err := svc.ForwardImages(c.Request.Context(), c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: credentials}, body, parsed, channelModel) + require.NoError(t, err) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, model, result.UpstreamModel) + require.Equal(t, firstNonEmptyString(channelModel, parsed.Model), result.Model) + require.Equal(t, "https://compatible.example"+endpoint, upstream.lastReq.URL.String()) + require.Equal(t, "Bearer image-key", upstream.lastReq.Header.Get("Authorization")) + if strings.Contains(kind, "multipart") { + r := httptest.NewRequest(http.MethodPost, endpoint, bytes.NewReader(upstream.lastBody)) + r.Header.Set("Content-Type", upstream.lastReq.Header.Get("Content-Type")) + require.NoError(t, r.ParseMultipartForm(1<<20)) + require.Equal(t, model, r.FormValue("model")) + require.Equal(t, "preserved", r.FormValue("custom_field")) + file, _, err := r.FormFile("image") + require.NoError(t, err) + defer file.Close() + data, err := io.ReadAll(file) + require.NoError(t, err) + require.Equal(t, "original-image-bytes", string(data)) + } else { + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "preserved", gjson.GetBytes(upstream.lastBody, "custom_field").String()) + } + }) + } +} + +func TestCompatibleImagesNativeAccountsRejectGeminiBeforeForwarding(t *testing.T) { + for _, typ := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + for _, mapping := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/mapping=%t", typ, mapping), func(t *testing.T) { + model := "gemini-3-pro-image" + account := &Account{Platform: PlatformOpenAI, Type: typ, Credentials: map[string]any{"access_token": "unused"}} + if mapping { + account.Credentials["model_mapping"] = map[string]any{"gpt-image-2": model} + model = "gpt-image-2" + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, openAIImagesGenerationsEndpoint, nil) + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + _, err := svc.ForwardImages(context.Background(), c, account, nil, &OpenAIImagesRequest{Model: model}, "") + require.ErrorContains(t, err, "images endpoint requires an image model") + require.Empty(t, upstream.requests) + }) + } + } +} From 9fedf899e2d26fa33f295211ca9393b54ad05c14 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 12 Sep 2026 14:34:31 +0800 Subject: [PATCH 008/142] fix(images): route compatible Gemini image models through API keys --- backend/internal/handler/openai_images.go | 2 +- .../composite_images_compatible_test.go | 160 ++++++++++++++++++ backend/internal/service/account.go | 2 + backend/internal/service/openai_images.go | 42 ++++- .../service/openai_images_compatible_test.go | 3 + 5 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 backend/internal/server/routes/composite_images_compatible_test.go diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 18e1d59195e5..99008617153c 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -165,7 +165,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { sessionHash, routingModel, failedAccountIDs, - parsed.RequiredCapability, + parsed.RequiredCapabilityForModel(channelMapping.MappedModel), ) if err != nil { if failoverClientGone(c) { diff --git a/backend/internal/server/routes/composite_images_compatible_test.go b/backend/internal/server/routes/composite_images_compatible_test.go new file mode 100644 index 000000000000..1e0b9406ba4c --- /dev/null +++ b/backend/internal/server/routes/composite_images_compatible_test.go @@ -0,0 +1,160 @@ +//go:build unit + +package routes + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type compatibleImagesAccounts struct { + service.AccountRepository + accounts []service.Account +} + +func (r compatibleImagesAccounts) GetByID(_ context.Context, id int64) (*service.Account, error) { + for _, account := range r.accounts { + if account.ID == id { + return &account, nil + } + } + return nil, service.ErrNoAvailableAccounts +} + +func (r compatibleImagesAccounts) ListSchedulableByPlatform(context.Context, string) ([]service.Account, error) { + return r.accounts, nil +} + +func (r compatibleImagesAccounts) ListSchedulableUngroupedByPlatform(context.Context, string) ([]service.Account, error) { + return r.accounts, nil +} + +func (r compatibleImagesAccounts) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]service.Account, error) { + return r.accounts, nil +} + +type compatibleImagesUpstream struct { + service.HTTPUpstream + accountIDs []int64 + body []byte +} + +func (u *compatibleImagesUpstream) Do(req *http.Request, _ string, id int64, _ int) (*http.Response, error) { + u.accountIDs = append(u.accountIDs, id) + var err error + u.body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"compatible-image-test"}}, Body: io.NopCloser(strings.NewReader(`{"data":[{"b64_json":"aW1hZ2U="}]}`))}, nil +} + +type compatibleImagesUsage struct { + service.UsageLogRepository + logs []*service.UsageLog +} + +func (r *compatibleImagesUsage) Create(_ context.Context, log *service.UsageLog) (bool, error) { + r.logs = append(r.logs, log) + return true, nil +} + +func TestCompositeCompatibleImagesEndToEnd(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, scenario := range []string{"generation", "json_edit", "multipart_alias", "disabled", "native_only", "restricted"} { + t.Run(scenario, func(t *testing.T) { + const model = "gemini-3.1-flash-image" + groupID := int64(101) + price := 0.17 + group := &service.Group{ID: groupID, Platform: service.PlatformComposite, AllowImageGeneration: scenario != "disabled", RateMultiplier: 1, ImagePrice1K: &price, ImagePrice2K: &price, ImagePrice4K: &price} + accounts := []service.Account{} + // Native accounts deliberately advertise the same model and have higher + // priority, proving the image capability fence controls selection. + for i, typ := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey} { + if scenario == "native_only" && typ == service.AccountTypeAPIKey { + continue + } + mapping := map[string]any{model: model} + if scenario == "restricted" && typ == service.AccountTypeAPIKey { + mapping = map[string]any{"gpt-image-2": "gpt-image-2"} + } + accounts = append(accounts, service.Account{ID: int64(i + 1), Platform: service.PlatformOpenAI, Type: typ, Status: service.StatusActive, Schedulable: true, Priority: i, Credentials: map[string]any{"api_key": "fixture-key", "access_token": "unused-native-token", "base_url": "https://compatible.example/v1", "model_mapping": mapping}}) + } + cfg := &config.Config{RunMode: config.RunModeSimple} + repo := compatibleImagesAccounts{accounts: accounts} + upstream, usage := &compatibleImagesUpstream{}, &compatibleImagesUsage{} + billingCache := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(billingCache.Stop) + gateway := service.NewOpenAIGatewayService(repo, usage, nil, nil, nil, nil, nil, cfg, nil, nil, service.NewBillingService(cfg, nil), nil, billingCache, upstream, &service.DeferredService{}, nil, nil, nil, nil, nil, nil, nil) + imagesHandler := handler.NewOpenAIGatewayHandler(gateway, service.NewConcurrencyService(nil), billingCache, service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg), nil, nil, nil, nil, cfg) + publicModel := model + if scenario == "multipart_alias" { + publicModel = "public-image" + } + resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{routes: []service.CompositeModelRoute{{ID: 1, GroupID: groupID, PublicModel: publicModel, MatchType: service.CompositeRouteMatchExact, TargetPlatform: service.PlatformOpenAI, UpstreamModel: model, Endpoint: service.CompositeRouteEndpointImages, Enabled: true}}}) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{ID: 202, GroupID: &groupID, Group: group, User: &service.User{ID: 303}}) + c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 303}) + c.Next() + }) + router.Use(compositeTargetPlatformMiddleware(resolver)) + router.POST("/v1/images/generations", imagesHandler.Images) + router.POST("/v1/images/edits", imagesHandler.Images) + endpoint, contentType := "/v1/images/generations", "application/json" + body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw","size":"1024x1024"}`, publicModel)) + if scenario == "json_edit" { + endpoint = "/v1/images/edits" + body = []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw","images":[{"image_url":"https://source.example/input.png"}]}`, publicModel)) + } + if scenario == "multipart_alias" { + endpoint = "/v1/images/edits" + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", publicModel)) + require.NoError(t, writer.WriteField("prompt", "draw")) + part, err := writer.CreateFormFile("image", "input.png") + require.NoError(t, err) + _, err = part.Write([]byte("fixture-image")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + body, contentType = buf.Bytes(), writer.FormDataContentType() + } + req := httptest.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if scenario == "disabled" || scenario == "native_only" || scenario == "restricted" { + require.GreaterOrEqual(t, rec.Code, 400, rec.Body.String()) + if scenario == "disabled" { + require.Equal(t, http.StatusForbidden, rec.Code) + } + require.Empty(t, upstream.accountIDs) + require.Empty(t, usage.logs) + return + } + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Equal(t, []int64{3}, upstream.accountIDs) + require.Contains(t, string(upstream.body), model) + require.Contains(t, rec.Body.String(), "aW1hZ2U=") + require.Len(t, usage.logs, 1, "the image must reach usage recording, not just return HTTP 200") + require.Equal(t, 1, usage.logs[0].ImageCount) + require.InDelta(t, price, usage.logs[0].ActualCost, 1e-9) + require.Equal(t, publicModel, usage.logs[0].OriginalModel) + }) + } +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index b5268e910266..bee3e8946aa0 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -2008,6 +2008,8 @@ func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapabilit return false } switch capability { + case OpenAIImagesCapabilityAPIKey: + return a.Type == AccountTypeAPIKey case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative: return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey default: diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 0fe06a334b1c..c71a9ea05db9 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -59,6 +59,8 @@ type OpenAIImagesCapability string const ( OpenAIImagesCapabilityBasic OpenAIImagesCapability = "images-basic" OpenAIImagesCapabilityNative OpenAIImagesCapability = "images-native" + // Compatible provider models require the API-key Images passthrough path. + OpenAIImagesCapabilityAPIKey OpenAIImagesCapability = "images-apikey" ) type OpenAIImagesUpload struct { @@ -227,7 +229,14 @@ func (s *OpenAIGatewayService) ParseOpenAIImagesRequest(c *gin.Context, body []b } applyOpenAIImagesDefaults(req) - if err := validateOpenAIImagesModel(req.Model); err != nil { + // Composite middleware preserves multipart bodies, including their public + // alias. Validate and forward the resolved model without altering uploads. + if platform, _ := ResolvedTargetPlatformFromContext(c.Request.Context()); platform == PlatformOpenAI { + if model, ok := ResolvedUpstreamModelFromContext(c.Request.Context()); ok { + req.Model = model + } + } + if err := validateCompatibleImagesModel(req.Model); err != nil { return nil, err } req.SizeTier = normalizeOpenAIImageSizeTier(req.Size) @@ -494,6 +503,30 @@ func validateOpenAIImagesModel(model string) error { return fmt.Errorf("images endpoint requires an image model, got %q", model) } +// Keep this separate from isOpenAIImageGenerationModel: that predicate also +// drives native Responses tool conversion, pricing and rate-limit policy. +func isGeminiCompatibleImageModel(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(model, "gemini-") && + (strings.HasSuffix(model, "-image") || strings.Contains(model, "-image-")) +} + +func validateCompatibleImagesModel(model string) error { + if isGeminiCompatibleImageModel(model) { + return nil + } + return validateOpenAIImagesModel(model) +} + +// RequiredCapabilityForModel also applies the API-key-only fence when channel +// mapping introduces a compatible provider model after request parsing. +func (req *OpenAIImagesRequest) RequiredCapabilityForModel(model string) OpenAIImagesCapability { + if isGeminiCompatibleImageModel(model) { + return OpenAIImagesCapabilityAPIKey + } + return req.RequiredCapability +} + func normalizeOpenAIImagesEndpointPath(path string) string { trimmed := strings.TrimSpace(path) switch { @@ -510,6 +543,9 @@ func classifyOpenAIImagesCapability(req *OpenAIImagesRequest) OpenAIImagesCapabi if req == nil { return OpenAIImagesCapabilityNative } + if isGeminiCompatibleImageModel(req.Model) { + return OpenAIImagesCapabilityAPIKey + } if req.ExplicitModel || req.ExplicitSize { return OpenAIImagesCapabilityNative } @@ -594,11 +630,11 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if mapped := strings.TrimSpace(channelMappedModel); mapped != "" { requestModel = mapped } - if err := validateOpenAIImagesModel(requestModel); err != nil { + if err := validateCompatibleImagesModel(requestModel); err != nil { return nil, err } upstreamModel := account.GetMappedModel(requestModel) - if err := validateOpenAIImagesModel(upstreamModel); err != nil { + if err := validateCompatibleImagesModel(upstreamModel); err != nil { return nil, err } SetOpsUpstreamModel(c, upstreamModel) diff --git a/backend/internal/service/openai_images_compatible_test.go b/backend/internal/service/openai_images_compatible_test.go index 4adc7c208af9..10e50f8104a9 100644 --- a/backend/internal/service/openai_images_compatible_test.go +++ b/backend/internal/service/openai_images_compatible_test.go @@ -98,6 +98,9 @@ func TestCompatibleImagesForwardGemini(t *testing.T) { svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} parsed, err := svc.ParseOpenAIImagesRequest(c, body) require.NoError(t, err) + if kind == "channel_mapping" { + require.Equal(t, OpenAIImagesCapabilityAPIKey, parsed.RequiredCapabilityForModel(channelModel)) + } result, err := svc.ForwardImages(c.Request.Context(), c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: credentials}, body, parsed, channelModel) require.NoError(t, err) require.Equal(t, 1, result.ImageCount) From baa39f39e8a706fb8a6148565f3c10b9c7757f54 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 12 Sep 2026 14:44:47 +0800 Subject: [PATCH 009/142] test(images): assert the recorded requested model field --- .../internal/server/routes/composite_images_compatible_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/server/routes/composite_images_compatible_test.go b/backend/internal/server/routes/composite_images_compatible_test.go index 1e0b9406ba4c..516a84bb4f5a 100644 --- a/backend/internal/server/routes/composite_images_compatible_test.go +++ b/backend/internal/server/routes/composite_images_compatible_test.go @@ -154,7 +154,7 @@ func TestCompositeCompatibleImagesEndToEnd(t *testing.T) { require.Len(t, usage.logs, 1, "the image must reach usage recording, not just return HTTP 200") require.Equal(t, 1, usage.logs[0].ImageCount) require.InDelta(t, price, usage.logs[0].ActualCost, 1e-9) - require.Equal(t, publicModel, usage.logs[0].OriginalModel) + require.Equal(t, publicModel, usage.logs[0].RequestedModel) }) } } From c24da29f6b37fcd0e54967bf1992772b82b89ee4 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 12 Sep 2026 14:46:50 +0800 Subject: [PATCH 010/142] test(images): require explicit incompatible-account rejection --- .../server/routes/composite_images_compatible_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/internal/server/routes/composite_images_compatible_test.go b/backend/internal/server/routes/composite_images_compatible_test.go index 516a84bb4f5a..e8672e7c614e 100644 --- a/backend/internal/server/routes/composite_images_compatible_test.go +++ b/backend/internal/server/routes/composite_images_compatible_test.go @@ -47,6 +47,10 @@ func (r compatibleImagesAccounts) ListSchedulableByGroupIDAndPlatform(context.Co return r.accounts, nil } +func (r compatibleImagesAccounts) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]service.Account, error) { + return r.accounts, nil +} + type compatibleImagesUpstream struct { service.HTTPUpstream accountIDs []int64 @@ -139,9 +143,10 @@ func TestCompositeCompatibleImagesEndToEnd(t *testing.T) { rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if scenario == "disabled" || scenario == "native_only" || scenario == "restricted" { - require.GreaterOrEqual(t, rec.Code, 400, rec.Body.String()) if scenario == "disabled" { require.Equal(t, http.StatusForbidden, rec.Code) + } else { + require.Equal(t, http.StatusServiceUnavailable, rec.Code, rec.Body.String()) } require.Empty(t, upstream.accountIDs) require.Empty(t, usage.logs) From 5d781ee6b7dcda2dd22d86108ae70f52253a889b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=85=B0=E9=91=AB?= <1735612127@qq.com> Date: Sat, 12 Sep 2026 23:25:57 +0800 Subject: [PATCH 011/142] fix(antigravity): handle prefixItems and ensure array schema has valid items fallback - Handle JSON Schema Draft 2020-12 prefixItems (tuple arrays) by converting to a best-match items schema - Ensure array schema always defines a valid items schema to prevent Gemini 400 'missing field: items.items' error - Fix recursive traversal when a schema defines both properties and items - Add comprehensive unit test cases for tuple and array schemas --- .../pkg/antigravity/schema_cleaner.go | 124 +++++++++++------- .../pkg/antigravity/schema_cleaner_test.go | 110 ++++++++++++++++ 2 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 backend/internal/pkg/antigravity/schema_cleaner_test.go diff --git a/backend/internal/pkg/antigravity/schema_cleaner.go b/backend/internal/pkg/antigravity/schema_cleaner.go index 0ee746aa3781..8988603ff11c 100644 --- a/backend/internal/pkg/antigravity/schema_cleaner.go +++ b/backend/internal/pkg/antigravity/schema_cleaner.go @@ -119,14 +119,27 @@ func cleanJSONSchemaRecursive(value any) any { // 0. [NEW] 合并 allOf mergeAllOf(schemaMap) + // 0.5 [FIX] 处理 Draft 2020-12 的 prefixItems (元组定义) + if prefixItems, ok := schemaMap["prefixItems"].([]any); ok && len(prefixItems) > 0 { + itemsVal, hasItems := schemaMap["items"] + _, itemsIsBool := itemsVal.(bool) + if !hasItems || itemsVal == nil || itemsIsBool { + best := extractBestSchemaFromUnion(prefixItems) + if best == nil { + best = map[string]any{"type": "string"} + } + schemaMap["items"] = best + } + delete(schemaMap, "prefixItems") + } + // 1. [CRITICAL] 深度递归处理子项 if props, ok := schemaMap["properties"].(map[string]any); ok { for _, v := range props { cleanJSONSchemaRecursive(v) } - // Go 中不需要像 Rust 那样显式处理 nullable_keys remove required, - // 因为我们在子项处理中会正确设置 type 和 description - } else if items, ok := schemaMap["items"]; ok { + } + if items, ok := schemaMap["items"]; ok { // [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。 if itemsArr, ok := items.([]any); ok { // 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。 @@ -141,8 +154,9 @@ func cleanJSONSchemaRecursive(value any) any { } else { cleanJSONSchemaRecursive(items) } - } else { - // 遍历所有值递归 + } + // 如果既无 properties 也无 items,遍历其他 map/slice 子项递归 + if !hasKey(schemaMap, "properties") && !hasKey(schemaMap, "items") { for _, v := range schemaMap { if _, isMap := v.(map[string]any); isMap { cleanJSONSchemaRecursive(v) @@ -238,43 +252,7 @@ func cleanJSONSchemaRecursive(value any) any { } } - // 6. [SAFETY] 处理空 Object - if t, _ := schemaMap["type"].(string); t == "object" { - hasProps := false - if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 { - hasProps = true - } - if !hasProps { - schemaMap["properties"] = map[string]any{ - "reason": map[string]any{ - "type": "string", - "description": "Reason for calling this tool", - }, - } - schemaMap["required"] = []any{"reason"} - } - } - - // 7. [SAFETY] Required 字段对齐 - if props, ok := schemaMap["properties"].(map[string]any); ok { - if req, ok := schemaMap["required"].([]any); ok { - var validReq []any - for _, r := range req { - if rStr, ok := r.(string); ok { - if _, exists := props[rStr]; exists { - validReq = append(validReq, r) - } - } - } - if len(validReq) > 0 { - schemaMap["required"] = validReq - } else { - delete(schemaMap, "required") - } - } - } - - // 8. 处理 type 字段 (Lowercase + Nullable 提取) + // 6. 处理 type 字段 (Lowercase + Nullable 提取) isEffectivelyNullable := false if typeVal, exists := schemaMap["type"]; exists { var selectedType string @@ -305,16 +283,72 @@ func cleanJSONSchemaRecursive(value any) any { } schemaMap["type"] = selectedType } else { - // 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist) - // 如果没有 type,但有 properties,补一个 + // 如果没有 type,但有 properties/items,推断并补全 if hasKey(schemaMap, "properties") { schemaMap["type"] = "object" + } else if hasKey(schemaMap, "items") { + schemaMap["type"] = "array" } else { - // 默认为 string ? or object? Gemini 通常需要明确 type schemaMap["type"] = "object" } } + // 7. [SAFETY] 处理空 Object + if t, _ := schemaMap["type"].(string); t == "object" { + hasProps := false + if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 { + hasProps = true + } + if !hasProps { + schemaMap["properties"] = map[string]any{ + "reason": map[string]any{ + "type": "string", + "description": "Reason for calling this tool", + }, + } + schemaMap["required"] = []any{"reason"} + } + } + + // 8. [SAFETY] 处理缺失 items 的 Array (Gemini 严苛要求 array 必须声明 items Schema) + if t, _ := schemaMap["type"].(string); t == "array" { + itemsVal, hasItems := schemaMap["items"] + needDefaultItems := false + if !hasItems || itemsVal == nil { + needDefaultItems = true + } else if itemsMap, ok := itemsVal.(map[string]any); ok { + if len(itemsMap) == 0 { + needDefaultItems = true + } + } else if _, isBool := itemsVal.(bool); isBool { + needDefaultItems = true + } + if needDefaultItems { + schemaMap["items"] = map[string]any{ + "type": "string", + } + } + } + + // 9. [SAFETY] Required 字段对齐 + if props, ok := schemaMap["properties"].(map[string]any); ok { + if req, ok := schemaMap["required"].([]any); ok { + var validReq []any + for _, r := range req { + if rStr, ok := r.(string); ok { + if _, exists := props[rStr]; exists { + validReq = append(validReq, r) + } + } + } + if len(validReq) > 0 { + schemaMap["required"] = validReq + } else { + delete(schemaMap, "required") + } + } + } + if isEffectivelyNullable { desc, _ := schemaMap["description"].(string) if !strings.Contains(desc, "nullable") { diff --git a/backend/internal/pkg/antigravity/schema_cleaner_test.go b/backend/internal/pkg/antigravity/schema_cleaner_test.go new file mode 100644 index 000000000000..516d010c7257 --- /dev/null +++ b/backend/internal/pkg/antigravity/schema_cleaner_test.go @@ -0,0 +1,110 @@ +package antigravity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanJSONSchema_ArrayPrefixItems(t *testing.T) { + // 模拟 Claude Code 2.1 Artifact 工具的 query.where 参数 Schema (Draft 2020-12 prefixItems 元组) + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "object", + "properties": map[string]any{ + "where": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "array", + "prefixItems": []any{ + map[string]any{"type": "string"}, + map[string]any{"type": "string", "enum": []any{"==", "!=", ">", "<"}}, + map[string]any{}, + }, + }, + "maxItems": float64(10), + }, + }, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + query, ok := cleaned["properties"].(map[string]any)["query"].(map[string]any) + require.True(t, ok) + where, ok := query["properties"].(map[string]any)["where"].(map[string]any) + require.True(t, ok) + + assert.Equal(t, "array", where["type"]) + // prefixItems 应被移除或替换 + assert.Nil(t, where["prefixItems"]) + + whereItems, ok := where["items"].(map[string]any) + require.True(t, ok, "where.items must be an object") + assert.Equal(t, "array", whereItems["type"]) + + // 关键验证:where.items.items 必须存在且有效,不能缺失导致 Gemini 400 + innerItems, ok := whereItems["items"].(map[string]any) + require.True(t, ok, "where.items.items must be an object") + assert.Equal(t, "string", innerItems["type"]) +} + +func TestCleanJSONSchema_ArrayMissingItemsFallback(t *testing.T) { + // 针对任何缺少 items 的 array,必须兜底注入 items: {type: string} + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{ + "type": "array", + }, + "nested_empty_array": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "array", + }, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + props := cleaned["properties"].(map[string]any) + tags := props["tags"].(map[string]any) + require.NotNil(t, tags["items"]) + assert.Equal(t, "string", tags["items"].(map[string]any)["type"]) + + nested := props["nested_empty_array"].(map[string]any) + nestedItems := nested["items"].(map[string]any) + assert.Equal(t, "array", nestedItems["type"]) + require.NotNil(t, nestedItems["items"]) + assert.Equal(t, "string", nestedItems["items"].(map[string]any)["type"]) +} + +func TestCleanJSONSchema_ArrayExistingItemsPreserved(t *testing.T) { + // 正常的 array items 不受影响 + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "numbers": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "integer", + }, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + numbers := cleaned["properties"].(map[string]any)["numbers"].(map[string]any) + assert.Equal(t, "array", numbers["type"]) + items := numbers["items"].(map[string]any) + assert.Equal(t, "integer", items["type"]) +} From 69d49744cf644b1dc7c73bf57167047e4db1e089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=85=B0=E9=91=AB?= <1735612127@qq.com> Date: Sun, 13 Sep 2026 03:57:18 +0800 Subject: [PATCH 012/142] fix(antigravity): address code review feedback on schema cleaner - Fix test assertion target location for whereItems prefixItems - Support const keyword normalization to enum and infer scalar type - Infer scalar type for enum-only schema to prevent object reason injection - Recursively clean properties and items after anyOf/oneOf merge - Ensure empty prefixItems is safely deleted --- .../pkg/antigravity/schema_cleaner.go | 77 ++++++++++-- .../pkg/antigravity/schema_cleaner_test.go | 115 +++++++++++++++++- 2 files changed, 180 insertions(+), 12 deletions(-) diff --git a/backend/internal/pkg/antigravity/schema_cleaner.go b/backend/internal/pkg/antigravity/schema_cleaner.go index 8988603ff11c..9ac4211e4aff 100644 --- a/backend/internal/pkg/antigravity/schema_cleaner.go +++ b/backend/internal/pkg/antigravity/schema_cleaner.go @@ -120,16 +120,21 @@ func cleanJSONSchemaRecursive(value any) any { mergeAllOf(schemaMap) // 0.5 [FIX] 处理 Draft 2020-12 的 prefixItems (元组定义) - if prefixItems, ok := schemaMap["prefixItems"].([]any); ok && len(prefixItems) > 0 { - itemsVal, hasItems := schemaMap["items"] - _, itemsIsBool := itemsVal.(bool) - if !hasItems || itemsVal == nil || itemsIsBool { - best := extractBestSchemaFromUnion(prefixItems) - if best == nil { - best = map[string]any{"type": "string"} + if prefixItemsVal, hasPrefix := schemaMap["prefixItems"]; hasPrefix { + if prefixItems, ok := prefixItemsVal.([]any); ok && len(prefixItems) > 0 { + itemsVal, hasItems := schemaMap["items"] + _, itemsIsBool := itemsVal.(bool) + // 如果没有 items,或者 items 为 nil,或者 items 是布尔值(如 Draft 2020-12 中表示元组闭合的 items: false), + // 用选出的最佳元组项填充 items,以满足 Gemini 对 array.items 必须为合法 Schema 对象的规范。 + if !hasItems || itemsVal == nil || itemsIsBool { + best := extractBestSchemaFromUnion(prefixItems) + if best == nil { + best = map[string]any{"type": "string"} + } + schemaMap["items"] = best } - schemaMap["items"] = best } + // 无论 prefixItems 是否为空数组,都彻底删除 prefixItems 关键字以防 Gemini 400 delete(schemaMap, "prefixItems") } @@ -219,6 +224,18 @@ func cleanJSONSchemaRecursive(value any) any { schemaMap[k] = deepCopy(v) } } + // 清除已合并的 union 关键字 + delete(schemaMap, "anyOf") + delete(schemaMap, "oneOf") + // 对合并进来的新属性和 items 进行深度递归清洗,确保合并进来的子结构(如 prefixItems)也能被处理 + if props, ok := schemaMap["properties"].(map[string]any); ok { + for _, pv := range props { + cleanJSONSchemaRecursive(pv) + } + } + if items, ok := schemaMap["items"]; ok { + cleanJSONSchemaRecursive(items) + } } } } @@ -228,14 +245,37 @@ func cleanJSONSchemaRecursive(value any) any { hasKey(schemaMap, "properties") || hasKey(schemaMap, "items") || hasKey(schemaMap, "enum") || + hasKey(schemaMap, "const") || hasKey(schemaMap, "anyOf") || hasKey(schemaMap, "oneOf") || hasKey(schemaMap, "allOf") if looksLikeSchema { - // 4. [ROBUST] 约束迁移 + // 4. [ROBUST] 约束迁移与 const 规范化 migrateConstraints(schemaMap) + // 规范化 const 关键字 (Draft 6+ 转换为 Gemini 兼容的 enum: [const]) + if constVal, exists := schemaMap["const"]; exists { + if _, hasEnum := schemaMap["enum"]; !hasEnum { + schemaMap["enum"] = []any{constVal} + } + if _, hasType := schemaMap["type"]; !hasType { + switch constVal.(type) { + case string: + schemaMap["type"] = "string" + case int, int32, int64: + schemaMap["type"] = "integer" + case float32, float64: + schemaMap["type"] = "number" + case bool: + schemaMap["type"] = "boolean" + default: + schemaMap["type"] = "string" + } + } + delete(schemaMap, "const") + } + // 5. [CRITICAL] 白名单过滤 allowedFields := map[string]bool{ "type": true, @@ -283,11 +323,25 @@ func cleanJSONSchemaRecursive(value any) any { } schemaMap["type"] = selectedType } else { - // 如果没有 type,但有 properties/items,推断并补全 + // 如果没有 type,但有 properties/items/enum,推断并补全 if hasKey(schemaMap, "properties") { schemaMap["type"] = "object" } else if hasKey(schemaMap, "items") { schemaMap["type"] = "array" + } else if enumArr, ok := schemaMap["enum"].([]any); ok && len(enumArr) > 0 { + // [FIX] 针对仅含 enum 的 schema,按其元素类型推断标量类型,避免被误判为 object 并注入 reason 属性 + switch enumArr[0].(type) { + case int, int32, int64: + schemaMap["type"] = "integer" + case float32, float64: + schemaMap["type"] = "number" + case bool: + schemaMap["type"] = "boolean" + default: + schemaMap["type"] = "string" + } + } else if hasKey(schemaMap, "enum") { + schemaMap["type"] = "string" } else { schemaMap["type"] = "object" } @@ -525,6 +579,9 @@ func scoreSchemaOption(val any) int { if hasKey(m, "items") || typeStr == "array" { return 2 } + if hasKey(m, "enum") || hasKey(m, "const") { + return 1 + } if typeStr != "" && typeStr != "null" { return 1 } diff --git a/backend/internal/pkg/antigravity/schema_cleaner_test.go b/backend/internal/pkg/antigravity/schema_cleaner_test.go index 516d010c7257..0401b670adce 100644 --- a/backend/internal/pkg/antigravity/schema_cleaner_test.go +++ b/backend/internal/pkg/antigravity/schema_cleaner_test.go @@ -41,12 +41,12 @@ func TestCleanJSONSchema_ArrayPrefixItems(t *testing.T) { require.True(t, ok) assert.Equal(t, "array", where["type"]) - // prefixItems 应被移除或替换 - assert.Nil(t, where["prefixItems"]) whereItems, ok := where["items"].(map[string]any) require.True(t, ok, "where.items must be an object") assert.Equal(t, "array", whereItems["type"]) + // prefixItems 应在 whereItems 中被彻底移除 + assert.Nil(t, whereItems["prefixItems"]) // 关键验证:where.items.items 必须存在且有效,不能缺失导致 Gemini 400 innerItems, ok := whereItems["items"].(map[string]any) @@ -108,3 +108,114 @@ func TestCleanJSONSchema_ArrayExistingItemsPreserved(t *testing.T) { items := numbers["items"].(map[string]any) assert.Equal(t, "integer", items["type"]) } + +func TestCleanJSONSchema_EnumOnlySchemaInTuple(t *testing.T) { + // 测试 enum-only schema 不会被误判为 object,也不应被注入 reason 属性 + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "choice": map[string]any{ + "enum": []any{"option_a", "option_b"}, + }, + "tuple_with_enum": map[string]any{ + "type": "array", + "prefixItems": []any{ + map[string]any{ + "enum": []any{"read", "write"}, + }, + }, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + props := cleaned["properties"].(map[string]any) + choice := props["choice"].(map[string]any) + assert.Equal(t, "string", choice["type"]) + assert.Nil(t, choice["properties"], "enum-only schema must NOT be treated as object with reason property") + assert.Equal(t, []any{"option_a", "option_b"}, choice["enum"]) + + tupleArray := props["tuple_with_enum"].(map[string]any) + tupleItems := tupleArray["items"].(map[string]any) + assert.Equal(t, "string", tupleItems["type"]) + assert.Nil(t, tupleItems["properties"]) + assert.Equal(t, []any{"read", "write"}, tupleItems["enum"]) +} + +func TestCleanJSONSchema_ConstKeywordConversion(t *testing.T) { + // 测试 const 关键字自动转换为 Gemini 兼容的 enum: [const] 且具有合法 type + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "const": "ping", + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + props := cleaned["properties"].(map[string]any) + action := props["action"].(map[string]any) + assert.Nil(t, action["const"], "const keyword must be removed") + assert.Equal(t, "string", action["type"]) + assert.Equal(t, []any{"ping"}, action["enum"]) +} + +func TestCleanJSONSchema_AnyOfNestedPrefixItems(t *testing.T) { + // 测试 anyOf 分支合并进来的嵌套 prefixItems 也被完整深度清洗 + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "status": map[string]any{"type": "string"}, + }, + "anyOf": []any{ + map[string]any{ + "properties": map[string]any{ + "extra_tuple": map[string]any{ + "type": "array", + "prefixItems": []any{ + map[string]any{"type": "integer"}, + }, + }, + }, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + props := cleaned["properties"].(map[string]any) + require.NotNil(t, props["extra_tuple"]) + extraTuple := props["extra_tuple"].(map[string]any) + assert.Nil(t, extraTuple["prefixItems"], "nested prefixItems from anyOf merge must be cleaned") + require.NotNil(t, extraTuple["items"]) + assert.Equal(t, "integer", extraTuple["items"].(map[string]any)["type"]) +} + +func TestCleanJSONSchema_EmptyPrefixItems(t *testing.T) { + // 验证空 prefixItems: [] 也被安全删除,不遗留非法关键字 + input := map[string]any{ + "type": "object", + "properties": map[string]any{ + "empty_tuple": map[string]any{ + "type": "array", + "prefixItems": []any{}, + }, + }, + } + + cleaned := CleanJSONSchema(input) + require.NotNil(t, cleaned) + + props := cleaned["properties"].(map[string]any) + emptyTuple := props["empty_tuple"].(map[string]any) + assert.Nil(t, emptyTuple["prefixItems"], "empty prefixItems array must be removed") + require.NotNil(t, emptyTuple["items"]) + assert.Equal(t, "string", emptyTuple["items"].(map[string]any)["type"]) +} + From b18e4ce455282e73e1833a9fe50396a9279d616b Mon Sep 17 00:00:00 2001 From: Brisbanehuang <202969697+Brisbanehuang@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:14:16 -0400 Subject: [PATCH 013/142] =?UTF-8?q?fix(scheduler):=20=E4=B8=BA=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=80=8D=E7=8E=87=E8=A1=A5=E5=85=85=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=80=8D=E7=8E=87=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/admin/setting_handler_audit.go | 2 +- .../admin/setting_handler_oauth_rate_test.go | 76 +++++++++++ .../handler/admin/setting_handler_update.go | 7 +- backend/internal/handler/dto/settings.go | 54 ++++---- .../service/openai_account_scheduler.go | 36 +++-- ...ai_account_scheduler_upstream_cost_test.go | 47 +++---- .../openai_scheduling_rate_fallback_test.go | 125 ++++++++++++++++++ backend/internal/service/setting_parse.go | 16 ++- .../service/setting_service_update_test.go | 12 +- backend/internal/service/setting_update.go | 5 +- backend/internal/service/settings_view.go | 2 +- frontend/src/api/admin/settings.ts | 6 +- .../src/i18n/locales/en/admin/settings.ts | 5 +- .../src/i18n/locales/zh/admin/settings.ts | 5 +- frontend/src/views/admin/SettingsView.vue | 24 +++- .../admin/__tests__/SettingsView.spec.ts | 59 ++++++++- 16 files changed, 387 insertions(+), 94 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_oauth_rate_test.go create mode 100644 backend/internal/service/openai_scheduling_rate_fallback_test.go diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index f888d53adf18..edeeba61d0e6 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -506,7 +506,7 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAILowUpstreamRatePriorityEnabled != after.OpenAILowUpstreamRatePriorityEnabled { changed = append(changed, "openai_low_upstream_rate_priority_enabled") } - if before.OpenAIOAuthSchedulingRateMultiplier != after.OpenAIOAuthSchedulingRateMultiplier { + if !equalNullableFloat(before.OpenAIOAuthSchedulingRateMultiplier, after.OpenAIOAuthSchedulingRateMultiplier) { changed = append(changed, "openai_oauth_scheduling_rate_multiplier") } if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled { diff --git a/backend/internal/handler/admin/setting_handler_oauth_rate_test.go b/backend/internal/handler/admin/setting_handler_oauth_rate_test.go new file mode 100644 index 000000000000..5c643d0f4e3e --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_oauth_rate_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSettingHandler_OAuthSchedulingRateRoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + initial map[string]string + body string + stored string + value any + status int + }{ + {"absent keeps default", nil, `{}`, "1", float64(1), http.StatusOK}, + {"omitted preserves override", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, `{}`, "0.7", 0.7, http.StatusOK}, + {"clear override", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, `{"openai_oauth_scheduling_rate_multiplier":null}`, "", nil, http.StatusOK}, + {"omitted preserves cleared", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: ""}, `{}`, "", nil, http.StatusOK}, + {"set override", nil, `{"openai_oauth_scheduling_rate_multiplier":0.7}`, "0.7", 0.7, http.StatusOK}, + {"zero is explicit", nil, `{"openai_oauth_scheduling_rate_multiplier":0}`, "0", float64(0), http.StatusOK}, + {"negative is rejected", nil, `{"openai_oauth_scheduling_rate_multiplier":-1}`, "", nil, http.StatusBadRequest}, + {"string is rejected", nil, `{"openai_oauth_scheduling_rate_multiplier":"invalid"}`, "", nil, http.StatusBadRequest}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &settingHandlerRepoStub{values: tt.initial} + svc := service.NewSettingService(repo, &config.Config{}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewBufferString(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + require.Equal(t, tt.status, rec.Code, rec.Body.String()) + if tt.status != http.StatusOK { + require.Empty(t, repo.lastUpdates) + return + } + require.Equal(t, tt.stored, repo.values[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + var body struct { + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Contains(t, body.Data, service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, tt.value, body.Data[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + + getRec := httptest.NewRecorder() + getContext, _ := gin.CreateTestContext(getRec) + getContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) + handler.GetSettings(getContext) + require.Equal(t, http.StatusOK, getRec.Code, getRec.Body.String()) + require.NoError(t, json.Unmarshal(getRec.Body.Bytes(), &body)) + require.Contains(t, body.Data, service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, tt.value, body.Data[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + }) + } +} + +func TestSettingsAuditTracksOAuthRateValueNotPointer(t *testing.T) { + beforeRate, afterRate := 0.7, 0.7 + before := &service.SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &beforeRate} + after := &service.SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &afterRate} + require.NotContains(t, diffSettings(before, after, nil, nil, UpdateSettingsRequest{}), service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + after.OpenAIOAuthSchedulingRateMultiplier = nil + require.Contains(t, diffSettings(before, after, nil, nil, UpdateSettingsRequest{}), service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 3f20bbe1f65c..2143cb1f2846 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -1809,9 +1809,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAILowUpstreamRatePriorityEnabled }(), - OpenAIOAuthSchedulingRateMultiplier: func() float64 { - if req.OpenAIOAuthSchedulingRateMultiplier != nil { - return *req.OpenAIOAuthSchedulingRateMultiplier + OpenAIOAuthSchedulingRateMultiplier: func() *float64 { + // Omitted fields preserve the override; explicit null clears it. + if _, sent := sentFields[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]; sent { + return req.OpenAIOAuthSchedulingRateMultiplier } return previousSettings.OpenAIOAuthSchedulingRateMultiplier }(), diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index b0f4ebed70bb..cac990e520a6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -236,33 +236,33 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"` - OpenAIOAuthSchedulingRateMultiplier float64 `json:"openai_oauth_scheduling_rate_multiplier"` - OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` - OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` - OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` - OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"` - OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"` - OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"` - OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"` - OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"` - OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"` - OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"` - OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"` - OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"` - OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"` - OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"` - OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"` - OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"` - OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"` - OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"` - OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"` - OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"` - OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"` - OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"` - OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"` - OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"` - OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"` + OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"` + OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"` + OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` + OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` + OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"` + OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"` + OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"` + OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"` + OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"` + OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"` + OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"` + OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"` + OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"` + OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"` + OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"` + OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"` + OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"` + OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"` + OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"` + OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"` + OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"` + OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"` + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"` + OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"` + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"` + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"` // Payment configuration PaymentEnabled bool `json:"payment_enabled"` diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 5ad63d30f8a0..928e2624d2b8 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -45,7 +45,7 @@ const ( type cachedOpenAIAdvancedSchedulerSetting struct { lowUpstreamRatePriorityEnabled bool - oauthSchedulingRateMultiplier float64 + oauthSchedulingRateMultiplier *float64 enabled bool stickyWeightedEnabled bool subscriptionPriorityEnabled bool @@ -56,7 +56,7 @@ type cachedOpenAIAdvancedSchedulerSetting struct { type openAIAdvancedSchedulerRuntimeSettings struct { lowUpstreamRatePriorityEnabled bool - oauthSchedulingRateMultiplier float64 + oauthSchedulingRateMultiplier *float64 enabled bool stickyWeightedEnabled bool subscriptionPriorityEnabled bool @@ -1904,7 +1904,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex } lowUpstreamRatePriorityEnabled := false - oauthSchedulingRateMultiplier := defaultOpenAIOAuthSchedulingRateMultiplier + oauthSchedulingRateMultiplier := parseOpenAIOAuthSchedulingRateMultiplier(nil) enabled := false stickyWeightedEnabled := false subscriptionPriorityEnabled := false @@ -1916,7 +1916,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex if values, err := repo.GetMultiple(dbCtx, openAIAdvancedSchedulerRuntimeSettingKeys()); err == nil { lowUpstreamRatePriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAILowUpstreamRatePriorityEnabled]), "true") - oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(values[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(values) enabled = strings.EqualFold(strings.TrimSpace(values[openAIAdvancedSchedulerSettingKey]), "true") stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") @@ -1933,7 +1933,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex } } lowUpstreamRatePriorityEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAILowUpstreamRatePriorityEnabled]), "true") - oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(fallbackValues[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(fallbackValues) enabled = strings.EqualFold(strings.TrimSpace(fallbackValues[openAIAdvancedSchedulerSettingKey]), "true") stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") @@ -1976,7 +1976,7 @@ func (s *OpenAIGatewayService) isOpenAILowUpstreamRatePriorityEnabled(ctx contex return !settings.enabled && settings.lowUpstreamRatePriorityEnabled } -func (s *OpenAIGatewayService) openAIOAuthSchedulingRateMultiplier(ctx context.Context) float64 { +func (s *OpenAIGatewayService) openAIOAuthSchedulingRateMultiplier(ctx context.Context) *float64 { return s.openAIAdvancedSchedulerRuntimeSettings(ctx).oauthSchedulingRateMultiplier } @@ -2664,7 +2664,7 @@ func BuildOpenAIAccountSchedulerScoreSnapshot( loadMap map[int64]*AccountLoadInfo, ) map[int64]OpenAIAccountSchedulerScoreSnapshot { gateway := &OpenAIGatewayService{} - return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier) + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false, parseOpenAIOAuthSchedulingRateMultiplier(nil)) } func buildOpenAIAccountSchedulerScoreSnapshot( @@ -2672,7 +2672,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot( loadMap map[int64]*AccountLoadInfo, weights GatewayOpenAIWSSchedulerScoreWeightsView, stickyWeightedEnabled bool, - oauthSchedulingRateMultiplier float64, + oauthSchedulingRateMultiplier *float64, ) map[int64]OpenAIAccountSchedulerScoreSnapshot { if len(accounts) == 0 { return nil @@ -2795,7 +2795,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot( return result } -func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier float64) map[int64]float64 { +func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier *float64) map[int64]float64 { type rateSample struct { accountID int64 rate float64 @@ -2862,7 +2862,7 @@ type openAILegacyUpstreamRateOrder struct { rates map[int64]float64 } -func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier float64) openAILegacyUpstreamRateOrder { +func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier *float64) openAILegacyUpstreamRateOrder { rates := make(map[int64]float64, len(accounts)) var first float64 distinct := false @@ -2890,11 +2890,19 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS return openAILegacyUpstreamRateOrder{enabled: len(rates) >= 2 && distinct, rates: rates} } -func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier float64) (float64, bool) { - if account != nil && account.IsOpenAIOAuthLike() { - return oauthSchedulingRateMultiplier, true +func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier *float64) (float64, bool) { + if account == nil || (!account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike()) { + return 0, false + } + if account.IsOpenAIOAuthLike() { + if rate := oauthSchedulingRateMultiplier; rate != nil && *rate >= 0 && !math.IsNaN(*rate) && !math.IsInf(*rate, 0) { + return *rate, true + } + } else if rate, ok := openAIFreshUpstreamBillingRate(account, now); ok { + return rate, true } - return openAIFreshUpstreamBillingRate(account, now) + rate := account.BillingRateMultiplier() + return rate, !math.IsNaN(rate) && !math.IsInf(rate, 0) } // compare returns -1 when a should be selected before b, 1 when b should be diff --git a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go index 128810757357..8b402eeb25b8 100644 --- a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go +++ b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "math" "strconv" "testing" "time" @@ -402,16 +401,17 @@ func TestOpenAIFreshUpstreamBillingRateRecomputesPeakAtSelectionTime(t *testing. "timezone": "UTC", } - duringPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 17, 59, 0, 0, time.UTC)) + account.RateMultiplier = floatPtr(0.1) + duringPeak, ok := openAISchedulingRate(account, time.Date(2026, 7, 13, 17, 59, 0, 0, time.UTC), nil) require.True(t, ok) require.Equal(t, 0.8, duringPeak) - afterPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 18, 1, 0, 0, time.UTC)) + afterPeak, ok := openAISchedulingRate(account, time.Date(2026, 7, 13, 18, 1, 0, 0, time.UTC), nil) require.True(t, ok) require.Equal(t, 0.4, afterPeak) } -func TestOpenAIUpstreamCostFactorsSparseProbeIsNeutral(t *testing.T) { +func TestOpenAIUpstreamCostFactorsEqualProbeAndFallbackRatesAreNeutral(t *testing.T) { now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) accounts := make([]*Account, 0, 10) accounts = append(accounts, upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1, now.Add(-time.Minute), 30*time.Minute)) @@ -430,13 +430,13 @@ func TestOpenAIUpstreamCostFactorsSparseProbeIsNeutral(t *testing.T) { }) } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) for id := int64(1); id <= 10; id++ { require.Equal(t, openAIUpstreamCostNeutralFactor, factors[id]) } } -func TestOpenAIUpstreamCostFactorsCoverageShrinksSparseSignal(t *testing.T) { +func TestOpenAIUpstreamCostFactorsIncludeAccountFallbacksInCoverage(t *testing.T) { now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) accounts := []*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), @@ -446,10 +446,10 @@ func TestOpenAIUpstreamCostFactorsCoverageShrinksSparseSignal(t *testing.T) { accounts = append(accounts, &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) - center := math.Sqrt(0.03 * 0.8) - require.InDelta(t, 0.5+0.2*(1/(1+0.03/center)-0.5), factors[1], 1e-12) - require.InDelta(t, 0.5+0.2*(1/(1+0.8/center)-0.5), factors[2], 1e-12) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) + // Eight unprobed accounts default to 1x, so the median is 1 and coverage is complete. + require.InDelta(t, 1/(1+0.03), factors[1], 1e-12) + require.InDelta(t, 1/(1+0.8), factors[2], 1e-12) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[3]) } @@ -461,7 +461,7 @@ func TestOpenAIUpstreamCostFactorsUseMedianAgainstOutlier(t *testing.T) { upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 100, now.Add(-time.Minute), 30*time.Minute), } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.InDelta(t, 2.0/3.0, factors[1], 1e-12) require.InDelta(t, 0.5, factors[2], 1e-12) require.InDelta(t, 1/(1+100/0.2), factors[3], 1e-12) @@ -472,20 +472,21 @@ func TestOpenAILegacyUpstreamRateOrderRequiresComparableRates(t *testing.T) { oneKnown := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) - require.False(t, oneKnown.enabled) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) + require.True(t, oneKnown.enabled) + require.Equal(t, 1.0, oneKnown.rates[2]) allEqual := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute), upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute), - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.False(t, allEqual.enabled) distinct := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), {ID: 3, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.True(t, distinct.enabled) require.Negative(t, distinct.compare(&Account{ID: 1}, &Account{ID: 2})) require.Negative(t, distinct.compare(&Account{ID: 2}, &Account{ID: 3})) @@ -506,12 +507,12 @@ func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) { grokCheap := nonOpenAI(1, PlatformGrok, 0.01) anthropicExpensive := nonOpenAI(2, PlatformAnthropic, 0.9) - order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.False(t, order.enabled) require.Empty(t, order.rates) require.Zero(t, order.compare(grokCheap, anthropicExpensive)) - factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[grokCheap.ID]) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[anthropicExpensive.ID]) @@ -520,7 +521,7 @@ func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) { openAIExpensive := upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute) mixed := newOpenAILegacyUpstreamRateOrder( []*Account{grokCheap, openAICheap, anthropicExpensive, openAIExpensive}, - now, defaultOpenAIOAuthSchedulingRateMultiplier, + now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier), ) require.True(t, mixed.enabled) require.Len(t, mixed.rates, 2) @@ -537,12 +538,12 @@ func TestOpenAISchedulingRatePlacesOAuthAtConfiguredReference(t *testing.T) { oauth := upstreamCostTestOAuthAccount(2) expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute) - order := newOpenAILegacyUpstreamRateOrder([]*Account{cheap, oauth, expensive}, now, 0.05) + order := newOpenAILegacyUpstreamRateOrder([]*Account{cheap, oauth, expensive}, now, floatPtr(0.05)) require.True(t, order.enabled) require.Negative(t, order.compare(cheap, oauth)) require.Negative(t, order.compare(oauth, expensive)) - factors := openAIUpstreamCostFactors([]*Account{cheap, oauth, expensive}, now, 0.05) + factors := openAIUpstreamCostFactors([]*Account{cheap, oauth, expensive}, now, floatPtr(0.05)) require.Greater(t, factors[cheap.ID], factors[oauth.ID]) require.Greater(t, factors[oauth.ID], factors[expensive.ID]) } @@ -834,9 +835,9 @@ func TestBuildOpenAIAccountSchedulerScoreSnapshotUpstreamCostIsExactNoOpWithoutS 2: {AccountID: 2, LoadRate: 80}, } weights := GatewayOpenAIWSSchedulerScoreWeightsView{Priority: 1, Load: 1, Queue: 0.7, ErrorRate: 0.8, TTFT: 0.5} - baseline := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + baseline := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) weights.UpstreamCost = 1.5 - withCost := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + withCost := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Equal(t, baseline, withCost) } @@ -848,7 +849,7 @@ func TestBuildOpenAIAccountSchedulerScoreSnapshotUsesUpstreamCostSignal(t *testi upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), } weights := GatewayOpenAIWSSchedulerScoreWeightsView{UpstreamCost: 1.5} - scores := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + scores := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Greater(t, scores[1].BaseScore, scores[2].BaseScore) } diff --git a/backend/internal/service/openai_scheduling_rate_fallback_test.go b/backend/internal/service/openai_scheduling_rate_fallback_test.go new file mode 100644 index 000000000000..86ab5add4549 --- /dev/null +++ b/backend/internal/service/openai_scheduling_rate_fallback_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAISchedulingRateFallback(t *testing.T) { + now := time.Now() + for _, tt := range []struct { + name string + status string + age time.Duration + probe float64 + want float64 + }{ + {"fresh snapshot wins", UpstreamBillingProbeStatusOK, time.Minute, 0.6, 0.6}, + {"fresh snapshot survives failed refresh", UpstreamBillingProbeStatusFailed, time.Minute, 0.6, 0.6}, + {"expired snapshot falls back", UpstreamBillingProbeStatusOK, 3 * time.Hour, 0.6, 0.3}, + {"future snapshot falls back", UpstreamBillingProbeStatusOK, -time.Hour, 0.6, 0.3}, + {"invalid snapshot falls back", UpstreamBillingProbeStatusOK, time.Minute, -1, 0.3}, + {"zero snapshot remains valid", UpstreamBillingProbeStatusOK, time.Minute, 0, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + account := upstreamCostTestAccount(1, tt.status, tt.probe, now.Add(-tt.age), time.Hour) + account.RateMultiplier = floatPtr(0.3) + rate, ok := openAISchedulingRate(account, now, floatPtr(0.9)) + require.True(t, ok) + require.Equal(t, tt.want, rate) + }) + } + + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + t.Run(accountType+" without probe or override", func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: accountType} + for _, tt := range []struct { + configured *float64 + want float64 + known bool + }{ + {floatPtr(0.3), 0.3, true}, + {floatPtr(0), 0, true}, + {nil, 1, true}, + {floatPtr(-1), 1, true}, + {floatPtr(math.NaN()), 0, false}, + {floatPtr(math.Inf(1)), 0, false}, + } { + account.RateMultiplier = tt.configured + rate, ok := openAISchedulingRate(account, now, nil) + require.Equal(t, tt.known, ok) + if ok { + require.Equal(t, tt.want, rate) + } + } + }) + } + + oauth := upstreamCostTestOAuthAccount(1) + oauth.RateMultiplier = floatPtr(0.3) + for _, override := range []float64{0, 0.7} { + rate, ok := openAISchedulingRate(oauth, now, &override) + require.True(t, ok) + require.Equal(t, override, rate) + } + for _, override := range []float64{-1, math.NaN(), math.Inf(1)} { + rate, ok := openAISchedulingRate(oauth, now, &override) + require.True(t, ok) + require.Equal(t, 0.3, rate) + } + _, ok := openAISchedulingRate(nil, now, nil) + require.False(t, ok) +} + +func TestOpenAISchedulingRateFallbackSharedByBothModes(t *testing.T) { + now := time.Now() + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + t.Run(accountType, func(t *testing.T) { + cheap := &Account{ID: 1, Platform: PlatformOpenAI, Type: accountType, RateMultiplier: floatPtr(0.3)} + expensive := &Account{ID: 2, Platform: PlatformOpenAI, Type: accountType, RateMultiplier: floatPtr(0.8)} + accounts := []*Account{cheap, expensive} + order := newOpenAILegacyUpstreamRateOrder(accounts, now, nil) + require.True(t, order.enabled) + require.Negative(t, order.compare(cheap, expensive)) + factors := openAIUpstreamCostFactors(accounts, now, nil) + require.Greater(t, factors[cheap.ID], factors[expensive.ID]) + if accountType == AccountTypeOAuth { + order = newOpenAILegacyUpstreamRateOrder(accounts, now, floatPtr(0.7)) + require.False(t, order.enabled) + factors = openAIUpstreamCostFactors(accounts, now, floatPtr(0.7)) + require.Equal(t, factors[cheap.ID], factors[expensive.ID]) + } + }) + } +} + +func TestOpenAIOAuthSchedulingRateRuntimePreservesExplicitClear(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + for _, tt := range []struct { + name string + values map[string]string + want *float64 + }{ + {"absent", map[string]string{}, floatPtr(1)}, + {"cleared", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: ""}, nil}, + {"zero", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0"}, floatPtr(0)}, + {"override", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, floatPtr(0.7)}, + } { + t.Run(tt.name, func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := &OpenAIGatewayService{ + rateLimitService: &RateLimitService{settingService: &SettingService{ + settingRepo: &openAIAdvancedSchedulerSettingRepoStub{values: tt.values}, + }}, + } + for i := 0; i < 2; i++ { + require.Equal(t, tt.want, svc.openAIOAuthSchedulingRateMultiplier(context.Background())) + } + }) + } +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index dc9366ba9e6f..d6497775cfdf 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -915,7 +915,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" result.OpenAILowUpstreamRatePriorityEnabled = settings[SettingKeyOpenAILowUpstreamRatePriorityEnabled] == "true" - result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings) result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" @@ -1080,7 +1080,7 @@ func formatOpenAIAdvancedSchedulerFloat(value float64) string { } func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { - if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate < 0 || math.IsNaN(rate) || math.IsInf(rate, 0) { + if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate != nil && (*rate < 0 || math.IsNaN(*rate) || math.IsInf(*rate, 0)) { return infraerrors.BadRequest("INVALID_OPENAI_OAUTH_SCHEDULING_RATE_MULTIPLIER", "OpenAI OAuth scheduling rate multiplier must be a finite non-negative number") } @@ -1132,12 +1132,18 @@ func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *Sys return nil } -func parseOpenAIOAuthSchedulingRateMultiplier(raw string) float64 { +func parseOpenAIOAuthSchedulingRateMultiplier(settings map[string]string) *float64 { + raw, exists := settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier] + if !exists { + // Preserve the legacy default until an administrator explicitly clears it. + value := defaultOpenAIOAuthSchedulingRateMultiplier + return &value + } value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return defaultOpenAIOAuthSchedulingRateMultiplier + return nil } - return value + return &value } // resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 750f01728b3c..40619e29e422 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -410,7 +410,7 @@ func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler PaymentVisibleMethodAlipayEnabled: true, PaymentVisibleMethodWxpayEnabled: false, OpenAILowUpstreamRatePriorityEnabled: true, - OpenAIOAuthSchedulingRateMultiplier: 0.05, + OpenAIOAuthSchedulingRateMultiplier: testPtrFloat64(0.05), OpenAIAdvancedSchedulerEnabled: true, OpenAIAdvancedSchedulerStickyWeightedEnabled: true, OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: true, @@ -454,7 +454,7 @@ func TestSettingService_UpdateSettingsRejectsInvalidOpenAIOAuthSchedulingRateMul svc := NewSettingService(repo, &config.Config{}) for _, rate := range []float64{-0.01, math.NaN(), math.Inf(1)} { - err := svc.UpdateSettings(context.Background(), &SystemSettings{OpenAIOAuthSchedulingRateMultiplier: rate}) + err := svc.UpdateSettings(context.Background(), &SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &rate}) require.Error(t, err) } } @@ -515,8 +515,12 @@ func TestSettingService_UpdateSettings_OpenAIAdvancedSchedulerWeightSums(t *test func TestSettingService_ParseSettingsDefaultsOpenAIOAuthSchedulingRateMultiplier(t *testing.T) { svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{}) - require.Equal(t, 1.0, svc.parseSettings(map[string]string{}).OpenAIOAuthSchedulingRateMultiplier) - require.Equal(t, 0.05, svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05"}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(1.0), svc.parseSettings(map[string]string{}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(0.05), svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05"}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(0), svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0"}).OpenAIOAuthSchedulingRateMultiplier) + for _, raw := range []string{"", " ", "invalid", "-1", "NaN", "+Inf"} { + require.Nil(t, svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: raw}).OpenAIOAuthSchedulingRateMultiplier, raw) + } } func TestSettingService_GetAllSettings_OpenAIAdvancedSchedulerEffectiveValuesUseConfig(t *testing.T) { diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 77bac7b21167..873d40849afc 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -499,7 +499,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) updates[SettingKeyOpenAILowUpstreamRatePriorityEnabled] = strconv.FormatBool(settings.OpenAILowUpstreamRatePriorityEnabled) - updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = strconv.FormatFloat(settings.OpenAIOAuthSchedulingRateMultiplier, 'f', -1, 64) + updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = "" + if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate != nil { + updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = strconv.FormatFloat(*rate, 'f', -1, 64) + } updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 83c142256871..a305d390cf29 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -268,7 +268,7 @@ type SystemSettings struct { // OpenAI 账号调度 OpenAILowUpstreamRatePriorityEnabled bool - OpenAIOAuthSchedulingRateMultiplier float64 + OpenAIOAuthSchedulingRateMultiplier *float64 OpenAIAdvancedSchedulerEnabled bool OpenAIAdvancedSchedulerStickyWeightedEnabled bool OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4c8c06a7a3ea..4f0e66b086ad 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -683,7 +683,8 @@ export interface SystemSettings { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_low_upstream_rate_priority_enabled?: boolean; - openai_oauth_scheduling_rate_multiplier?: number; + /** null means OAuth accounts use their individual account rates. */ + openai_oauth_scheduling_rate_multiplier?: number | null; openai_advanced_scheduler_enabled?: boolean; openai_advanced_scheduler_sticky_weighted_enabled?: boolean; openai_advanced_scheduler_subscription_priority_enabled?: boolean; @@ -1000,7 +1001,8 @@ export interface UpdateSettingsRequest { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_low_upstream_rate_priority_enabled?: boolean; - openai_oauth_scheduling_rate_multiplier?: number; + /** Omit to preserve the override; null clears it; zero is an explicit rate. */ + openai_oauth_scheduling_rate_multiplier?: number | null; openai_advanced_scheduler_enabled?: boolean; openai_advanced_scheduler_sticky_weighted_enabled?: boolean; openai_advanced_scheduler_subscription_priority_enabled?: boolean; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 99e82fb12b5f..7037d2e368b8 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -1219,8 +1219,9 @@ export default { lowRatePriorityTitle: 'Prefer lower rates', lowRatePriorityDescription: 'When enabled, accounts with lower billing rates are preferred. If rates are equal, account priority, current load, and other scheduling factors are considered. This switch is ignored when the experimental scheduler is enabled.', oauthRateTitle: 'OAuth scheduling reference rate', - oauthRatePriorityDescription: 'When a group contains both API Key and OAuth accounts, this rate is used to order OAuth accounts alongside probed API Key billing rates.', - oauthRateWeightedDescription: 'When a group contains both API Key and OAuth accounts, this rate is used for OAuth accounts when calculating the billing-rate score.', + oauthRatePriorityDescription: 'OAuth accounts use this reference rate for low-rate-first ordering. Leave blank to use each account\'s own rate. API Key accounts use a valid probed rate when available, otherwise their account rate.', + oauthRateWeightedDescription: 'OAuth accounts use this reference rate for the billing-rate score. Leave blank to use each account\'s own rate. API Key accounts use a valid probed rate when available, otherwise their account rate.', + oauthRateInvalid: 'The OAuth scheduling reference rate must be a non-negative number, or blank to use account rates.', stickyWeightedTitle: 'Sticky weighting', stickyWeightedDescription: 'When enabled, previous_response_id and session_hash affinity are scored by the advanced scheduler. When disabled, sticky accounts keep the legacy hard-hit behavior.', subscriptionPriorityTitle: 'Subscription priority', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index df178cb67d2d..ec7614a10257 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -1213,8 +1213,9 @@ export default { lowRatePriorityTitle: '低倍率优先', lowRatePriorityDescription: '开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。', oauthRateTitle: 'OAuth 调度参考倍率', - oauthRatePriorityDescription: '同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。', - oauthRateWeightedDescription: '同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。', + oauthRatePriorityDescription: 'OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。', + oauthRateWeightedDescription: '计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。', + oauthRateInvalid: 'OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。', stickyWeightedTitle: '粘性加权', stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。', subscriptionPriorityTitle: '订阅优先', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c25b06eb3bd5..15ed5582b90c 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5053,7 +5053,6 @@ class="input pr-8" data-testid="openai-oauth-scheduling-rate-multiplier" min="0" - required step="0.01" type="number" /> @@ -5142,7 +5141,6 @@ class="input pr-8" data-testid="openai-oauth-scheduling-rate-multiplier" min="0" - required step="0.01" type="number" /> @@ -9541,6 +9539,7 @@ type SettingsForm = Omit< | "wechat_connect_open_enabled" | "wechat_connect_mp_enabled" | "wechat_connect_mobile_enabled" + | "openai_oauth_scheduling_rate_multiplier" > & { /** Form always binds a concrete boolean (SystemSettings marks this optional). */ channel_monitor_hide_throughput: boolean; @@ -9566,7 +9565,7 @@ type SettingsForm = Omit< google_oauth_client_secret: string; force_email_on_third_party_signup: boolean; openai_low_upstream_rate_priority_enabled: boolean; - openai_oauth_scheduling_rate_multiplier: number; + openai_oauth_scheduling_rate_multiplier: number | "" | null; openai_advanced_scheduler_enabled: boolean; openai_advanced_scheduler_sticky_weighted_enabled: boolean; openai_advanced_scheduler_subscription_priority_enabled: boolean; @@ -10841,6 +10840,10 @@ async function loadSettings() { (form as Record)[key] = value; } } + // For this optional override, null explicitly selects per-account rates. + if (settings.openai_oauth_scheduling_rate_multiplier === null) { + form.openai_oauth_scheduling_rate_multiplier = null; + } syncCaptchaProviderSelection(); if (!form.claude_oauth_system_prompt_blocks?.trim()) { form.claude_oauth_system_prompt_blocks = @@ -11240,6 +11243,16 @@ async function saveSettings() { form.claude_oauth_system_prompt_blocks = claudeOAuthSystemPromptBlocksJSON; + const oauthSchedulingRate = form.openai_oauth_scheduling_rate_multiplier; + if ( + oauthSchedulingRate !== "" && + oauthSchedulingRate !== null && + (!Number.isFinite(oauthSchedulingRate) || oauthSchedulingRate < 0) + ) { + appStore.showError(t("admin.settings.openaiExperimentalScheduler.oauthRateInvalid")); + return; + } + const payload: UpdateSettingsRequest = { registration_enabled: form.registration_enabled, email_verify_enabled: form.email_verify_enabled, @@ -11498,7 +11511,7 @@ async function saveSettings() { openai_low_upstream_rate_priority_enabled: form.openai_low_upstream_rate_priority_enabled, openai_oauth_scheduling_rate_multiplier: - form.openai_oauth_scheduling_rate_multiplier, + oauthSchedulingRate === "" ? null : oauthSchedulingRate, openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled, openai_advanced_scheduler_sticky_weighted_enabled: form.openai_advanced_scheduler_sticky_weighted_enabled, @@ -11607,6 +11620,9 @@ async function saveSettings() { (form as Record)[key] = value; } } + if (updated.openai_oauth_scheduling_rate_multiplier === null) { + form.openai_oauth_scheduling_rate_multiplier = null; + } Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated)); form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas); form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap( diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8491ff9e3078..8103259845ac 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -201,8 +201,9 @@ vi.mock("vue-i18n", async () => { "admin.settings.openaiExperimentalScheduler.lowRatePriorityTitle": "低倍率优先", "admin.settings.openaiExperimentalScheduler.lowRatePriorityDescription": "开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。", "admin.settings.openaiExperimentalScheduler.oauthRateTitle": "OAuth 调度参考倍率", - "admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", - "admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", + "admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。", + "admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。", + "admin.settings.openaiExperimentalScheduler.oauthRateInvalid": "OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。", "admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权", "admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。", "admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先", @@ -1437,6 +1438,54 @@ describe("admin SettingsView payment visible method controls", () => { }); }); + it.each([false, true])("clears the OAuth rate without losing zero (weighted=%s)", async (weighted) => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + openai_low_upstream_rate_priority_enabled: !weighted, + openai_advanced_scheduler_enabled: weighted, + openai_oauth_scheduling_rate_multiplier: 0.7, + }); + const wrapper = mountView(); + await flushPromises(); + const input = wrapper.get('[data-testid="openai-oauth-scheduling-rate-multiplier"]'); + expect(input.attributes("required")).toBeUndefined(); + await input.setValue(""); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenLastCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: null, + })); + await input.setValue("0"); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenLastCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: 0, + })); + updateSettings.mockClear(); + await input.setValue("-1"); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith("OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。"); + }); + + it("loads and preserves an explicitly cleared OAuth rate", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + openai_advanced_scheduler_enabled: true, + openai_oauth_scheduling_rate_multiplier: null, + }); + const wrapper = mountView(); + await flushPromises(); + const input = wrapper.get('[data-testid="openai-oauth-scheduling-rate-multiplier"]'); + expect(input.element.value).toBe(""); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: null, + })); + }); + it("places and explains rate controls for both scheduling modes", async () => { const wrapper = mountView(); @@ -1449,7 +1498,7 @@ describe("admin SettingsView payment visible method controls", () => { await lowRateToggle.setValue(true); const priorityModeText = wrapper.text(); expect(priorityModeText).toContain( - "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", + "OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。", ); expect(priorityModeText.indexOf("低倍率优先")).toBeLessThan( priorityModeText.indexOf("OAuth 调度参考倍率"), @@ -1483,10 +1532,10 @@ describe("admin SettingsView payment visible method controls", () => { ).toBe(true); const weightedModeText = wrapper.text(); expect(weightedModeText).toContain( - "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", + "计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。", ); expect(weightedModeText).not.toContain( - "OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", + "OAuth 账号按此参考倍率参与低倍率优先排序;", ); expect(weightedModeText.indexOf("订阅优先")).toBeLessThan( weightedModeText.indexOf("OAuth 调度参考倍率"), From 7511e4de59aee7e2bd6f912b138c263de01e05e2 Mon Sep 17 00:00:00 2001 From: MHJoy99 <75517252+MHJoy99@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:09:28 +0600 Subject: [PATCH 014/142] fix(streaming): drop Anthropic ping keepalives before OpenAI conversion Upstream Anthropic keepalive frames (event: ping) leaked into OpenAI Chat Completions SSE streams, crashing strict clients (Cursor, Hermes, OpenAI SDKs) on unexpected event types. Error events still forward. Fixes #5203 --- .../service/gateway_forward_as_chat_completions.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40da8..0f6611c1422d 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -418,6 +418,15 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( } processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) bool { + if event == nil { + return false + } + // Drop Anthropic keepalive pings before OpenAI conversion: + // leaking `event: ping` frames crashes OpenAI-stream clients. + // Error events must still forward — they carry upstream failures. + if event.Type == "ping" { + return false + } if firstChunk { firstChunk = false ms := int(time.Since(startTime).Milliseconds()) From a985de062c9860dae83cc865c3c3836f59a95333 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 13 Sep 2026 17:16:26 +0800 Subject: [PATCH 015/142] fix(ui): focus non-searchable select dropdowns --- frontend/src/components/common/Select.vue | 3 + .../common/__tests__/Select.keyboard.spec.ts | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 frontend/src/components/common/__tests__/Select.keyboard.spec.ts diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index a534f5c246a6..6f68a5342974 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -55,6 +55,7 @@ :class="[instanceId]" :style="dropdownStyle" role="listbox" + tabindex="-1" @click.stop @mousedown.stop @keydown="onDropdownKeyDown" @@ -384,6 +385,8 @@ watch(isOpen, (open) => { if (isSearchable.value) { nextTick(() => searchInputRef.value?.focus()) + } else { + nextTick(() => dropdownRef.value?.focus()) } // Add scroll listener to update position window.addEventListener('scroll', updateTriggerRect, { capture: true, passive: true }) diff --git a/frontend/src/components/common/__tests__/Select.keyboard.spec.ts b/frontend/src/components/common/__tests__/Select.keyboard.spec.ts new file mode 100644 index 000000000000..e4af8a4593d4 --- /dev/null +++ b/frontend/src/components/common/__tests__/Select.keyboard.spec.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import Select from '../Select.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +let wrapper: ReturnType +afterEach(() => { wrapper?.unmount(); document.body.innerHTML = '' }) + +async function press(key: string) { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + await nextTick() +} + +async function open(searchable: boolean | 'auto' = 'auto') { + wrapper = mount(Select, { + attachTo: document.body, + props: { modelValue: 'alpha', searchable, options: [ + { value: 'alpha', label: 'Alpha' }, + { value: 'disabled', label: 'Disabled', disabled: true }, + { value: 'beta', label: 'Beta' }, + ] }, + }) + wrapper.get('button').element.focus() + await press('ArrowDown') + await nextTick() +} + +describe('Select keyboard focus', () => { + it.each([false, 'auto'] as const)('selects an enabled option without a search input (%s)', async (searchable) => { + await open(searchable) + expect(document.activeElement).toBe(document.querySelector('[role="listbox"]')) + await press('ArrowDown') + await press('Enter') + expect(wrapper.emitted('update:modelValue')).toEqual([['beta']]) + expect(wrapper.get('button').attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(wrapper.get('button').element) + }) + + it('allows Escape to close a non-searchable dropdown and restore trigger focus', async () => { + await open(false) + await press('Escape') + expect(wrapper.get('button').attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(wrapper.get('button').element) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('keeps focus in the search input when search is enabled', async () => { + await open(true) + expect(document.activeElement).toBe(document.querySelector('.select-search-input')) + await press('ArrowDown') + await press('Enter') + expect(wrapper.emitted('update:modelValue')).toEqual([['beta']]) + }) +}) From 7dace2fecb7b6f47ca13dbd1eeb5926279963d77 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 13 Sep 2026 17:18:15 +0800 Subject: [PATCH 016/142] fix(ui): discard unapplied date ranges on dismissal --- .../src/components/common/DateRangePicker.vue | 8 ++++ .../__tests__/DateRangePicker.dismiss.spec.ts | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 frontend/src/components/common/__tests__/DateRangePicker.dismiss.spec.ts diff --git a/frontend/src/components/common/DateRangePicker.vue b/frontend/src/components/common/DateRangePicker.vue index 56861979bfc4..b116dff2e380 100644 --- a/frontend/src/components/common/DateRangePicker.vue +++ b/frontend/src/components/common/DateRangePicker.vue @@ -290,6 +290,14 @@ const handleEscape = (event: KeyboardEvent) => { } } +// Restore the applied range after dismissal, including parent updates from Apply. +watch(isOpen, (open) => { + if (open) return + localStartDate.value = props.startDate + localEndDate.value = props.endDate + onDateChange() +}, { flush: 'post' }) + // Sync local state with props watch( () => props.startDate, diff --git a/frontend/src/components/common/__tests__/DateRangePicker.dismiss.spec.ts b/frontend/src/components/common/__tests__/DateRangePicker.dismiss.spec.ts new file mode 100644 index 000000000000..222cd54b0839 --- /dev/null +++ b/frontend/src/components/common/__tests__/DateRangePicker.dismiss.spec.ts @@ -0,0 +1,47 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' +import DateRangePicker from '../DateRangePicker.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref('en') }) })) +let wrapper: ReturnType> +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 13, 12)) }) +afterEach(() => { wrapper?.unmount(); vi.useRealTimers() }) + +async function chooseDraft() { + wrapper = mount(DateRangePicker, { + props: { + startDate: '2026-09-13', endDate: '2026-09-13', + 'onUpdate:startDate': (startDate: string) => { void wrapper.setProps({ startDate }) }, + 'onUpdate:endDate': (endDate: string) => { void wrapper.setProps({ endDate }) }, + }, + }) + await wrapper.get('.date-picker-trigger').trigger('click') + await wrapper.findAll('.date-picker-preset').find(node => node.text() === 'dates.last7Days')!.trigger('click') +} + +describe('DateRangePicker unapplied changes', () => { + it.each(['outside', 'escape', 'toggle'])('discards draft dates when dismissed with %s', async (method) => { + await chooseDraft() + if (method === 'outside') document.body.click() + else if (method === 'escape') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + else await wrapper.get('.date-picker-trigger').trigger('click') + await nextTick() + expect(wrapper.emitted('change')).toBeUndefined() + expect(wrapper.get('.date-picker-trigger').text()).toContain('dates.today') + await wrapper.get('.date-picker-trigger').trigger('click') + expect(wrapper.findAll('input[type="date"]').map(input => input.element.value)) + .toEqual(['2026-09-13', '2026-09-13']) + }) + + it('retains a newly applied range after the parent accepts both updates', async () => { + await chooseDraft() + await wrapper.get('.date-picker-apply').trigger('click') + await nextTick() + expect(wrapper.emitted('change')).toEqual([[{ startDate: '2026-09-07', endDate: '2026-09-13', preset: '7days' }]]) + expect(wrapper.get('.date-picker-trigger').text()).toContain('dates.last7Days') + await wrapper.get('.date-picker-trigger').trigger('click') + expect(wrapper.findAll('input[type="date"]').map(input => input.element.value)) + .toEqual(['2026-09-07', '2026-09-13']) + }) +}) From 29bce46a2a6f86472214427869ad98bbc7c4bf83 Mon Sep 17 00:00:00 2001 From: HPnetwork Date: Mon, 14 Sep 2026 05:56:59 -0400 Subject: [PATCH 017/142] fix: keep API key accounts available for unknown models --- .../service/model_not_found_error_test.go | 1 + .../service/openai_gateway_upstream_errors.go | 5 ++++ backend/internal/service/ratelimit_service.go | 3 +++ .../service/ratelimit_service_401_test.go | 23 +++++++++++++++++++ .../ratelimit_service_model_not_found_test.go | 23 +++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/backend/internal/service/model_not_found_error_test.go b/backend/internal/service/model_not_found_error_test.go index d76d71d0f1d6..b83204dc95bd 100644 --- a/backend/internal/service/model_not_found_error_test.go +++ b/backend/internal/service/model_not_found_error_test.go @@ -122,6 +122,7 @@ func TestIsOpenAICompatibleModelNotFound400(t *testing.T) { {name: "structured code", body: `{"error":{"code":"model_not_found","message":"No such deployment"}}`, want: true}, {name: "unknown provider", body: `{"error":{"message":"Unknown provider for model claude-x"}}`, want: true}, {name: "model not found", body: `{"error":{"message":"Model not found: claude-x"}}`, want: true}, + {name: "unknown model", body: `{"error":{"message":"unknown model: claude-x"}}`, want: true}, {name: "model unsupported", body: `{"error":{"message":"The requested model is not supported"}}`, want: true}, {name: "plain text compatible gateway", body: `unknown provider for model claude-x`, want: true}, {name: "invalid parameter remains terminal", body: `{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`, want: false}, diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index b5985a226ed4..24900432843f 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -286,6 +286,10 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(account *Acc } func isOpenAICompatibleModelNotFound400(respBody []byte) bool { + return isOpenAICompatibleModelNotFoundBody(respBody) +} + +func isOpenAICompatibleModelNotFoundBody(respBody []byte) bool { code := strings.TrimSpace(extractUpstreamErrorCode(respBody)) if code != "" { return strings.EqualFold(code, "model_not_found") @@ -296,6 +300,7 @@ func isOpenAICompatibleModelNotFound400(respBody []byte) bool { msg = strings.ToLower(strings.TrimSpace(string(respBody))) } return strings.Contains(msg, "unknown provider for model") || + strings.Contains(msg, "unknown model") || strings.Contains(msg, "model not found") || strings.Contains(msg, "model is not supported") } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4158d0559cfe..92186dd6ab39 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -2430,6 +2430,7 @@ func parseOpenAIImageTryAgainCooldown(body []byte) time.Duration { const upstreamModelNotFoundCooldown = 30 * time.Minute const upstreamModelNotFoundReason = "upstream_404_model_not_found" +const upstreamModelNotFound401Reason = "upstream_401_model_not_found" const upstreamCodexPlanGatedModelCooldown = 30 * time.Minute const upstreamCodexPlanGatedModelReason = "upstream_400_codex_plan_gated_model" const tempUnschedBodyMaxBytes = 64 << 10 @@ -2455,6 +2456,8 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco switch { case isUpstreamModelNotFoundError(statusCode, responseBody): cooldown, reason = upstreamModelNotFoundCooldown, upstreamModelNotFoundReason + case statusCode == http.StatusUnauthorized && account.Type == AccountTypeAPIKey && account.IsOpenAICompatible() && isOpenAICompatibleModelNotFoundBody(responseBody): + cooldown, reason = upstreamModelNotFoundCooldown, upstreamModelNotFound401Reason case isOpenAIOAuthAccount(account) && isOpenAICodexPlanGatedModelError(statusCode, responseBody): cooldown, reason = upstreamCodexPlanGatedModelCooldown, upstreamCodexPlanGatedModelReason default: diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go index c1f9f069544e..8804cab702e6 100644 --- a/backend/internal/service/ratelimit_service_401_test.go +++ b/backend/internal/service/ratelimit_service_401_test.go @@ -236,6 +236,29 @@ func TestRateLimitService_HandleUpstreamError_NonOAuth401(t *testing.T) { require.Empty(t, invalidator.accounts) } +func TestRateLimitService_HandleUpstreamError_NonOAuthModel401StillDisablesCredentials(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 104, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusUnauthorized, + http.Header{}, + []byte(`{"error":{"message":"invalid or expired credentials"}}`), + "definitely-not-real", + ) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) +} + // TestRateLimitService_HandleUpstreamError_OAuth401DoesNotOverwriteCredentials // 回归测试:确保 401 handler 不再使用请求开始时的 account 快照写回 credentials。 // 原实现会通过 persistAccountCredentials → UpdateCredentials → SetCredentials diff --git a/backend/internal/service/ratelimit_service_model_not_found_test.go b/backend/internal/service/ratelimit_service_model_not_found_test.go index 5e2fa0f71d97..9e2baa3b09dc 100644 --- a/backend/internal/service/ratelimit_service_model_not_found_test.go +++ b/backend/internal/service/ratelimit_service_model_not_found_test.go @@ -69,6 +69,29 @@ func TestRateLimitService_HandleUpstreamError_ModelNotFoundUsesModelRateLimit(t require.WithinDuration(t, time.Now().Add(upstreamModelNotFoundCooldown), call.resetAt, 5*time.Second) } +func TestRateLimitService_HandleUpstreamError_APIKeyModel401UsesModelRateLimit(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAIModelNotFoundTempAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusUnauthorized, + http.Header{}, + []byte(`{"error":{"message":"unknown model definitely-not-real"}}`), + "definitely-not-real", + ) + + require.True(t, handled) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + call := repo.modelRateLimitCalls[0] + require.Equal(t, account.ID, call.accountID) + require.Equal(t, "definitely-not-real", call.scope) + require.Equal(t, upstreamModelNotFound401Reason, call.reason) +} + func TestRateLimitService_HandleUpstreamError_ModelNotFoundWriteFailureDoesNotTempUnschedule(t *testing.T) { repo := &modelNotFoundAccountRepoStub{modelRateLimitErr: errors.New("write failed")} svc := &RateLimitService{accountRepo: repo} From 0e1329b52088b809b351509e482ad1cc69b3b9bb Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Mon, 14 Sep 2026 21:28:59 +0800 Subject: [PATCH 018/142] fix(ui): reset select highlight when search results change --- frontend/src/components/common/Select.vue | 6 ++ .../__tests__/Select.searchHighlight.spec.ts | 85 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 frontend/src/components/common/__tests__/Select.searchHighlight.spec.ts diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index a534f5c246a6..e409b15e8d4e 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -333,6 +333,12 @@ const findPrevEnabledIndex = (startIndex: number): number => { return -1 } +watch(filteredOptions, () => { + if (!isOpen.value) return + focusedIndex.value = findNextEnabledIndex(0) + if (focusedIndex.value >= 0) scrollToFocused() +}) + const handleOptionMouseEnter = (option: any, index: number) => { if (isOptionDisabled(option) || isGroupHeaderOption(option)) return focusedIndex.value = index diff --git a/frontend/src/components/common/__tests__/Select.searchHighlight.spec.ts b/frontend/src/components/common/__tests__/Select.searchHighlight.spec.ts new file mode 100644 index 000000000000..5e2b65e3a3df --- /dev/null +++ b/frontend/src/components/common/__tests__/Select.searchHighlight.spec.ts @@ -0,0 +1,85 @@ +import { DOMWrapper, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import Select from '../Select.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +let wrapper: ReturnType +afterEach(() => { wrapper?.unmount(); document.body.innerHTML = '' }) + +async function open(props: Record = {}) { + wrapper = mount(Select, { + attachTo: document.body, + props: { + modelValue: 'gamma', searchable: true, + options: [ + { value: 'alpha', label: 'Alpha' }, + { value: 'beta', label: 'Beta' }, + { value: 'gamma', label: 'Gamma' }, + ], + ...props, + }, + }) + await wrapper.get('button').trigger('click') + await nextTick() +} + +async function search(query: string) { + const input = document.querySelector('.select-search-input')! + await new DOMWrapper(input).setValue(query) +} + +async function enter() { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await nextTick() +} + +describe('Select highlight after result changes', () => { + it('selects a filtered result when the previous highlight index is no longer valid', async () => { + await open() + await search('alpha') + await enter() + expect(wrapper.emitted('update:modelValue')).toEqual([['alpha']]) + }) + + it('restores keyboard selection after a search with no matches', async () => { + await open() + await search('no matches') + await enter() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await search('beta') + await enter() + expect(wrapper.emitted('update:modelValue')).toEqual([['beta']]) + }) + + it('highlights the first enabled result when remote options arrive', async () => { + await open({ modelValue: null, remote: true, options: [] }) + await wrapper.setProps({ options: [ + { value: 'group', label: 'Group', kind: 'group', disabled: true }, + { value: 'result', label: 'Result' }, + ] }) + await enter() + expect(wrapper.emitted('update:modelValue')).toEqual([['result']]) + }) + + it('does not retain an old index that now points to a disabled option', async () => { + await open({ remote: true }) + await wrapper.setProps({ options: [ + { value: 'result', label: 'Result' }, + { value: 'other', label: 'Other' }, + { value: 'disabled', label: 'Disabled', disabled: true }, + ] }) + await enter() + expect(wrapper.emitted('update:modelValue')).toEqual([['result']]) + }) + + it('preserves the selected highlight when reopening after clearing a search', async () => { + await open() + await search('alpha') + await wrapper.get('button').trigger('click') + await wrapper.get('button').trigger('click') + await nextTick() + await enter() + expect(wrapper.emitted('update:modelValue')).toEqual([['gamma']]) + }) +}) From 6582260bc860f6f8e03f20baea687e318941d582 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Tue, 15 Sep 2026 11:30:25 +0800 Subject: [PATCH 019/142] feat: add configurable rolling log retention --- backend/cmd/server/wire_gen.go | 2 +- .../dashboard_aggregation_group_usage_test.go | 14 ++- .../repository/dashboard_aggregation_repo.go | 13 ++- .../request_log_retention_integration_test.go | 46 ++++++++ .../service/dashboard_aggregation_service.go | 78 ++++++++++++- .../dashboard_aggregation_service_test.go | 38 +++--- backend/internal/service/ops_log_runtime.go | 34 ++++-- .../internal/service/ops_settings_models.go | 13 ++- .../service/request_log_retention_test.go | 109 ++++++++++++++++++ backend/internal/service/wire.go | 3 +- docs/log-retention.md | 21 ++++ frontend/src/api/admin/ops.ts | 1 + frontend/src/i18n/locales/en/admin/ops.ts | 11 +- frontend/src/i18n/locales/zh/admin/ops.ts | 11 +- .../ops/components/LogRetentionSelect.vue | 43 +++++++ .../ops/components/OpsSystemLogTable.vue | 26 ++++- .../__tests__/OpsSystemLogTable.spec.ts | 65 ++++++++++- 17 files changed, 479 insertions(+), 49 deletions(-) create mode 100644 backend/internal/repository/request_log_retention_integration_test.go create mode 100644 backend/internal/service/request_log_retention_test.go create mode 100644 docs/log-retention.md create mode 100644 frontend/src/views/admin/ops/components/LogRetentionSelect.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index e2adb3a17a94..c719ee80e3f7 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -185,7 +185,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) - dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig) + dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig, settingRepository) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) adminGroupRepository := repository.NewAdminGroupRepository(client, db) adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) diff --git a/backend/internal/repository/dashboard_aggregation_group_usage_test.go b/backend/internal/repository/dashboard_aggregation_group_usage_test.go index cbb5d3c6b626..40bca32bc4c2 100644 --- a/backend/internal/repository/dashboard_aggregation_group_usage_test.go +++ b/backend/internal/repository/dashboard_aggregation_group_usage_test.go @@ -249,6 +249,18 @@ func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionedSortsAndInvali WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() } + // The boundary partition is pruned by exact timestamp. tableoid is required + // because ctid alone can also identify a retained row in another partition. + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`(?s)SELECT tableoid, ctid.*WHERE created_at < \$1.*WHERE \(tableoid, ctid\) IN.*RETURNING created_at`). + WithArgs(cutoff, usageLogsCleanupBatchSize). + WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(cutoff.Add(-time.Hour))) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(cutoff.Add(-time.Hour), "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() mock.ExpectBegin() mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). @@ -271,7 +283,7 @@ func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedFailureRoll mock.ExpectBegin() mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) - mock.ExpectQuery(`(?s)SELECT ctid.*ORDER BY created_at ASC, id ASC.*DELETE FROM usage_logs.*RETURNING created_at`). + mock.ExpectQuery(`(?s)SELECT tableoid, ctid.*ORDER BY created_at ASC, id ASC.*DELETE FROM usage_logs.*RETURNING created_at`). WithArgs(cutoff, usageLogsCleanupBatchSize). WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(deletedAt)) mock.ExpectExec(`UPDATE usage_group_rollup_state`). diff --git a/backend/internal/repository/dashboard_aggregation_repo.go b/backend/internal/repository/dashboard_aggregation_repo.go index 6701a3c99922..347600df05fc 100644 --- a/backend/internal/repository/dashboard_aggregation_repo.go +++ b/backend/internal/repository/dashboard_aggregation_repo.go @@ -239,7 +239,10 @@ func (r *dashboardAggregationRepository) CleanupUsageLogs(ctx context.Context, c if err := r.dropUsageLogsPartitions(ctx, cutoff); err != nil { return err } - } else if err := r.cleanupUsageLogsBatches(ctx, cutoff); err != nil { + } + // Remove the expired part of the boundary partition as well, so a retention + // window measured in days does not silently round up to a whole month. + if err := r.cleanupUsageLogsBatches(ctx, cutoff); err != nil { return err } return r.SyncGroupUsageRollups(ctx, service.GroupUsageTodayStart(r.now())) @@ -261,14 +264,14 @@ func (r *dashboardAggregationRepository) cleanupUsageLogsBatches(ctx context.Con res, err := r.sql.ExecContext(ctx, ` WITH victims AS ( - SELECT ctid + SELECT tableoid, ctid FROM usage_logs WHERE created_at < $1 ORDER BY created_at ASC, id ASC LIMIT $2 ) DELETE FROM usage_logs - WHERE ctid IN (SELECT ctid FROM victims) + WHERE (tableoid, ctid) IN (SELECT tableoid, ctid FROM victims) `, cutoff.UTC(), usageLogsCleanupBatchSize) if err != nil { return err @@ -298,14 +301,14 @@ func cleanupUsageLogsBatchWithRollupInvalidation(ctx context.Context, db *sql.DB } rows, err := tx.QueryContext(ctx, ` WITH victims AS ( - SELECT ctid + SELECT tableoid, ctid FROM usage_logs WHERE created_at < $1 ORDER BY created_at ASC, id ASC LIMIT $2 ) DELETE FROM usage_logs - WHERE ctid IN (SELECT ctid FROM victims) + WHERE (tableoid, ctid) IN (SELECT tableoid, ctid FROM victims) RETURNING created_at `, cutoff.UTC(), usageLogsCleanupBatchSize) if err != nil { diff --git a/backend/internal/repository/request_log_retention_integration_test.go b/backend/internal/repository/request_log_retention_integration_test.go new file mode 100644 index 000000000000..faee600804e2 --- /dev/null +++ b/backend/internal/repository/request_log_retention_integration_test.go @@ -0,0 +1,46 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRequestLogRetention_PartitionBoundaryKeepsRecentRows(t *testing.T) { + ctx := context.Background() + tx := testTx(t) + // Temporary tables shadow the real schema and disappear on rollback. + _, err := tx.ExecContext(ctx, ` + CREATE TEMP TABLE usage_logs (id bigint, created_at timestamptz) PARTITION BY RANGE (created_at); + CREATE TEMP TABLE retention_july PARTITION OF usage_logs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); + CREATE TEMP TABLE retention_august PARTITION OF usage_logs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); + INSERT INTO usage_logs VALUES + (1, '2026-07-17 23:59:59+00'), + (2, '2026-07-18 00:00:00+00'), + (3, '2026-08-01 00:00:00+00'); + `) + require.NoError(t, err) + var collidingCTIDs bool + require.NoError(t, tx.QueryRowContext(ctx, `SELECT a.ctid = b.ctid FROM usage_logs a, usage_logs b WHERE a.id = 1 AND b.id = 3`).Scan(&collidingCTIDs)) + require.True(t, collidingCTIDs, "fixture must exercise identical row locations in different partitions") + repo := newDashboardAggregationRepositoryWithSQL(tx) + cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + for range 2 { + require.NoError(t, repo.cleanupUsageLogsBatches(ctx, cutoff)) + rows, err := tx.QueryContext(ctx, `SELECT id FROM usage_logs ORDER BY id`) + require.NoError(t, err) + var ids []int64 + for rows.Next() { + var id int64 + require.NoError(t, rows.Scan(&id)) + ids = append(ids, id) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, []int64{2, 3}, ids) + } +} diff --git a/backend/internal/service/dashboard_aggregation_service.go b/backend/internal/service/dashboard_aggregation_service.go index 6e351d75d2bc..c85dec07a311 100644 --- a/backend/internal/service/dashboard_aggregation_service.go +++ b/backend/internal/service/dashboard_aggregation_service.go @@ -3,7 +3,9 @@ package service import ( "context" "database/sql" + "encoding/json" "errors" + "fmt" "log/slog" "sync/atomic" "time" @@ -55,6 +57,7 @@ type DashboardAggregationService struct { repo DashboardAggregationRepository timingWheel *TimingWheelService cfg config.DashboardAggregationConfig + settingRepo SettingRepository running int32 lastRetentionCleanup atomic.Value // time.Time @@ -95,6 +98,8 @@ func (s *DashboardAggregationService) Start() { } if !s.cfg.Enabled { logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业已禁用") + // Explicit runtime retention remains available without dashboard aggregation. + s.timingWheel.ScheduleRecurring("dashboard:retention", time.Minute, s.runScheduledRetention) return } go s.runStartupGroupUsageSync() @@ -360,20 +365,38 @@ func (s *DashboardAggregationService) maybeCleanupRetention(ctx context.Context, } } + usageDays, err := s.requestRetentionDays(ctx) + if err != nil { + // Never fall back to a shorter window after a settings read failure. + logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 读取日志保留设置失败,跳过清理: %v", err) + return + } hourlyCutoff := now.AddDate(0, 0, -s.cfg.Retention.HourlyDays) dailyCutoff := now.AddDate(0, 0, -s.cfg.Retention.DailyDays) - usageCutoff := now.AddDate(0, 0, -s.cfg.Retention.UsageLogsDays) - dedupCutoff := now.AddDate(0, 0, -s.cfg.Retention.UsageBillingDedupDays) + usageCutoff := now.AddDate(0, 0, -usageDays) + dedupDays := s.cfg.Retention.UsageBillingDedupDays + if dedupDays <= 0 { + dedupDays = 365 + } + dedupDays = max(dedupDays, usageDays) + dedupCutoff := now.AddDate(0, 0, -dedupDays) - aggErr := s.repo.CleanupAggregates(ctx, hourlyCutoff, dailyCutoff) + var aggErr, usageErr, dedupErr error + if s.cfg.Enabled { + aggErr = s.repo.CleanupAggregates(ctx, hourlyCutoff, dailyCutoff) + } if aggErr != nil { logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合保留清理失败: %v", aggErr) } - usageErr := s.repo.CleanupUsageLogs(ctx, usageCutoff) + if usageDays > 0 { + usageErr = s.repo.CleanupUsageLogs(ctx, usageCutoff) + } if usageErr != nil { logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] usage_logs 保留清理失败: %v", usageErr) } - dedupErr := s.repo.CleanupUsageBillingDedup(ctx, dedupCutoff) + if usageDays > 0 { + dedupErr = s.repo.CleanupUsageBillingDedup(ctx, dedupCutoff) + } if dedupErr != nil { logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] usage_billing_dedup 保留清理失败: %v", dedupErr) } @@ -382,6 +405,51 @@ func (s *DashboardAggregationService) maybeCleanupRetention(ctx context.Context, } } +func (s *DashboardAggregationService) requestRetentionDays(ctx context.Context) (int, error) { + days := s.cfg.Retention.UsageLogsDays + if !s.cfg.Enabled { + days = 0 + } + if s.settingRepo == nil { + return days, nil + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsRuntimeLogConfig) + if errors.Is(err, ErrSettingNotFound) { + return days, nil + } + if err != nil { + return 0, err + } + var cfg struct { + RequestRetentionDays *int `json:"request_retention_days"` + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return 0, err + } + if cfg.RequestRetentionDays != nil { + days = *cfg.RequestRetentionDays + if days < 0 || days > 3650 { + return 0, fmt.Errorf("invalid request_retention_days: %d", days) + } + } + return days, nil +} + +func (s *DashboardAggregationService) runScheduledRetention() { + if !atomic.CompareAndSwapInt32(&s.running, 0, 1) { + return + } + defer atomic.StoreInt32(&s.running, 0) + ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationTimeout) + defer cancel() + release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, dashboardAggregationLeaderLockKey, s.instanceID, dashboardAggregationLeaderLockTTL) + if !ok { + return + } + defer release() + s.maybeCleanupRetention(ctx, time.Now().UTC()) +} + func truncateToDayUTC(t time.Time) time.Time { t = t.UTC() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) diff --git a/backend/internal/service/dashboard_aggregation_service_test.go b/backend/internal/service/dashboard_aggregation_service_test.go index d164519330e7..f03d48491e58 100644 --- a/backend/internal/service/dashboard_aggregation_service_test.go +++ b/backend/internal/service/dashboard_aggregation_service_test.go @@ -11,21 +11,24 @@ import ( ) type dashboardAggregationRepoTestStub struct { - aggregateCalls int - recomputeCalls int - cleanupUsageCalls int - cleanupDedupCalls int - ensurePartitionCalls int - lastStart time.Time - lastEnd time.Time - watermark time.Time - aggregateErr error - cleanupAggregatesErr error - cleanupUsageErr error - cleanupDedupErr error - ensurePartitionErr error - aggregateCtx context.Context - events *[]string + aggregateCalls int + recomputeCalls int + cleanupUsageCalls int + cleanupUsageCutoff time.Time + cleanupDedupCutoff time.Time + cleanupAggregateCalls int + cleanupDedupCalls int + ensurePartitionCalls int + lastStart time.Time + lastEnd time.Time + watermark time.Time + aggregateErr error + cleanupAggregatesErr error + cleanupUsageErr error + cleanupDedupErr error + ensurePartitionErr error + aggregateCtx context.Context + events *[]string } type dashboardAggregationRollupRepoTestStub struct { @@ -71,16 +74,19 @@ func (s *dashboardAggregationRepoTestStub) UpdateAggregationWatermark(ctx contex } func (s *dashboardAggregationRepoTestStub) CleanupAggregates(ctx context.Context, hourlyCutoff, dailyCutoff time.Time) error { + s.cleanupAggregateCalls++ return s.cleanupAggregatesErr } func (s *dashboardAggregationRepoTestStub) CleanupUsageLogs(ctx context.Context, cutoff time.Time) error { s.cleanupUsageCalls++ + s.cleanupUsageCutoff = cutoff return s.cleanupUsageErr } func (s *dashboardAggregationRepoTestStub) CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error { s.cleanupDedupCalls++ + s.cleanupDedupCutoff = cutoff return s.cleanupDedupErr } @@ -214,6 +220,7 @@ func TestDashboardAggregationService_CleanupRetentionFailure_DoesNotRecord(t *te svc := &DashboardAggregationService{ repo: repo, cfg: config.DashboardAggregationConfig{ + Enabled: true, Retention: config.DashboardAggregationRetentionConfig{ UsageLogsDays: 1, HourlyDays: 1, @@ -234,6 +241,7 @@ func TestDashboardAggregationService_CleanupDedupFailure_DoesNotRecord(t *testin svc := &DashboardAggregationService{ repo: repo, cfg: config.DashboardAggregationConfig{ + Enabled: true, Retention: config.DashboardAggregationRetentionConfig{ UsageLogsDays: 1, HourlyDays: 1, diff --git a/backend/internal/service/ops_log_runtime.go b/backend/internal/service/ops_log_runtime.go index 637ab8d69f88..481fa6ca6c77 100644 --- a/backend/internal/service/ops_log_runtime.go +++ b/backend/internal/service/ops_log_runtime.go @@ -14,15 +14,23 @@ import ( ) func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { + requestRetentionDays := 90 + if cfg != nil { + requestRetentionDays = cfg.DashboardAgg.Retention.UsageLogsDays + if !cfg.DashboardAgg.Enabled { + requestRetentionDays = 0 + } + } out := &OpsRuntimeLogConfig{ - Level: "info", - PersistAccessLogs: false, - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "info", + PersistAccessLogs: false, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, + RequestRetentionDays: &requestRetentionDays, } if cfg == nil { return out @@ -60,6 +68,10 @@ func normalizeOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig, defaults *OpsRuntime if cfg.RetentionDays <= 0 { cfg.RetentionDays = defaults.RetentionDays } + if cfg.RequestRetentionDays == nil && defaults.RequestRetentionDays != nil { + days := *defaults.RequestRetentionDays + cfg.RequestRetentionDays = &days + } } func validateOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { @@ -85,6 +97,9 @@ func validateOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { if cfg.RetentionDays < 1 || cfg.RetentionDays > 3650 { return errors.New("retention_days must be between 1 and 3650") } + if cfg.RequestRetentionDays != nil && (*cfg.RequestRetentionDays < 0 || *cfg.RequestRetentionDays > 3650) { + return errors.New("request_retention_days must be between 0 and 3650") + } return nil } @@ -139,6 +154,9 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime return nil, err } next := *req + if next.RequestRetentionDays == nil { + next.RequestRetentionDays = oldCfg.RequestRetentionDays + } normalizeOpsRuntimeLogConfig(&next, defaultOpsRuntimeLogConfig(s.cfg)) if err := validateOpsRuntimeLogConfig(&next); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "validation_failed: "+err.Error()) diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index 93c364153cbc..8a32d7a92b4d 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -77,11 +77,14 @@ type OpsRuntimeLogConfig struct { Caller bool `json:"caller"` StacktraceLevel string `json:"stacktrace_level"` // RetentionDays is the authoritative retention for ops_system_logs. - RetentionDays int `json:"retention_days"` - Source string `json:"source,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - UpdatedByUserID int64 `json:"updated_by_user_id,omitempty"` - Extra map[string]any `json:"extra,omitempty"` + RetentionDays int `json:"retention_days"` + // RequestRetentionDays controls usage_logs; 0 keeps request history indefinitely. + // A nil value from older clients preserves the existing setting. + RequestRetentionDays *int `json:"request_retention_days"` + Source string `json:"source,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + UpdatedByUserID int64 `json:"updated_by_user_id,omitempty"` + Extra map[string]any `json:"extra,omitempty"` } type OpsAlertRuntimeSettings struct { diff --git a/backend/internal/service/request_log_retention_test.go b/backend/internal/service/request_log_retention_test.go new file mode 100644 index 000000000000..4cc8955f7972 --- /dev/null +++ b/backend/internal/service/request_log_retention_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/stretchr/testify/require" +) + +func TestRequestLogRetention_RuntimePolicy(t *testing.T) { + for _, tt := range []struct { + name string + raw string + enabled bool + wantDays int + wantCleanup bool + }{ + {"legacy uses deployment", `{"retention_days":30}`, true, 90, true}, + {"short window", `{"request_retention_days":7}`, true, 7, true}, + {"long window extends dedup", `{"request_retention_days":730}`, true, 730, true}, + {"forever skips request and dedup deletion", `{"request_retention_days":0}`, true, 0, false}, + {"works without aggregation", `{"request_retention_days":14}`, false, 14, true}, + {"legacy disabled stays disabled", `{"retention_days":30}`, false, 0, false}, + } { + t.Run(tt.name, func(t *testing.T) { + settings := newRuntimeSettingRepoStub() + settings.values[SettingKeyOpsRuntimeLogConfig] = tt.raw + repo := &dashboardAggregationRepoTestStub{} + svc := NewDashboardAggregationService(repo, nil, &config.Config{DashboardAgg: config.DashboardAggregationConfig{ + Enabled: tt.enabled, + Retention: config.DashboardAggregationRetentionConfig{UsageLogsDays: 90, UsageBillingDedupDays: 365, HourlyDays: 180, DailyDays: 730}, + }}) + svc.settingRepo = settings + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) + svc.maybeCleanupRetention(context.Background(), now) + if tt.wantCleanup { + require.Equal(t, 1, repo.cleanupUsageCalls) + require.Equal(t, now.AddDate(0, 0, -tt.wantDays), repo.cleanupUsageCutoff) + require.Equal(t, now.AddDate(0, 0, -max(365, tt.wantDays)), repo.cleanupDedupCutoff) + } else { + require.Zero(t, repo.cleanupUsageCalls) + require.Zero(t, repo.cleanupDedupCalls) + } + if !tt.enabled { + require.Zero(t, repo.cleanupAggregateCalls) + } + // Read the updated policy at the next rolling cleanup. + settings.values[SettingKeyOpsRuntimeLogConfig] = `{"request_retention_days":180}` + next := now.Add(dashboardAggregationRetentionInterval) + svc.maybeCleanupRetention(context.Background(), next) + require.Equal(t, next.AddDate(0, 0, -180), repo.cleanupUsageCutoff) + }) + } +} + +func TestRequestLogRetention_ReadFailureSkipsDeletion(t *testing.T) { + for _, raw := range []string{`{broken`, `{"request_retention_days":-1}`, `{"request_retention_days":3651}`} { + settings := newRuntimeSettingRepoStub() + settings.values[SettingKeyOpsRuntimeLogConfig] = raw + repo := &dashboardAggregationRepoTestStub{} + svc := &DashboardAggregationService{repo: repo, settingRepo: settings} + svc.runScheduledRetention() + require.Zero(t, repo.cleanupUsageCalls) + require.Zero(t, repo.cleanupDedupCalls) + require.Nil(t, svc.lastRetentionCleanup.Load()) + } + settings := newRuntimeSettingRepoStub() + settings.getValueFn = func(string) (string, error) { return "", errors.New("database unavailable") } + svc := &DashboardAggregationService{repo: &dashboardAggregationRepoTestStub{}, settingRepo: settings} + svc.runScheduledRetention() + require.Nil(t, svc.lastRetentionCleanup.Load()) +} + +func TestRuntimeLogConfig_RequestRetentionCompatibility(t *testing.T) { + settings := newRuntimeSettingRepoStub() + settings.values[SettingKeyOpsRuntimeLogConfig] = `{"level":"info","retention_days":7,"request_retention_days":180}` + svc := &OpsService{settingRepo: settings} + require.NoError(t, logger.Init(logger.InitOptions{Level: "info", Format: "json", Output: logger.OutputOptions{ToStdout: true}})) + t.Cleanup(logger.Sync) + legacy := defaultOpsRuntimeLogConfig(nil) + legacy.RequestRetentionDays = nil + updated, err := svc.UpdateRuntimeLogConfig(context.Background(), legacy, 1) + require.NoError(t, err) + require.Equal(t, 180, *updated.RequestRetentionDays) + for _, days := range []int{0, 1, 3650} { + updated.RequestRetentionDays = &days + saved, err := svc.UpdateRuntimeLogConfig(context.Background(), updated, 1) + require.NoError(t, err) + require.Equal(t, days, *saved.RequestRetentionDays) + var persisted OpsRuntimeLogConfig + require.NoError(t, json.Unmarshal([]byte(settings.values[SettingKeyOpsRuntimeLogConfig]), &persisted)) + require.Equal(t, days, *persisted.RequestRetentionDays) + } + for _, days := range []int{-1, 3651} { + updated.RequestRetentionDays = &days + before := settings.values[SettingKeyOpsRuntimeLogConfig] + _, err := svc.UpdateRuntimeLogConfig(context.Background(), updated, 1) + require.ErrorContains(t, err, "request_retention_days") + require.Equal(t, before, settings.values[SettingKeyOpsRuntimeLogConfig]) + } + reset, err := svc.ResetRuntimeLogConfig(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, 90, *reset.RequestRetentionDays) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3944ca5f07cf..10ecbd9e9982 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -373,8 +373,9 @@ func ProvideGrokTokenProvider( } // ProvideDashboardAggregationService 创建并启动仪表盘聚合服务 -func ProvideDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, lockCache LeaderLockCache, db *sql.DB, cfg *config.Config) *DashboardAggregationService { +func ProvideDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, lockCache LeaderLockCache, db *sql.DB, cfg *config.Config, settingRepo SettingRepository) *DashboardAggregationService { svc := NewDashboardAggregationService(repo, timingWheel, cfg) + svc.settingRepo = settingRepo svc.SetLeaderLock(lockCache, db) svc.Start() return svc diff --git a/docs/log-retention.md b/docs/log-retention.md new file mode 100644 index 000000000000..64c90f68e810 --- /dev/null +++ b/docs/log-retention.md @@ -0,0 +1,21 @@ +# 滚动日志保留设置 + +管理员在 **运维监控 → 系统日志 → 运行时日志配置** 中设置保留期,然后点击「保存并应用」。 + +| 日志 | 可选范围 | 执行时间 | +| --- | --- | --- | +| 运维系统日志 | 1–3650 天 | 运维设置中的数据清理计划,须开启「启用数据清理」 | +| 请求日志(请求使用明细) | 1–3650 天,或永久保留 | 每 6 小时清理一次,保存后在下一次清理生效 | + +下拉框提供 1、3、7、14、30、60、90、180、365 天,也可以选择「自定义天数」。按天清理只删除早于截止时间的记录;截止时间上的记录保留。请求日志按月分区时,也会清理截止日期所在分区中的过期记录。 + +## 配置兼容 + +- 设置保存在 `ops_runtime_log_config`:`retention_days` 控制运维系统日志,`request_retention_days` 控制请求日志,后者为 `0` 时表示永久保留。 +- 没有请求日志设置时,沿用 `dashboard_aggregation.retention.usage_logs_days`(默认 90 天);原来禁用仪表盘聚合的部署继续保留请求日志,不自动开启删除。 +- 显式保存请求日志保留天数后,即使仪表盘聚合关闭,滚动清理仍会运行。 +- 旧客户端没有传入 `request_retention_days` 时,保留已有值。「重置默认值」恢复部署配置。 +- 计费去重记录的保留期至少与请求日志一样长;请求日志永久保留时,也停止清理计费去重记录。 +- 读取请求日志保留设置失败或设置无效时,跳过清理,避免回退到更短的保留期。 + +运维错误日志和指标继续使用运维设置中的各自保留策略。本页的运维保留期针对数据库中的系统日志;磁盘日志文件仍使用部署配置中的轮转策略。 diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index a5ca947e115b..8754f1958370 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -822,6 +822,7 @@ export interface OpsRuntimeLogConfig { caller: boolean stacktrace_level: 'none' | 'error' | 'fatal' retention_days: number + request_retention_days: number source?: string updated_at?: string updated_by_user_id?: number diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index afdbd36db6f8..8576736b9c8b 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -41,8 +41,14 @@ export default { stacktraceThreshold: 'Stacktrace threshold', samplingInitial: 'Sampling initial', samplingThereafter: 'Sampling thereafter', - retentionDays: 'Retention days', - retentionDaysHint: 'Applied by the scheduled data-cleanup job.', + retentionDays: 'Operations log retention days', + requestRetentionDays: 'Request log retention days', + requestRetentionDaysHint: 'Request usage records are pruned every 6 hours. Changes apply on the next cleanup. Keeping records forever uses increasing storage.', + retentionDaysInvalid: 'Keep operations logs for 1–3650 days; keep request logs for 1–3650 days or choose Forever.', + retentionDaysOption: '{days} days', + retentionDaysCustom: 'Custom days', + retentionForever: 'Forever', + retentionDaysHint: 'Applied on the data-cleanup schedule when cleanup is enabled in Operations Settings.', caller: 'caller', sampling: 'sampling', persistAccessLogs: 'Store access logs in database', @@ -69,6 +75,7 @@ export default { logDetails: 'Log Details', loadFailed: 'Failed to load system logs', runtimeConfigActive: 'Runtime log configuration is active', + runtimeConfigLoadFailed: 'Failed to load log configuration. Refresh and try again.', runtimeConfigSaveFailed: 'Failed to save log configuration', resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?', runtimeConfigReset: 'Reset to startup log configuration', diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts index c7c017e4b996..ace6fff1e4f6 100644 --- a/frontend/src/i18n/locales/zh/admin/ops.ts +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -41,8 +41,14 @@ export default { stacktraceThreshold: '堆栈阈值', samplingInitial: '采样初始条数', samplingThereafter: '后续采样间隔', - retentionDays: '保留天数', - retentionDaysHint: '由定时数据清理任务执行。', + retentionDays: '运维日志保留天数', + requestRetentionDays: '请求日志保留天数', + requestRetentionDaysHint: '请求使用明细每 6 小时滚动清理一次,保存后在下次清理生效。永久保留会持续占用存储空间。', + retentionDaysInvalid: '运维日志须保留 1–3650 天;请求日志须保留 1–3650 天或选择永久保留。', + retentionDaysOption: '{days} 天', + retentionDaysCustom: '自定义天数', + retentionForever: '永久保留', + retentionDaysHint: '按运维设置中启用的数据清理计划执行。', caller: '调用方', sampling: '采样', persistAccessLogs: '将访问日志写入数据库', @@ -69,6 +75,7 @@ export default { logDetails: '日志详情', loadFailed: '加载系统日志失败', runtimeConfigActive: '运行时日志配置已生效', + runtimeConfigLoadFailed: '日志配置加载失败,请刷新后重试。', runtimeConfigSaveFailed: '保存日志配置失败', resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?', runtimeConfigReset: '已重置为启动日志配置', diff --git a/frontend/src/views/admin/ops/components/LogRetentionSelect.vue b/frontend/src/views/admin/ops/components/LogRetentionSelect.vue new file mode 100644 index 000000000000..21254ebc3888 --- /dev/null +++ b/frontend/src/views/admin/ops/components/LogRetentionSelect.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue index e360be3ce37c..3c6c1c2bf654 100644 --- a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue @@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useMediaQuery } from '@vueuse/core' import { useI18n } from 'vue-i18n' +import LogRetentionSelect from './LogRetentionSelect.vue' import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops' import Pagination from '@/components/common/Pagination.vue' import Select from '@/components/common/Select.vue' @@ -38,6 +39,7 @@ const health = ref({ }) const runtimeLoading = ref(false) +const runtimeConfigLoaded = ref(false) const runtimeSaving = ref(false) const runtimeConfig = reactive({ level: 'info', @@ -47,7 +49,8 @@ const runtimeConfig = reactive({ sampling_thereafter: 100, caller: true, stacktrace_level: 'error', - retention_days: 30 + retention_days: 30, + request_retention_days: 90 }) const filters = reactive({ @@ -229,6 +232,7 @@ const fetchHealth = async () => { const loadRuntimeConfig = async () => { runtimeLoading.value = true + runtimeConfigLoaded.value = false try { const cfg = await opsAPI.getRuntimeLogConfig() runtimeConfig.level = cfg.level @@ -239,14 +243,23 @@ const loadRuntimeConfig = async () => { runtimeConfig.caller = cfg.caller runtimeConfig.stacktrace_level = cfg.stacktrace_level runtimeConfig.retention_days = cfg.retention_days + runtimeConfig.request_retention_days = cfg.request_retention_days ?? 90 + runtimeConfigLoaded.value = true } catch (err: any) { console.error('[OpsSystemLogTable] Failed to load runtime log config', err) + appStore.showError(t('admin.ops.systemLogs.runtimeConfigLoadFailed')) } finally { runtimeLoading.value = false } } const saveRuntimeConfig = async () => { + if (!runtimeConfigLoaded.value || runtimeLoading.value) return + if (!Number.isInteger(runtimeConfig.retention_days) || runtimeConfig.retention_days < 1 || runtimeConfig.retention_days > 3650 || + !Number.isInteger(runtimeConfig.request_retention_days) || runtimeConfig.request_retention_days < 0 || runtimeConfig.request_retention_days > 3650) { + appStore.showError(t('admin.ops.systemLogs.retentionDaysInvalid')) + return + } runtimeSaving.value = true try { const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig }) @@ -258,6 +271,7 @@ const saveRuntimeConfig = async () => { runtimeConfig.caller = saved.caller runtimeConfig.stacktrace_level = saved.stacktrace_level runtimeConfig.retention_days = saved.retention_days + runtimeConfig.request_retention_days = saved.request_retention_days ?? 90 appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigActive')) } catch (err: any) { console.error('[OpsSystemLogTable] Failed to save runtime log config', err) @@ -282,6 +296,7 @@ const resetRuntimeConfig = async () => { runtimeConfig.caller = saved.caller runtimeConfig.stacktrace_level = saved.stacktrace_level runtimeConfig.retention_days = saved.retention_days + runtimeConfig.request_retention_days = saved.request_retention_days ?? 90 appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigReset')) await fetchHealth() } catch (err: any) { @@ -422,9 +437,14 @@ onMounted(async () => { +
@@ -442,7 +462,7 @@ onMounted(async () => {
- +
@@ -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 000000000000..8d3cf6797907 --- /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 000000000000..6306a5364bd8 --- /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 000000000000..f6aac2570a53 --- /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 a9ff6633870f1d4b47d84619c7ce2764d406ee0b Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 18 Sep 2026 06:47:43 +0800 Subject: [PATCH 025/142] =?UTF-8?q?feat(backup):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9C=88=E5=BA=A6=E5=BD=92=E6=A1=A3=E4=B8=8E=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/backup_handler.go | 8 +- .../internal/service/backup_recovery_test.go | 222 ++++++++++++ .../internal/service/backup_restore_state.go | 85 +++++ backend/internal/service/backup_retention.go | 193 ++++++++++ .../internal/service/backup_retention_test.go | 296 ++++++++++++++++ backend/internal/service/backup_service.go | 334 ++++++++++++------ .../internal/service/backup_service_test.go | 9 +- frontend/src/api/admin/backup.ts | 19 +- .../admin/BackupArchiveSettings.vue | 125 +++++++ .../src/i18n/locales/en/admin/overview.ts | 36 +- .../src/i18n/locales/zh/admin/overview.ts | 36 +- frontend/src/views/admin/BackupView.vue | 71 +++- .../views/admin/__tests__/BackupView.spec.ts | 108 +++++- 13 files changed, 1415 insertions(+), 127 deletions(-) create mode 100644 backend/internal/service/backup_recovery_test.go create mode 100644 backend/internal/service/backup_restore_state.go create mode 100644 backend/internal/service/backup_retention.go create mode 100644 backend/internal/service/backup_retention_test.go create mode 100644 frontend/src/components/admin/BackupArchiveSettings.vue diff --git a/backend/internal/handler/admin/backup_handler.go b/backend/internal/handler/admin/backup_handler.go index b668eda82a8d..4d874660de67 100644 --- a/backend/internal/handler/admin/backup_handler.go +++ b/backend/internal/handler/admin/backup_handler.go @@ -140,7 +140,13 @@ func (h *BackupHandler) DeleteBackup(c *gin.Context) { response.BadRequest(c, "backup ID is required") return } - if err := h.backupService.DeleteBackup(c.Request.Context(), backupID); err != nil { + var err error + if c.Query("delete_archived") == "true" { + err = h.backupService.DeleteArchivedBackup(c.Request.Context(), backupID) + } else { + err = h.backupService.DeleteBackup(c.Request.Context(), backupID) + } + if err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/service/backup_recovery_test.go b/backend/internal/service/backup_recovery_test.go new file mode 100644 index 000000000000..aa4a34023b87 --- /dev/null +++ b/backend/internal/service/backup_recovery_test.go @@ -0,0 +1,222 @@ +//go:build unit + +package service + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type backupDelayedReadRepo struct { + *mockSettingRepo + fired atomic.Bool + loaded chan struct{} + resume chan struct{} +} + +func (r *backupDelayedReadRepo) GetValue(ctx context.Context, key string) (string, error) { + value, err := r.mockSettingRepo.GetValue(ctx, key) + if key == settingKeyBackupRecords && r.fired.CompareAndSwap(false, true) { + close(r.loaded) + <-r.resume + } + return value, err +} +func TestBackupRecovery_RestartPreservesPeerArchive(t *testing.T) { + ctx := context.Background() + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + writer := newTestBackupService(repo, &mockDumper{}, store) + started := time.Now().UTC() + record := archiveRecord("now-permanent", started.Format(time.RFC3339)) + record.Status = "running" + record.S3Key = "now-permanent.sql.gz" + store.objects[record.S3Key] = []byte("important archive") + require.NoError(t, writer.saveRecord(ctx, record)) + + restarting := newTestBackupService(repo, &mockDumper{}, store) + delayedRepo := &backupDelayedReadRepo{mockSettingRepo: repo, loaded: make(chan struct{}), resume: make(chan struct{})} + restarting.settingRepo = delayedRepo + cache := &fakeLeaderLockCache{} + writer.SetLeaderLock(cache, nil) + restarting.SetLeaderLock(cache, nil) + done := make(chan struct{}) + go func() { restarting.recoverStaleRecords(); close(done) }() + <-delayedRepo.loaded + record.Status = "completed" + // Recovery must own the writer lock across its read and decision. The peer + // completes only after recovery releases it; the young operation is active. + require.Equal(t, restarting.instanceID, cache.heldBy("backup:records:writer")) + written := make(chan error, 1) + go func() { written <- writer.saveRecordWithArchive(ctx, record, archiveSchedule(started.Day())) }() + close(delayedRepo.resume) + <-done + require.NoError(t, <-written) + require.NotNil(t, record.MonthlyArchive) + restarting.recoverStaleRecords() + saved, err := writer.GetBackupRecord(ctx, record.ID) + require.NoError(t, err) + require.NotNil(t, saved.MonthlyArchive) + require.Equal(t, "completed", saved.Status, "completed permanent archive was overwritten by stale restart snapshot") + require.Contains(t, store.objects, record.S3Key, "restart must not delete completed permanent archive") +} + +func TestBackupRecovery_RestoreRejectsRemovedArchive(t *testing.T) { + ctx := context.Background() + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + cleaner := newTestBackupService(repo, &mockDumper{}, store) + for i, id := range []string{"old", "new"} { + record := archiveRecord(id, []string{"2026-09-01T04:00:00Z", "2026-09-15T04:00:00Z"}[i]) + record.MonthlyArchive = &BackupMonthlyArchive{RetainCount: 1} + record.S3Key = id + store.objects[id] = []byte("payload") + require.NoError(t, cleaner.saveRecord(ctx, record)) + } + restoring := newTestBackupService(repo, &mockDumper{}, store) + delayedRepo := &backupDelayedReadRepo{mockSettingRepo: repo, loaded: make(chan struct{}), resume: make(chan struct{})} + restoring.settingRepo = delayedRepo + cache := &fakeLeaderLockCache{} + cleaner.SetLeaderLock(cache, nil) + restoring.SetLeaderLock(cache, nil) + result := make(chan error) + go func() { _, err := restoring.StartRestore(ctx, "old"); result <- err }() + <-delayedRepo.loaded + cfg := archiveSchedule(1, 15) + cfg.MonthlyArchive.RetainCount = 1 + require.NoError(t, cleaner.cleanupOldBackups(ctx, cfg)) + require.NotContains(t, store.objects, "old") + close(delayedRepo.resume) + err := <-result + restoring.wg.Wait() + saved, getErr := cleaner.GetBackupRecord(ctx, "old") + require.Nil(t, saved) + require.ErrorIs(t, err, ErrBackupNotFound, "restore must not start against a deleted archive") + require.ErrorIs(t, getErr, ErrBackupNotFound, "cleanup-deleted archive must not be resurrected") +} + +type backupFailRecordWriteRepo struct{ *mockSettingRepo } + +func (r *backupFailRecordWriteRepo) Set(ctx context.Context, key, value string) error { + if key == settingKeyBackupRecords { + return context.DeadlineExceeded + } + return r.mockSettingRepo.Set(ctx, key, value) +} +func TestBackupRecovery_RestoreRequiresDurableProtection(t *testing.T) { + ctx := context.Background() + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + record := archiveRecord("finite-archive", "2026-09-01T04:00:00Z") + record.MonthlyArchive = &BackupMonthlyArchive{RetainCount: 1} + record.S3Key = record.ID + require.NoError(t, svc.saveRecord(ctx, record)) + svc.settingRepo = &backupFailRecordWriteRepo{repo} + _, err := svc.StartRestore(ctx, record.ID) + svc.wg.Wait() + require.Error(t, err, "failed restore-running marker must prevent restore from starting") +} + +func TestBackupRecovery_ActiveAndLegacyRestoresKeepProtection(t *testing.T) { + ctx := context.Background() + svc := newTestBackupService(newMockSettingRepo(), &mockDumper{}, newMockObjectStore()) + for _, id := range []string{"active", "legacy"} { + record := archiveRecord(id, time.Now().AddDate(-1, 0, 0).Format(time.RFC3339)) + record.MonthlyArchive = &BackupMonthlyArchive{RetainCount: 0} + record.RestoreStatus = "running" + if id == "active" { + record.RestoreStartedAt = time.Now().Format(time.RFC3339) + } + require.NoError(t, svc.saveRecord(ctx, record)) + } + svc.recoverStaleRecords() + for _, id := range []string{"active", "legacy"} { + record, err := svc.GetBackupRecord(ctx, id) + require.NoError(t, err) + require.Equal(t, "running", record.RestoreStatus) + require.NotEmpty(t, record.RestoreStartedAt) + require.ErrorIs(t, svc.DeleteArchivedBackup(ctx, id), ErrRestoreInProgress) + } +} + +func TestBackupRecovery_FailedStateWriteNeverDeletesObjects(t *testing.T) { + ctx := context.Background() + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + record := archiveRecord("interrupted", time.Now().Add(-time.Hour).Format(time.RFC3339)) + record.Status, record.S3Key = "running", "interrupted.sql.gz" + store.objects[record.S3Key] = []byte("payload") + require.NoError(t, svc.saveRecord(ctx, record)) + svc.settingRepo = &backupFailRecordWriteRepo{repo} + svc.recoverStaleRecords() + saved, err := svc.GetBackupRecord(ctx, record.ID) + require.NoError(t, err) + require.Equal(t, "running", saved.Status) + require.Contains(t, store.objects, record.S3Key) + require.Empty(t, store.deletedKeys) +} + +func TestBackupRecovery_RestoreReservationProtectsFromPeerCleanup(t *testing.T) { + ctx := context.Background() + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + peer := newTestBackupService(repo, &mockDumper{}, store) + cache := &fakeLeaderLockCache{} + svc.SetLeaderLock(cache, nil) + peer.SetLeaderLock(cache, nil) + record := archiveRecord("old", time.Now().AddDate(-1, 0, 0).Format(time.RFC3339)) + record.S3Key = record.ID + store.objects[record.ID] = []byte("payload") + require.NoError(t, svc.saveRecord(ctx, record)) + restoring, err := svc.beginBackupRestore(ctx, record.ID) + require.NoError(t, err) + require.Equal(t, "running", restoring.RestoreStatus) + _, err = peer.beginBackupRestore(ctx, record.ID) + require.ErrorIs(t, err, ErrRestoreInProgress) + require.NoError(t, peer.cleanupOldBackups(ctx, archiveSchedule(1))) + require.Contains(t, store.objects, record.ID) + require.ErrorIs(t, peer.DeleteBackup(ctx, record.ID), ErrRestoreInProgress) +} + +func TestBackupRecovery_FailedRestoreCannotRecreateMissingRecord(t *testing.T) { + ctx := context.Background() + svc := newTestBackupService(newMockSettingRepo(), &mockDumper{}, newMockObjectStore()) + record := archiveRecord("removed", time.Now().Format(time.RFC3339)) + record.RestoreStatus = "failed" + require.ErrorIs(t, svc.saveRestoreRecord(ctx, record), ErrBackupNotFound) + _, err := svc.GetBackupRecord(ctx, record.ID) + require.ErrorIs(t, err, ErrBackupNotFound) +} + +func TestBackupRecovery_LateRestoreResultCannotReleaseNewProtection(t *testing.T) { + ctx := context.Background() + svc := newTestBackupService(newMockSettingRepo(), &mockDumper{}, newMockObjectStore()) + record := archiveRecord("restoring", time.Now().AddDate(-1, 0, 0).Format(time.RFC3339)) + record.RestoreStatus = "running" + record.RestoreStartedAt = time.Now().Add(-time.Hour).Format(time.RFC3339) + require.NoError(t, svc.saveRecord(ctx, record)) + svc.recoverStaleRecords() + current, err := svc.beginBackupRestore(ctx, record.ID) + require.NoError(t, err) + for _, status := range []string{"failed", "completed"} { + record.RestoreStatus = status + require.Error(t, svc.saveRestoreRecord(ctx, record)) + saved, err := svc.GetBackupRecord(ctx, record.ID) + require.NoError(t, err) + require.Equal(t, "running", saved.RestoreStatus) + require.Equal(t, current.RestoreStartedAt, saved.RestoreStartedAt) + require.ErrorIs(t, svc.DeleteBackup(ctx, record.ID), ErrRestoreInProgress) + } +} diff --git a/backend/internal/service/backup_restore_state.go b/backend/internal/service/backup_restore_state.go new file mode 100644 index 000000000000..832109c12905 --- /dev/null +++ b/backend/internal/service/backup_restore_state.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +func (s *BackupService) beginBackupRestore(ctx context.Context, id string) (*BackupRecord, error) { + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return nil, err + } + defer release() + records, err := s.loadRecordsLocked(ctx) + if err != nil { + return nil, err + } + for i := range records { + if records[i].ID != id { + continue + } + if records[i].Status != "completed" { + return nil, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup") + } + if records[i].RestoreStatus == "running" { + return nil, ErrRestoreInProgress + } + records[i].RestoreStatus = "running" + records[i].RestoreStartedAt = time.Now().Format(time.RFC3339) + records[i].RestoreError = "" + if err := s.saveRecordsLocked(ctx, records); err != nil { + return nil, err + } + return &records[i], nil + } + return nil, ErrBackupNotFound +} + +// Failed/panicked restores must not resurrect a removed record. A successful +// full database restore may itself roll back settings and remove its own record, +// so that completion keeps the existing explicit re-registration behavior. +func (s *BackupService) saveRestoreRecord(ctx context.Context, record *BackupRecord) error { + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return err + } + defer release() + records, err := s.loadRecordsLocked(ctx) + if err != nil { + return err + } + for i := range records { + if records[i].ID != record.ID { + continue + } + if records[i].RestoreStartedAt != record.RestoreStartedAt && + (record.RestoreStatus != "completed" || (records[i].RestoreStatus == "running" && records[i].RestoreStartedAt != "")) { + return infraerrors.Conflict("BACKUP_RESTORE_STATE_CHANGED", "a newer restore operation owns this backup") + } + if record.RestoreStatus == "completed" { + // Restoring the full database may roll the backup record itself back + // to its pre-upload state. Re-register the completed snapshot while + // retaining any archive protection already persisted in the database. + snapshot := *record + if records[i].MonthlyArchive != nil { + snapshot.MonthlyArchive = records[i].MonthlyArchive + snapshot.ExpiresAt = "" + } + records[i] = snapshot + return s.saveRecordsLocked(ctx, records) + } + // Only the restore operation's fields change, preserving current archive + // metadata and any unrelated progress recorded by another writer. + records[i].RestoreStatus = record.RestoreStatus + records[i].RestoreError = record.RestoreError + records[i].RestoredAt = record.RestoredAt + return s.saveRecordsLocked(ctx, records) + } + if record.RestoreStatus == "completed" { + return s.saveRecordsLocked(ctx, append(records, *record)) + } + return ErrBackupNotFound +} diff --git a/backend/internal/service/backup_retention.go b/backend/internal/service/backup_retention.go new file mode 100644 index 000000000000..e5a6dcfe75eb --- /dev/null +++ b/backend/internal/service/backup_retention.go @@ -0,0 +1,193 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/robfig/cron/v3" +) + +const settingKeyBackupArchiveCheckpoint = "backup_monthly_archive_checkpoint" + +// Records and the archive checkpoint share a read/modify/write boundary across +// instances, including manual backups and restore status updates. Use the same +// backend consistently; a Redis error must not silently switch to a DB lock +// while another instance still owns the Redis lock. +func (s *BackupService) lockBackupRecordUpdates(ctx context.Context) (context.Context, func(), error) { + s.recordsMu.Lock() + writeCtx, cancel := context.WithTimeout(ctx, 30*time.Minute) + waitCtx, stopWaiting := context.WithTimeout(writeCtx, 10*time.Second) + defer stopWaiting() + const key = "backup:records:writer" + for { + var release func() + var ok bool + if s.lockCache != nil { + var err error + ok, err = s.lockCache.TryAcquireLeaderLock(waitCtx, key, s.instanceID, backupScheduledLeaderLockTTL) + if err != nil { + cancel() + s.recordsMu.Unlock() + return nil, nil, fmt.Errorf("lock backup records: %w", err) + } + release = func() { + ctx, done := context.WithTimeout(context.Background(), 2*time.Second) + defer done() + _ = s.lockCache.ReleaseLeaderLock(ctx, key, s.instanceID) + } + } else if s.db != nil { + // A session advisory lock holds a connection while the repository + // needs another. Redis avoids this requirement in production. + if s.db.Stats().MaxOpenConnections == 1 { + cancel() + s.recordsMu.Unlock() + return nil, nil, fmt.Errorf("backup record locking requires Redis or at least two database connections") + } + var err error + release, ok, err = tryAcquireDBAdvisoryLockWithError(waitCtx, s.db, hashAdvisoryLockID(key)) + if err != nil { + cancel() + s.recordsMu.Unlock() + return nil, nil, fmt.Errorf("lock backup records: %w", err) + } + } else { + release, ok = func() {}, true + } + if ok { + return writeCtx, func() { release(); cancel(); s.recordsMu.Unlock() }, nil + } + select { + case <-waitCtx.Done(): + cancel() + s.recordsMu.Unlock() + return nil, nil, fmt.Errorf("lock backup records: %w", waitCtx.Err()) + case <-time.After(25 * time.Millisecond): + } + } +} + +type BackupMonthlyArchiveConfig struct { + Enabled bool `json:"enabled"` + Days []int `json:"days"` + IncludeMonthEnd bool `json:"include_month_end"` + RetainCount int `json:"retain_count"` // 0 = permanent; positive = finite archive pool size. +} + +type BackupMonthlyArchive struct { + Dates []string `json:"dates"` // Deduplicated target dates covered by this backup. + RetainCount int `json:"retain_count"` // 0 is permanent and never implicitly downgraded. +} + +func validateBackupRetention(cfg *BackupScheduleConfig) error { + if cfg.RetainDays < 0 || cfg.RetainCount < 0 { + return infraerrors.BadRequest("INVALID_BACKUP_RETENTION", "backup retention must not be negative") + } + archive := cfg.MonthlyArchive + if archive == nil { + return nil + } + if archive.RetainCount < 0 || len(archive.Days) > 31 { + return infraerrors.BadRequest("INVALID_MONTHLY_ARCHIVE", "invalid archive retention or dates") + } + seen := make(map[int]bool) + days := make([]int, 0, len(archive.Days)) + for _, day := range archive.Days { + if day < 1 || day > 31 { + return infraerrors.BadRequest("INVALID_MONTHLY_ARCHIVE", "archive dates must be between 1 and 31") + } + if !seen[day] { + days = append(days, day) + seen[day] = true + } + } + sort.Ints(days) + archive.Days = days + if archive.Enabled && len(days) == 0 && !archive.IncludeMonthEnd { + return infraerrors.BadRequest("INVALID_MONTHLY_ARCHIVE", "select at least one archive date") + } + return nil +} + +// assignMonthlyArchive runs under recordsMu and only for a completed scheduled +// backup. Successful processing advances a durable date checkpoint even when +// no date is due. Removing a record or reducing retention cannot reset it. +func (s *BackupService) assignMonthlyArchive(ctx context.Context, record *BackupRecord, schedule *BackupScheduleConfig) (string, error) { + if record.Status != "completed" || record.TriggeredBy != "scheduled" || record.MonthlyArchive != nil || schedule == nil || schedule.MonthlyArchive == nil || !schedule.MonthlyArchive.Enabled { + return "", nil + } + if err := validateBackupRetention(schedule); err != nil { + return "", err + } + startedAt, err := time.Parse(time.RFC3339, record.StartedAt) + if err != nil { + return "", fmt.Errorf("parse archive backup start: %w", err) + } + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + spec, err := parser.Parse(schedule.CronExpr) + if err != nil { + return "", fmt.Errorf("parse archive schedule: %w", err) + } + location := time.Local + if parsed, ok := spec.(*cron.SpecSchedule); ok { + location = parsed.Location + } + startedAt = startedAt.In(location) + today := startedAt.Format(time.DateOnly) + checkpoint, err := s.settingRepo.GetValue(ctx, settingKeyBackupArchiveCheckpoint) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return "", fmt.Errorf("load archive checkpoint: %w", err) + } + if checkpoint != "" { + if _, err := time.Parse(time.DateOnly, checkpoint); err != nil { + return "", infraerrors.InternalServer("BACKUP_ARCHIVE_CHECKPOINT_CORRUPT", "archive checkpoint data is corrupted") + } + if checkpoint >= today { + return "", nil + } + } + dates := monthlyArchiveDates(startedAt, schedule.MonthlyArchive) + var due []string + for _, date := range dates { + if date > checkpoint && date <= today { + due = append(due, date) + } + } + if len(due) > 0 { + record.MonthlyArchive = &BackupMonthlyArchive{Dates: due, RetainCount: schedule.MonthlyArchive.RetainCount} + record.ExpiresAt = "" + } + return today, nil +} + +func monthlyArchiveDates(at time.Time, cfg *BackupMonthlyArchiveConfig) []string { + lastDay := time.Date(at.Year(), at.Month()+1, 0, 0, 0, 0, 0, at.Location()).Day() + unique := make(map[int]bool) + for _, day := range cfg.Days { + unique[min(day, lastDay)] = true + } + if cfg.IncludeMonthEnd { + unique[lastDay] = true + } + dates := make([]string, 0, len(unique)) + for day := range unique { + dates = append(dates, time.Date(at.Year(), at.Month(), day, 0, 0, 0, 0, at.Location()).Format(time.DateOnly)) + } + sort.Strings(dates) + return dates +} + +func backupStartedAfter(a, b BackupRecord) bool { + left, leftErr := time.Parse(time.RFC3339, a.StartedAt) + right, rightErr := time.Parse(time.RFC3339, b.StartedAt) + if leftErr == nil && rightErr == nil && !left.Equal(right) { + return left.After(right) + } + if a.StartedAt != b.StartedAt { + return a.StartedAt > b.StartedAt + } + return a.ID > b.ID +} diff --git a/backend/internal/service/backup_retention_test.go b/backend/internal/service/backup_retention_test.go new file mode 100644 index 000000000000..41b45bf80f5a --- /dev/null +++ b/backend/internal/service/backup_retention_test.go @@ -0,0 +1,296 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func archiveSchedule(days ...int) *BackupScheduleConfig { + return &BackupScheduleConfig{CronExpr: "CRON_TZ=UTC 0 4 * * *", RetainDays: 14, RetainCount: 10, + MonthlyArchive: &BackupMonthlyArchiveConfig{Enabled: true, Days: days}} +} + +func archiveRecord(id, started string) *BackupRecord { + return &BackupRecord{ID: id, Status: "completed", TriggeredBy: "scheduled", StartedAt: started, ExpiresAt: "2099-01-01T00:00:00Z"} +} + +func TestBackupRetention_ValidationAndRoundTrip(t *testing.T) { + for _, cfg := range []BackupScheduleConfig{ + {RetainDays: -1}, {RetainCount: -1}, + {MonthlyArchive: &BackupMonthlyArchiveConfig{Enabled: true}}, + {MonthlyArchive: &BackupMonthlyArchiveConfig{Days: []int{0}}}, + {MonthlyArchive: &BackupMonthlyArchiveConfig{Days: []int{32}}}, + {MonthlyArchive: &BackupMonthlyArchiveConfig{RetainCount: -1}}, + } { + require.Error(t, validateBackupRetention(&cfg)) + } + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + cfg := archiveSchedule(15, 1, 15) + cfg.RetainDays, cfg.RetainCount = 0, 0 + cfg.MonthlyArchive.IncludeMonthEnd = true + cfg.MonthlyArchive.RetainCount = 17 + saved, err := svc.UpdateSchedule(context.Background(), *cfg) + require.NoError(t, err) + require.Equal(t, []int{1, 15}, saved.MonthlyArchive.Days) + loaded, err := svc.GetSchedule(context.Background()) + require.NoError(t, err) + require.Equal(t, saved, loaded) + require.Zero(t, loaded.RetainDays) + require.Zero(t, loaded.RetainCount) +} + +func TestBackupRetention_MonthEndAndLeapYears(t *testing.T) { + for _, tc := range []struct{ date, end string }{ + {"2026-02-01", "2026-02-28"}, {"2028-02-01", "2028-02-29"}, {"2026-04-01", "2026-04-30"}, + } { + t.Run(tc.date, func(t *testing.T) { + at, err := time.Parse(time.DateOnly, tc.date) + require.NoError(t, err) + cfg := &BackupMonthlyArchiveConfig{Days: []int{30, 31}, IncludeMonthEnd: true} + require.Equal(t, []string{tc.end}, monthlyArchiveDates(at, cfg)) + }) + } +} + +func TestBackupRetention_SuccessOnlyFallbackAndMultipleDates(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + cfg := archiveSchedule(1, 15) + ctx := context.Background() + failed := archiveRecord("failed", "2026-09-01T04:00:00Z") + failed.Status = "failed" + require.NoError(t, svc.saveRecordWithArchive(ctx, failed, cfg)) + manual := archiveRecord("manual", "2026-09-01T05:00:00Z") + manual.TriggeredBy = "manual" + require.NoError(t, svc.saveRecordWithArchive(ctx, manual, cfg)) + require.Nil(t, failed.MonthlyArchive) + require.Nil(t, manual.MonthlyArchive) + first := archiveRecord("first", "2026-09-02T04:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, first, cfg)) + require.Equal(t, []string{"2026-09-01"}, first.MonthlyArchive.Dates) + require.Empty(t, first.ExpiresAt) + again := archiveRecord("again", "2026-09-02T06:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, again, cfg)) + require.Nil(t, again.MonthlyArchive) + next := archiveRecord("next", "2026-09-16T04:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, next, cfg)) + require.Equal(t, []string{"2026-09-15"}, next.MonthlyArchive.Dates) + // A later month does not manufacture archives for the missed previous month. + newMonth := archiveRecord("oct", "2026-10-20T04:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, newMonth, cfg)) + require.Equal(t, []string{"2026-10-01", "2026-10-15"}, newMonth.MonthlyArchive.Dates) +} + +func TestBackupRetention_ScheduleTimezoneAndStartDate(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + cfg := archiveSchedule(1, 31) + cfg.CronExpr = "CRON_TZ=Asia/Tokyo 0 4 * * *" + first := archiveRecord("tokyo", "2026-08-31T16:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(context.Background(), first, cfg)) + require.Equal(t, []string{"2026-09-01"}, first.MonthlyArchive.Dates) + last := archiveRecord("cross-midnight", "2026-09-30T14:59:00Z") + last.FinishedAt = "2026-09-30T15:10:00Z" + require.NoError(t, svc.saveRecordWithArchive(context.Background(), last, cfg)) + require.Equal(t, []string{"2026-09-30"}, last.MonthlyArchive.Dates) +} + +func TestBackupRetention_CheckpointSurvivesRecordRemovalAndRestart(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + cfg := archiveSchedule(1, 15) + ctx := context.Background() + record := archiveRecord("archive", "2026-09-16T04:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, record, cfg)) + require.ErrorIs(t, svc.DeleteBackup(ctx, record.ID), ErrBackupArchiveProtected) + require.NoError(t, svc.DeleteArchivedBackup(ctx, record.ID)) + svc = newTestBackupService(repo, &mockDumper{}, store) + retry := archiveRecord("retry", "2026-09-17T04:00:00Z") + require.NoError(t, svc.saveRecordWithArchive(ctx, retry, cfg)) + require.Nil(t, retry.MonthlyArchive) +} + +func TestBackupRetention_CleanupSeparatePoolsAndPermanentProtection(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + ctx := context.Background() + now := time.Now().UTC() + for i, id := range []string{"running", "failed", "ordinary-new", "archive-new", "permanent", "ordinary-old", "archive-old", "restoring"} { + r := &BackupRecord{ID: id, Status: "completed", StartedAt: now.Add(-time.Duration(i) * time.Hour).Format(time.RFC3339), S3Key: id} + switch id { + case "running", "failed": + r.Status = id + case "archive-new", "archive-old": + r.MonthlyArchive = &BackupMonthlyArchive{RetainCount: 12} + case "permanent": + r.MonthlyArchive = &BackupMonthlyArchive{} + case "restoring": + r.RestoreStatus = "running" + } + store.objects[id] = []byte(id) + require.NoError(t, svc.saveRecord(ctx, r)) + } + cfg := &BackupScheduleConfig{RetainDays: 1, RetainCount: 1, MonthlyArchive: &BackupMonthlyArchiveConfig{RetainCount: 1}} + require.NoError(t, svc.cleanupOldBackups(ctx, cfg)) + for _, id := range []string{"running", "failed", "ordinary-new", "archive-new", "permanent", "restoring"} { + _, err := svc.GetBackupRecord(ctx, id) + require.NoError(t, err, id) + } + for _, id := range []string{"ordinary-old", "archive-old"} { + _, err := svc.GetBackupRecord(ctx, id) + require.ErrorIs(t, err, ErrBackupNotFound) + require.NotContains(t, store.objects, id) + } + finite, err := svc.GetBackupRecord(ctx, "archive-new") + require.NoError(t, err) + require.Equal(t, 1, finite.MonthlyArchive.RetainCount) + // Switching the rule off or to permanent does not downgrade prior permanent archives. + cfg.MonthlyArchive = &BackupMonthlyArchiveConfig{} + cfg.RetainDays = 0 + require.NoError(t, svc.cleanupOldBackups(ctx, cfg)) + _, err = svc.GetBackupRecord(ctx, "permanent") + require.NoError(t, err) +} + +func TestBackupRetention_Over100ArchivesRemainDiscoverable(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + ctx := context.Background() + for i := range 105 { + record := archiveRecord(fmt.Sprint(i), "2020-01-01T00:00:00Z") + record.MonthlyArchive = &BackupMonthlyArchive{} + require.NoError(t, svc.saveRecord(ctx, record)) + } + require.NoError(t, svc.cleanupOldBackups(ctx, &BackupScheduleConfig{RetainDays: 1, RetainCount: 1})) + records, err := svc.ListBackups(ctx) + require.NoError(t, err) + require.Len(t, records, 105) +} + +type failingArchiveRepo struct { + *mockSettingRepo + fail bool +} + +func (r *failingArchiveRepo) SetMultiple(ctx context.Context, values map[string]string) error { + if r.fail { + return errors.New("injected atomic save failure") + } + return r.mockSettingRepo.SetMultiple(ctx, values) +} + +func TestBackupRetention_PersistenceFailuresFailClosed(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + ctx := context.Background() + failing := &failingArchiveRepo{mockSettingRepo: repo, fail: true} + svc.settingRepo = failing + record := archiveRecord("new", "2026-09-01T04:00:00Z") + cfg := archiveSchedule(1) + require.Error(t, svc.saveRecordWithArchive(ctx, record, cfg)) + require.Nil(t, record.MonthlyArchive, "failed commit must not mutate the caller's retry state") + require.Empty(t, repo.data[settingKeyBackupArchiveCheckpoint]) + failing.fail = false + require.NoError(t, svc.saveRecordWithArchive(ctx, record, cfg)) + require.NotNil(t, record.MonthlyArchive) + oldRecords := repo.data[settingKeyBackupRecords] + repo.getValueErr = errors.New("read failed") + require.Error(t, svc.saveRecord(ctx, archiveRecord("other", "2026-09-02T04:00:00Z"))) + require.Equal(t, oldRecords, repo.data[settingKeyBackupRecords]) + require.Error(t, svc.cleanupOldBackups(ctx, cfg)) + require.Error(t, func() error { _, err := svc.GetSchedule(ctx); return err }()) + repo.getValueErr = nil + repo.data[settingKeyBackupRecords] = "corrupted" + require.ErrorIs(t, svc.saveRecord(ctx, record), ErrBackupRecordsCorrupt) + require.Equal(t, "corrupted", repo.data[settingKeyBackupRecords]) + repo.data[settingKeyBackupRecords] = oldRecords + repo.data[settingKeyBackupArchiveCheckpoint] = "bad date" + require.Error(t, svc.saveRecordWithArchive(ctx, archiveRecord("later", "2026-09-02T04:00:00Z"), cfg)) + require.Equal(t, oldRecords, repo.data[settingKeyBackupRecords]) +} + +func TestBackupRetention_ScheduledExecutionAndZeroDays(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("backup")}, newMockObjectStore()) + cfg := archiveSchedule(time.Now().UTC().Day()) + cfg.RetainDays = 0 + raw, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, repo.Set(context.Background(), settingKeyBackupSchedule, string(raw))) + svc.runScheduledBackup() + records, err := svc.ListBackups(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + require.NotNil(t, records[0].MonthlyArchive) + require.Empty(t, records[0].ExpiresAt) + // The following successful backup is ordinary and must still honor zero days. + svc.runScheduledBackup() + records, err = svc.ListBackups(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + for _, record := range records { + require.Empty(t, record.ExpiresAt) + } +} + +func TestBackupRetention_CrossInstanceRecordUpdates(t *testing.T) { + repo := newMockSettingRepo() + cache := &fakeLeaderLockCache{} + services := []*BackupService{ + newTestBackupService(repo, &mockDumper{}, newMockObjectStore()), + newTestBackupService(repo, &mockDumper{}, newMockObjectStore()), + } + for _, svc := range services { + svc.SetLeaderLock(cache, nil) + } + var wg sync.WaitGroup + errors := make(chan error, 24) + for i := range 24 { + wg.Add(1) + go func() { + defer wg.Done() + errors <- services[i%2].saveRecord(context.Background(), archiveRecord(fmt.Sprint(i), "2026-09-01T00:00:00Z")) + }() + } + wg.Wait() + close(errors) + for err := range errors { + require.NoError(t, err) + } + records, err := services[0].ListBackups(context.Background()) + require.NoError(t, err) + require.Len(t, records, 24) +} + +func TestBackupRetention_RecordLockWithSingleConnectionPool(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + db.SetMaxOpenConns(1) + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + svc.SetLeaderLock(&fakeLeaderLockCache{}, db) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + record := archiveRecord("single-connection", "2026-09-01T00:00:00Z") + require.NoError(t, svc.saveRecord(ctx, record), "Redis locking must not occupy a database connection") + require.NoError(t, mock.ExpectationsWereMet()) + svc.SetLeaderLock(nil, db) + require.ErrorContains(t, svc.saveRecord(ctx, record), "at least two database connections") +} diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index aea9aeeb8c9d..6e1d10ea744e 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -30,7 +30,6 @@ const ( settingKeyBackupSchedule = "backup_schedule" settingKeyBackupRecords = "backup_records" - maxBackupRecords = 100 backupObjectCleanupTimeout = 2 * time.Minute // backupScheduledLeaderLockKey gates the scheduled full-database backup so @@ -50,12 +49,13 @@ const ( ) var ( - ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured") - ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found") - ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress") - ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress") - ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted") - ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted") + ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured") + ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found") + ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress") + ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress") + ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted") + ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted") + ErrBackupArchiveProtected = infraerrors.Conflict("BACKUP_ARCHIVE_PROTECTED", "explicit confirmation is required to delete an archived backup") // ErrSecretEncryptionKeyNotConfigured is returned when an S3 SecretAccessKey // would be encrypted with an auto-generated (ephemeral) key. That key is @@ -111,30 +111,33 @@ func (c *BackupS3Config) IsConfigured() bool { // BackupScheduleConfig 定时备份配置 type BackupScheduleConfig struct { - Enabled bool `json:"enabled"` - CronExpr string `json:"cron_expr"` // cron 表达式,如 "0 2 * * *" 每天凌晨2点 - RetainDays int `json:"retain_days"` // 备份文件过期天数,默认14,0=不自动清理 - RetainCount int `json:"retain_count"` // 最多保留份数,0=不限制 + Enabled bool `json:"enabled"` + CronExpr string `json:"cron_expr"` // cron 表达式,如 "0 2 * * *" 每天凌晨2点 + RetainDays int `json:"retain_days"` // 备份文件过期天数,默认14,0=不自动清理 + RetainCount int `json:"retain_count"` // 最多保留份数,0=不限制 + MonthlyArchive *BackupMonthlyArchiveConfig `json:"monthly_archive,omitempty"` } // BackupRecord 备份记录 type BackupRecord struct { - ID string `json:"id"` - Status string `json:"status"` // pending, running, completed, failed - BackupType string `json:"backup_type"` // postgres - FileName string `json:"file_name"` - S3Key string `json:"s3_key"` - Parts []BackupPart `json:"parts,omitempty"` - SizeBytes int64 `json:"size_bytes"` - TriggeredBy string `json:"triggered_by"` // manual, scheduled - ErrorMsg string `json:"error_message,omitempty"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 - Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" - RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" - RestoreError string `json:"restore_error,omitempty"` - RestoredAt string `json:"restored_at,omitempty"` + ID string `json:"id"` + Status string `json:"status"` // pending, running, completed, failed + BackupType string `json:"backup_type"` // postgres + FileName string `json:"file_name"` + S3Key string `json:"s3_key"` + Parts []BackupPart `json:"parts,omitempty"` + SizeBytes int64 `json:"size_bytes"` + TriggeredBy string `json:"triggered_by"` // manual, scheduled + ErrorMsg string `json:"error_message,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" + RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" + RestoreStartedAt string `json:"restore_started_at,omitempty"` + RestoreError string `json:"restore_error,omitempty"` + RestoredAt string `json:"restored_at,omitempty"` + MonthlyArchive *BackupMonthlyArchive `json:"monthly_archive,omitempty"` } // BackupDownloadPart 描述一个可下载的备份分卷。 @@ -228,6 +231,9 @@ func (s *BackupService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) { // Start 启动定时备份调度器并清理孤立记录 func (s *BackupService) Start() { s.cronSched = cron.New() + // A recently interrupted job may still belong to a live peer. Revisit it + // after the maximum execution window rather than deleting it at startup. + _, _ = s.cronSched.AddFunc("@every 5m", s.recoverStaleRecords) s.cronSched.Start() // 清理重启后孤立的 running 记录 @@ -248,53 +254,78 @@ func (s *BackupService) Start() { } } -// recoverStaleRecords 启动时将孤立的 running 记录标记为 failed,并清理已上传对象。 +// recoverStaleRecords expires interrupted operations only after their maximum +// runtime. Re-read, status persistence and object deletion share the writer lock. func (s *BackupService) recoverStaleRecords() { - loadCtx, loadCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer loadCancel() - - records, err := s.loadRecords(loadCtx) + ctx, cancel := context.WithTimeout(s.bgCtx, 5*time.Minute) + defer cancel() + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return + } + defer release() + records, err := s.loadRecordsLocked(ctx) if err != nil { return } + now := time.Now() + changed := false + var stale []int for i := range records { - if records[i].Status == "running" { - staleRecord := records[i] + if records[i].Status == "running" && records[i].MonthlyArchive == nil && backupOperationExpired(records[i].StartedAt, now) { records[i].Status = "failed" records[i].ErrorMsg = "interrupted by server restart" records[i].Progress = "" - records[i].FinishedAt = time.Now().Format(time.RFC3339) - s.saveRecoveredRecord(&records[i]) - - if cleanupErr := s.cleanupStaleBackupObjects(&staleRecord); cleanupErr != nil { - records[i].ErrorMsg = fmt.Sprintf("interrupted by server restart; cleanup failed, manual deletion may be required: %v", cleanupErr) - s.saveRecoveredRecord(&records[i]) - logger.LegacyPrintf("service.backup", "[Backup] failed to clean stale backup objects for %s: %v", records[i].ID, cleanupErr) - } - logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID) + records[i].FinishedAt = now.Format(time.RFC3339) + changed = true + stale = append(stale, i) } if records[i].RestoreStatus == "running" { + if records[i].RestoreStartedAt == "" { + // Legacy restores have no operation timestamp. Give them a full + // grace period; the backup's creation date says nothing about it. + records[i].RestoreStartedAt = now.Format(time.RFC3339) + changed = true + continue + } + if !backupOperationExpired(records[i].RestoreStartedAt, now) { + continue + } records[i].RestoreStatus = "failed" records[i].RestoreError = "interrupted by server restart" - s.saveRecoveredRecord(&records[i]) - logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID) + changed = true + } + } + if !changed { + return + } + if err := s.saveRecordsLocked(ctx, records); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存中断记录失败: %v", err) + return // Never delete objects if the state transition did not commit. + } + for _, i := range stale { + if cleanupErr := s.cleanupStaleBackupObjects(ctx, &records[i]); cleanupErr != nil { + records[i].ErrorMsg = fmt.Sprintf("interrupted by server restart; cleanup failed, manual deletion may be required: %v", cleanupErr) + logger.LegacyPrintf("service.backup", "[Backup] failed to clean stale backup objects for %s: %v", records[i].ID, cleanupErr) + } + } + if len(stale) > 0 { + if err := s.saveRecordsLocked(ctx, records); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存中断清理结果失败: %v", err) } } } -func (s *BackupService) saveRecoveredRecord(record *BackupRecord) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := s.saveRecord(ctx, record); err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 保存恢复后的备份记录失败 %s: %v", record.ID, err) - } +func backupOperationExpired(started string, now time.Time) bool { + at, err := time.Parse(time.RFC3339, started) + return err == nil && now.Sub(at) > backupScheduledLeaderLockTTL } -func (s *BackupService) cleanupStaleBackupObjects(record *BackupRecord) error { +func (s *BackupService) cleanupStaleBackupObjects(ctx context.Context, record *BackupRecord) error { if len(backupObjectKeys(record)) == 0 { return nil } - ctx, cancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout) + ctx, cancel := context.WithTimeout(ctx, backupObjectCleanupTimeout) defer cancel() return s.deleteBackupObjects(ctx, record) } @@ -303,15 +334,19 @@ func (s *BackupService) cleanupStaleBackupObjects(record *BackupRecord) error { func (s *BackupService) Stop() { s.shuttingDown.Store(true) + var cronStopped context.Context s.cronMu.Lock() if s.cronSched != nil { - s.cronSched.Stop() + cronStopped = s.cronSched.Stop() } s.cronMu.Unlock() // 等待活跃备份/恢复完成(最多 5 分钟) done := make(chan struct{}) go func() { + if cronStopped != nil { + <-cronStopped.Done() + } s.wg.Wait() close(done) }() @@ -419,17 +454,23 @@ func (s *BackupService) TestS3Connection(ctx context.Context, cfg BackupS3Config func (s *BackupService) GetSchedule(ctx context.Context) (*BackupScheduleConfig, error) { raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupSchedule) - if err != nil || raw == "" { - return &BackupScheduleConfig{}, nil + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return nil, fmt.Errorf("load backup schedule: %w", err) + } + if raw == "" { + return &BackupScheduleConfig{RetainDays: 14, RetainCount: 10}, nil } var cfg BackupScheduleConfig if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - return &BackupScheduleConfig{}, nil + return nil, infraerrors.InternalServer("BACKUP_SCHEDULE_CORRUPT", "backup schedule data is corrupted") } return &cfg, nil } func (s *BackupService) UpdateSchedule(ctx context.Context, cfg BackupScheduleConfig) (*BackupScheduleConfig, error) { + if err := validateBackupRetention(&cfg); err != nil { + return nil, err + } if cfg.Enabled && cfg.CronExpr == "" { return nil, infraerrors.BadRequest("INVALID_CRON", "cron expression is required when schedule is enabled") } @@ -514,14 +555,18 @@ func (s *BackupService) runScheduledBackup() { defer release() // 读取定时备份配置中的过期天数 - schedule, _ := s.GetSchedule(ctx) + schedule, err := s.GetSchedule(ctx) + if err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 加载备份配置失败: %v", err) + return + } expireDays := 14 // 默认14天过期 - if schedule != nil && schedule.RetainDays > 0 { + if schedule != nil { expireDays = schedule.RetainDays } logger.LegacyPrintf("service.backup", "[Backup] 开始执行定时备份, 过期天数: %d", expireDays) - record, err := s.CreateBackup(ctx, "scheduled", expireDays) + record, err := s.createBackup(ctx, "scheduled", expireDays, schedule) if err != nil { if errors.Is(err, ErrBackupInProgress) { logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 已有备份正在进行中") @@ -546,6 +591,10 @@ func (s *BackupService) runScheduledBackup() { // CreateBackup 创建全量数据库备份并上传到 S3。 // expireDays: 备份过期天数,0=永不过期,默认14天 func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { + return s.createBackup(ctx, triggeredBy, expireDays, nil) +} + +func (s *BackupService) createBackup(ctx context.Context, triggeredBy string, expireDays int, schedule *BackupScheduleConfig) (*BackupRecord, error) { if s.shuttingDown.Load() { return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") } @@ -578,7 +627,7 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex now := time.Now() backupID := uuid.New().String()[:8] - fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405")) + fileName := fmt.Sprintf("%s_%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"), backupID) s3Key := s.buildS3Key(s3Cfg, fileName) var expiresAt string @@ -620,8 +669,8 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex record.Status = "completed" record.FinishedAt = time.Now().Format(time.RFC3339) - if err := s.saveRecord(ctx, record); err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err) + if err := s.saveRecordWithArchive(ctx, record, schedule); err != nil { + return record, fmt.Errorf("save completed backup: %w", err) } return record, nil @@ -667,7 +716,7 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp now := time.Now() backupID := uuid.New().String()[:8] - fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405")) + fileName := fmt.Sprintf("%s_%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"), backupID) s3Key := s.buildS3Key(s3Cfg, fileName) var expiresAt string @@ -966,8 +1015,12 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac return nil, fmt.Errorf("init object store: %w", err) } - record.RestoreStatus = "running" - _ = s.saveRecord(ctx, record) + // Re-read and reserve under the same lock used by cleanup/deletion. A prior + // read above may have become stale while S3 configuration was being loaded. + record, err = s.beginBackupRestore(ctx, backupID) + if err != nil { + return nil, err + } launched = true result := *record @@ -985,7 +1038,7 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r) record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("internal panic: %v", r) - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) } }() s.executeRestore(record, objectStore) @@ -1004,19 +1057,19 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err != nil { record.RestoreStatus = "failed" record.RestoreError = err.Error() - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) return } defer func() { _ = cleanupBackupFiles(archivePath) }() if err := s.restoreArchive(ctx, archivePath); err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("pg restore: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) return } record.RestoreStatus = "completed" record.RestoredAt = time.Now().Format(time.RFC3339) - if err := s.saveRecord(context.Background(), record); err != nil { + if err := s.saveRestoreRecord(context.Background(), record); err != nil { logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) } return @@ -1026,7 +1079,7 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("S3 download failed: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) return } defer func() { _ = body.Close() }() @@ -1035,7 +1088,7 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("gzip reader: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) return } defer func() { _ = gzReader.Close() }() @@ -1043,13 +1096,13 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err := s.dumper.Restore(ctx, gzReader); err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("pg restore: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.saveRestoreRecord(context.Background(), record) return } record.RestoreStatus = "completed" record.RestoredAt = time.Now().Format(time.RFC3339) - if err := s.saveRecord(context.Background(), record); err != nil { + if err := s.saveRestoreRecord(context.Background(), record); err != nil { logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) } } @@ -1136,7 +1189,7 @@ func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) } // 倒序返回(最新在前) sort.Slice(records, func(i, j int) bool { - return records[i].StartedAt > records[j].StartedAt + return backupStartedAfter(records[i], records[j]) }) return records, nil } @@ -1155,8 +1208,20 @@ func (s *BackupService) GetBackupRecord(ctx context.Context, backupID string) (* } func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error { - s.recordsMu.Lock() - defer s.recordsMu.Unlock() + return s.deleteBackup(ctx, backupID, false) +} + +// DeleteArchivedBackup requires the caller to explicitly confirm archive removal. +func (s *BackupService) DeleteArchivedBackup(ctx context.Context, backupID string) error { + return s.deleteBackup(ctx, backupID, true) +} + +func (s *BackupService) deleteBackup(ctx context.Context, backupID string, deleteArchived bool) error { + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return err + } + defer release() records, err := s.loadRecordsLocked(ctx) if err != nil { @@ -1179,6 +1244,12 @@ func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error // 后台上传仍可能依赖 Parts 计划;删除对象会让随后完成的记录引用失效卷。 return ErrBackupInProgress } + if found.MonthlyArchive != nil && !deleteArchived { + return ErrBackupArchiveProtected + } + if found.RestoreStatus == "running" { + return ErrRestoreInProgress + } // 从对象存储删除所有单文件或分卷对象。删除不完整时保留记录,便于重试。 if err := s.deleteBackupObjects(ctx, found); err != nil { @@ -1301,7 +1372,10 @@ func (s *BackupService) loadRecords(ctx context.Context) ([]BackupRecord, error) // loadRecordsLocked 在已持有 recordsMu 锁的情况下加载记录 func (s *BackupService) loadRecordsLocked(ctx context.Context) ([]BackupRecord, error) { raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupRecords) - if err != nil || raw == "" { + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return nil, fmt.Errorf("load backup records: %w", err) + } + if raw == "" { return nil, nil //nolint:nilnil // no records is a valid state } var records []BackupRecord @@ -1322,27 +1396,64 @@ func (s *BackupService) saveRecordsLocked(ctx context.Context, records []BackupR // saveRecord 保存单条记录(带互斥锁保护) func (s *BackupService) saveRecord(ctx context.Context, record *BackupRecord) error { - s.recordsMu.Lock() - defer s.recordsMu.Unlock() + return s.saveRecordWithArchive(ctx, record, nil) +} + +func (s *BackupService) saveRecordWithArchive(ctx context.Context, record *BackupRecord, schedule *BackupScheduleConfig) error { + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return err + } + defer release() - records, _ := s.loadRecordsLocked(ctx) + records, err := s.loadRecordsLocked(ctx) + if err != nil { + return err + } + snapshot := *record + for _, existing := range records { + if existing.ID == record.ID && existing.MonthlyArchive != nil { + // Preserve durable protection when a progress/restore writer or retry + // holds an older record snapshot. + snapshot.MonthlyArchive = existing.MonthlyArchive + snapshot.ExpiresAt = "" + break + } + } + checkpoint, err := s.assignMonthlyArchive(ctx, &snapshot, schedule) + if err != nil { + return err + } // 更新已有记录或追加 found := false for i := range records { if records[i].ID == record.ID { - records[i] = *record + records[i] = snapshot found = true break } } if !found { - records = append(records, *record) + records = append(records, snapshot) } - // 限制记录数量 - if len(records) > maxBackupRecords { - records = records[len(records)-maxBackupRecords:] + // Never discard metadata for retained objects, including permanent archives. + // The checkpoint and completed record must commit together so retries cannot + // consume a date without an archive, or archive the same date repeatedly. + if checkpoint != "" { + data, err := json.Marshal(records) + if err != nil { + return err + } + if err := s.settingRepo.SetMultiple(ctx, map[string]string{ + settingKeyBackupRecords: string(data), + settingKeyBackupArchiveCheckpoint: checkpoint, + }); err != nil { + return err + } + *record = snapshot + return nil } return s.saveRecordsLocked(ctx, records) @@ -1353,8 +1464,11 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS return nil } - s.recordsMu.Lock() - defer s.recordsMu.Unlock() + ctx, release, err := s.lockBackupRecordUpdates(ctx) + if err != nil { + return err + } + defer release() records, err := s.loadRecordsLocked(ctx) if err != nil { @@ -1363,29 +1477,49 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS // 按时间倒序 sort.Slice(records, func(i, j int) bool { - return records[i].StartedAt > records[j].StartedAt + return backupStartedAfter(records[i], records[j]) }) var toDelete []BackupRecord var toKeep []BackupRecord - for i, r := range records { + ordinaryCount, archiveCount := 0, 0 + metadataChanged := false + for _, r := range records { shouldDelete := false - - // 按保留份数清理 - if schedule.RetainCount > 0 && i >= schedule.RetainCount { - shouldDelete = true + if r.Status != "completed" { + toKeep = append(toKeep, r) + continue } + if r.MonthlyArchive != nil { + if r.MonthlyArchive.RetainCount > 0 { + archiveCount++ + // A new finite limit applies to the finite archive pool. Permanent + // archives remain protected even after the configuration changes. + if cfg := schedule.MonthlyArchive; cfg != nil && cfg.RetainCount > 0 && cfg.RetainCount != r.MonthlyArchive.RetainCount { + r.MonthlyArchive.RetainCount = cfg.RetainCount + metadataChanged = true + } + shouldDelete = archiveCount > r.MonthlyArchive.RetainCount + } + } else { + ordinaryCount++ - // 按保留天数清理 - if schedule.RetainDays > 0 && r.StartedAt != "" { - startedAt, err := time.Parse(time.RFC3339, r.StartedAt) - if err == nil && time.Since(startedAt) > time.Duration(schedule.RetainDays)*24*time.Hour { + // 按保留份数清理 + if schedule.RetainCount > 0 && ordinaryCount > schedule.RetainCount { shouldDelete = true } + + // 按保留天数清理 + if schedule.RetainDays > 0 && r.StartedAt != "" { + startedAt, err := time.Parse(time.RFC3339, r.StartedAt) + if err == nil && time.Now().After(startedAt.AddDate(0, 0, schedule.RetainDays)) { + shouldDelete = true + } + } } - if shouldDelete && r.Status == "completed" { + if shouldDelete && r.RestoreStatus != "running" { toDelete = append(toDelete, r) } else { toKeep = append(toKeep, r) @@ -1404,7 +1538,7 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS deletedCount++ } - if len(toDelete) > 0 { + if len(toDelete) > 0 || metadataChanged { if err := s.saveRecordsLocked(ctx, toKeep); err != nil { cleanupErrs = append(cleanupErrs, fmt.Errorf("save backup records after cleanup: %w", err)) } diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index 876e8243d4d2..e6d5b4f634ab 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -1146,10 +1146,11 @@ func TestRecoverStaleRecords(t *testing.T) { }) // 模拟一条孤立的恢复中记录 _ = svc.saveRecord(context.Background(), &BackupRecord{ - ID: "stale-2", - Status: "completed", - RestoreStatus: "running", - StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + ID: "stale-2", + Status: "completed", + RestoreStatus: "running", + RestoreStartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), }) svc.recoverStaleRecords() diff --git a/frontend/src/api/admin/backup.ts b/frontend/src/api/admin/backup.ts index 25a6e5bd9cf1..7f4b89f6db96 100644 --- a/frontend/src/api/admin/backup.ts +++ b/frontend/src/api/admin/backup.ts @@ -15,6 +15,19 @@ export interface BackupScheduleConfig { cron_expr: string retain_days: number retain_count: number + monthly_archive?: BackupMonthlyArchiveConfig +} + +export interface BackupMonthlyArchiveConfig { + enabled: boolean + days: number[] + include_month_end: boolean + retain_count: number // 0 = permanent +} + +export interface BackupMonthlyArchive { + dates: string[] + retain_count: number } export interface BackupRecord { @@ -32,8 +45,10 @@ export interface BackupRecord { expires_at?: string progress?: string restore_status?: string + restore_started_at?: string restore_error?: string restored_at?: string + monthly_archive?: BackupMonthlyArchive } export interface BackupPart { @@ -152,8 +167,8 @@ export async function getBackup(id: string): Promise { return data } -export async function deleteBackup(id: string): Promise { - await apiClient.delete(`/admin/backups/${id}`) +export async function deleteBackup(id: string, deleteArchived = false): Promise { + await apiClient.delete(`/admin/backups/${id}`, deleteArchived ? { params: { delete_archived: true } } : undefined) } export async function getDownloadURL(id: string): Promise { diff --git a/frontend/src/components/admin/BackupArchiveSettings.vue b/frontend/src/components/admin/BackupArchiveSettings.vue new file mode 100644 index 000000000000..f1a1b1e30fc1 --- /dev/null +++ b/frontend/src/components/admin/BackupArchiveSettings.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 725cdada283d..3df37d96b100 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -122,11 +122,43 @@ export default { cronExpr: 'Cron Expression', cronHint: 'e.g. "0 2 * * *" means every day at 2:00 AM', retainDays: 'Backup Expire Days', - retainDaysHint: 'Backup files auto-delete after this many days, 0 = never expire', + retainDaysHint: 'Ordinary backups are deleted after this many days; 0 = no age limit', retainCount: 'Max Retain Count', - retainCountHint: 'Maximum number of backups to keep, 0 = unlimited', + retainCountHint: 'Maximum ordinary backups to keep; 0 = no count limit', + ordinaryRetention: 'Ordinary backup retention', + ordinaryHint: 'Clean up the oldest ordinary backups when either limit is reached. Monthly archives are retained separately.', + preview: 'Retention preview', + previewBoth: 'Keep up to {count} ordinary backups from the last {days} days.', + previewDays: 'Keep ordinary backups from the last {days} days, with no count limit.', + previewCount: 'Keep the latest {count} ordinary backups, with no age limit.', + previewUnlimited: 'Ordinary backups are not automatically deleted.', saved: 'Schedule configuration saved' }, + archive: { + title: 'Monthly archives', + enabled: 'Enable', + dates: 'Archive dates (select multiple)', + selectDates: 'Select at least one archive date', + selectedDates: '{count} dates selected', + day: 'Day {day}', + monthEnd: 'Month end', + done: 'Done', + datesHint: 'Archive the first successful scheduled backup for each selected date, using its start date and the schedule time zone.', + retention: 'Archive retention', + count: 'Number of archives to keep', + copies: 'copies', + forever: 'Keep forever', + foreverHint: 'Keep all new archives permanently. Existing permanent archives remain protected after settings change.', + countHint: 'Count non-permanent archives across all selected dates together; delete the oldest when the limit is exceeded.', + fallbackHint: 'If no backup succeeds on a selected date, use the next successful backup in that month. Missing dates use month end. Count each backup only once.', + independentHint: 'Archives reuse scheduled backups and do not count toward ordinary retention. No archive is created if the month has no later successful backup.', + disabledHint: 'Disabling stops new archives. Existing archives keep their previous retention policy.', + invalidRetention: 'Retention days and counts must be non-negative integers. Non-permanent archives require at least 1 copy.', + preview: 'Archive one backup on {dates} each month: {retention}.', + retainLatest: 'keep the latest {count} archives in total', + badge: 'Monthly archive', + deleteConfirm: 'This is a monthly archive. Remove archive protection and permanently delete this backup? This cannot be undone.', + }, operations: { title: 'Backup Records', description: 'Create manual backups and manage existing backup records', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b3b3e706d264..2f5b44f49561 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -122,11 +122,43 @@ export default { cronExpr: 'Cron 表达式', cronHint: '例如 "0 2 * * *" 表示每天凌晨 2 点', retainDays: '备份过期天数', - retainDaysHint: '备份文件超过此天数后自动删除,0 = 永不过期', + retainDaysHint: '普通备份超过此天数后自动删除,0 = 不按天数清理', retainCount: '最大保留份数', - retainCountHint: '最多保留的备份数量,0 = 不限制', + retainCountHint: '最多保留的普通备份数量,0 = 不按份数清理', + ordinaryRetention: '普通备份保留', + ordinaryHint: '达到任一清理条件时,清理最旧的普通备份;月度归档独立保留。', + preview: '保留效果', + previewBoth: '最近 {days} 天内最多保留 {count} 份普通备份。', + previewDays: '保留最近 {days} 天的普通备份,份数不限。', + previewCount: '保留最新 {count} 份普通备份,天数不限。', + previewUnlimited: '普通备份不自动清理。', saved: '定时备份配置已保存' }, + archive: { + title: '月度归档', + enabled: '启用', + dates: '归档日期(可多选)', + selectDates: '请至少选择一个归档日期', + selectedDates: '已选择 {count} 个日期', + day: '{day} 日', + monthEnd: '月末', + done: '完成', + datesHint: '按备份计划的时区与备份开始日期匹配,每个日期归档首份成功的定时备份。', + retention: '归档保留方式', + count: '归档保留数量', + copies: '份', + forever: '永久保留', + foreverHint: '所有新归档均永久保留;已有永久归档不会因配置变更被自动删除。', + countHint: '所有选定日期的非永久归档合计计数,超出时清理最旧归档。', + fallbackHint: '当日无成功备份时,顺延至本月下次成功备份;当月没有所选日期时按月末处理,同一份备份只计一份。', + independentHint: '归档不额外执行备份,不占普通备份名额;本月无后续成功备份则不归档。', + disabledHint: '关闭后停止产生新归档,已有归档继续按原保留方式管理。', + invalidRetention: '保留天数和份数须为非负整数;非永久归档须填写至少 1 份。', + preview: '每月 {dates} 各归档一份,{retention}。', + retainLatest: '合计保留最近 {count} 份', + badge: '月度归档', + deleteConfirm: '这是月度归档备份。确定要解除归档保护并永久删除此备份吗?此操作无法撤销。', + }, operations: { title: '备份记录', description: '创建手动备份和管理已有备份记录', diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 740cd440e1de..3c2d92058d11 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -130,7 +130,7 @@
-
+

{{ t('admin.backup.schedule.title') }} @@ -149,19 +149,27 @@

{{ t('admin.backup.schedule.cronHint') }}

+

{{ t('admin.backup.schedule.ordinaryRetention') }}

- +

{{ t('admin.backup.schedule.retainDaysHint') }}

- +

{{ t('admin.backup.schedule.retainCountHint') }}

+

{{ t('admin.backup.schedule.ordinaryHint') }}

+ + +
+

{{ t('admin.backup.schedule.preview') }}

+

{{ retentionPreview }}

+
-
@@ -220,11 +228,17 @@ : t(`admin.backup.status.${record.status}`) }} - {{ record.file_name }} + + {{ record.file_name }} + {{ t('admin.backup.archive.badge') }} +
{{ record.monthly_archive.dates.join(' / ') }}
+ {{ formatSize(record.size_bytes) }} {{ record.parts?.length || (record.status === 'running' ? '-' : 1) }} - {{ record.expires_at ? formatDate(record.expires_at) : t('admin.backup.neverExpire') }} + {{ record.monthly_archive + ? record.monthly_archive.retain_count === 0 ? t('admin.backup.archive.forever') : t('admin.backup.archive.retainLatest', { count: record.monthly_archive.retain_count }) + : record.expires_at ? formatDate(record.expires_at) : t('admin.backup.neverExpire') }} {{ record.triggered_by === 'scheduled' ? t('admin.backup.trigger.scheduled') : t('admin.backup.trigger.manual') }} @@ -407,12 +421,14 @@ import { useAppStore } from '@/stores' import type { BackupS3Config, BackupScheduleConfig, + BackupMonthlyArchiveConfig, BackupRecord, BackupDownloadPart, ImageStorageConfig, } from '@/api/admin/backup' import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp' import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue' +import BackupArchiveSettings from '@/components/admin/BackupArchiveSettings.vue' const { t } = useI18n() const appStore = useAppStore() @@ -471,6 +487,31 @@ const scheduleForm = ref({ retain_count: 10, }) const savingSchedule = ref(false) +const archiveForm = ref({ enabled: false, days: [1], include_month_end: false, retain_count: 0 }) +const scheduleValidationError = computed(() => { + if (![scheduleForm.value.retain_days, scheduleForm.value.retain_count, archiveForm.value.retain_count].every(value => Number.isSafeInteger(value) && value >= 0)) { + return t('admin.backup.archive.invalidRetention') + } + if (archiveForm.value.enabled && !archiveForm.value.days.length && !archiveForm.value.include_month_end) { + return t('admin.backup.archive.selectDates') + } + return '' +}) +const retentionPreview = computed(() => { + if (scheduleValidationError.value) return scheduleValidationError.value + const { retain_days: days, retain_count: count } = scheduleForm.value + const ordinary = days > 0 && count > 0 + ? t('admin.backup.schedule.previewBoth', { days, count }) + : days > 0 ? t('admin.backup.schedule.previewDays', { days }) + : count > 0 ? t('admin.backup.schedule.previewCount', { count }) : t('admin.backup.schedule.previewUnlimited') + if (!archiveForm.value.enabled) return ordinary + const dates = [ + ...[...archiveForm.value.days].sort((a, b) => a - b).map(day => t('admin.backup.archive.day', { day })), + ...(archiveForm.value.include_month_end ? [t('admin.backup.archive.monthEnd')] : []), + ].join(', ') + const retention = archiveForm.value.retain_count === 0 ? t('admin.backup.archive.forever') : t('admin.backup.archive.retainLatest', { count: archiveForm.value.retain_count }) + return `${ordinary} ${t('admin.backup.archive.preview', { dates, retention })}` +}) // Backups const backups = ref([]) @@ -703,8 +744,14 @@ async function loadSchedule() { scheduleForm.value = { enabled: cfg.enabled, cron_expr: cfg.cron_expr || '0 2 * * *', - retain_days: cfg.retain_days || 14, - retain_count: cfg.retain_count || 10, + retain_days: cfg.retain_days ?? 14, + retain_count: cfg.retain_count ?? 10, + } + archiveForm.value = { + enabled: cfg.monthly_archive?.enabled ?? false, + days: cfg.monthly_archive?.days ?? [1], + include_month_end: cfg.monthly_archive?.include_month_end ?? false, + retain_count: cfg.monthly_archive?.retain_count ?? 0, } } catch (error) { appStore.showError((error as { message?: string })?.message || t('errors.networkError')) @@ -712,9 +759,10 @@ async function loadSchedule() { } async function saveSchedule() { + if (scheduleValidationError.value) return savingSchedule.value = true try { - await adminAPI.backup.updateSchedule(scheduleForm.value) + await adminAPI.backup.updateSchedule({ ...scheduleForm.value, monthly_archive: { ...archiveForm.value, days: [...archiveForm.value.days] } }) appStore.showSuccess(t('admin.backup.schedule.saved')) } catch (error) { appStore.showError((error as { message?: string })?.message || t('errors.networkError')) @@ -812,9 +860,10 @@ async function restoreBackup(id: string) { } async function removeBackup(id: string) { - if (!window.confirm(t('admin.backup.actions.deleteConfirm'))) return + const archived = !!backups.value.find(record => record.id === id)?.monthly_archive + if (!window.confirm(t(archived ? 'admin.backup.archive.deleteConfirm' : 'admin.backup.actions.deleteConfirm'))) return try { - await adminAPI.backup.deleteBackup(id) + await adminAPI.backup.deleteBackup(id, archived) appStore.showSuccess(t('admin.backup.actions.deleted')) await loadBackups() } catch (error) { diff --git a/frontend/src/views/admin/__tests__/BackupView.spec.ts b/frontend/src/views/admin/__tests__/BackupView.spec.ts index d7a257a7ae5a..62ba71713b98 100644 --- a/frontend/src/views/admin/__tests__/BackupView.spec.ts +++ b/frontend/src/views/admin/__tests__/BackupView.spec.ts @@ -7,12 +7,16 @@ const { getS3Config, getImageStorageConfig, getSchedule, + updateSchedule, + deleteBackup, listBackups, getDownloadURL, } = vi.hoisted(() => ({ getS3Config: vi.fn(), getImageStorageConfig: vi.fn(), getSchedule: vi.fn(), + updateSchedule: vi.fn(), + deleteBackup: vi.fn(), listBackups: vi.fn(), getDownloadURL: vi.fn(), })) @@ -27,11 +31,11 @@ vi.mock('@/api', () => ({ updateImageStorageConfig: vi.fn(), testImageStorageConnection: vi.fn(), getSchedule, - updateSchedule: vi.fn(), + updateSchedule, createBackup: vi.fn(), listBackups, getBackup: vi.fn(), - deleteBackup: vi.fn(), + deleteBackup, getDownloadURL, restoreBackup: vi.fn(), }, @@ -56,7 +60,7 @@ vi.mock('@/composables/useStepUp', () => ({ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => - params?.index === undefined ? key : `${key}:${params.index}`, + params?.index !== undefined ? `${key}:${params.index}` : params?.day !== undefined ? `${key}:${params.day}` : key, }), })) @@ -72,26 +76,34 @@ const baseRecord = (id: string, parts?: unknown[]) => ({ started_at: '2026-08-09T00:00:00Z', }) +const wrappers: ReturnType[] = [] + function mountBackupView() { - return mount(BackupView, { + const wrapper = mount(BackupView, { global: { stubs: { TotpStepUpDialog: true, }, }, }) + wrappers.push(wrapper) + return wrapper } -describe('admin BackupView 分卷备份', () => { +describe('admin BackupView', () => { beforeEach(() => { getS3Config.mockResolvedValue({}) getImageStorageConfig.mockResolvedValue({ config: {}, secret_configured: false }) getSchedule.mockResolvedValue({ enabled: false, cron_expr: '', retain_days: 14, retain_count: 10 }) + updateSchedule.mockReset().mockResolvedValue({}) + deleteBackup.mockReset().mockResolvedValue(undefined) + listBackups.mockResolvedValue({ items: [] }) getDownloadURL.mockReset() vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) }) afterEach(() => { + wrappers.splice(0).forEach(wrapper => wrapper.unmount()) vi.restoreAllMocks() document.body.innerHTML = '' }) @@ -151,4 +163,90 @@ describe('admin BackupView 分卷备份', () => { expect(wrapper.find('tbody tr td:nth-child(5)').text()).toBe('-') expect(wrapper.findAll('button').some(button => button.text() === 'common.delete')).toBe(false) }) + + it('兼容旧配置并保留 0 值,不自动启用月度归档', async () => { + getSchedule.mockResolvedValue({ enabled: true, cron_expr: '0 4 * * *', retain_days: 0, retain_count: 0 }) + const wrapper = mountBackupView() + await flushPromises() + expect((wrapper.get('[data-testid="backup-retain-days"]').element as HTMLInputElement).value).toBe('0') + expect((wrapper.get('[data-testid="backup-retain-count"]').element as HTMLInputElement).value).toBe('0') + expect((wrapper.get('[data-testid="archive-enabled"]').element as HTMLInputElement).checked).toBe(false) + await wrapper.get('[data-testid="backup-schedule"] .btn-primary').trigger('click') + await flushPromises() + expect(updateSchedule).toHaveBeenCalledWith(expect.objectContaining({ retain_days: 0, retain_count: 0, monthly_archive: expect.objectContaining({ enabled: false }) })) + }) + + it('多选日期,手填份数;永久保留隐藏数量并在取消后恢复', async () => { + const wrapper = mountBackupView() + await flushPromises() + await wrapper.get('[data-testid="archive-enabled"]').setValue(true) + await wrapper.get('#backup-archive-dates').trigger('click') + await wrapper.get('#backup-archive-options input[value="15"]').setValue(true) + await wrapper.get('#backup-archive-options input[value="last"]').setValue(true) + expect(wrapper.get('#backup-archive-dates').attributes('aria-expanded')).toBe('true') + expect(wrapper.find('[data-testid="archive-count"]').exists()).toBe(false) + await wrapper.get('[data-testid="archive-forever"]').setValue(false) + await wrapper.get('[data-testid="archive-count"]').setValue(17) + await wrapper.get('[data-testid="archive-forever"]').setValue(true) + expect(wrapper.find('[data-testid="archive-count"]').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'BackupArchiveSettings' }).text()).not.toContain('admin.backup.archive.copies') + await wrapper.get('[data-testid="archive-forever"]').setValue(false) + expect((wrapper.get('[data-testid="archive-count"]').element as HTMLInputElement).value).toBe('17') + await wrapper.get('[data-testid="backup-schedule"] .btn-primary').trigger('click') + await flushPromises() + expect(updateSchedule).toHaveBeenLastCalledWith(expect.objectContaining({ monthly_archive: { enabled: true, days: [1, 15], include_month_end: true, retain_count: 17 } })) + await wrapper.get('[data-testid="archive-forever"]').setValue(true) + await wrapper.get('[data-testid="backup-schedule"] .btn-primary').trigger('click') + expect(updateSchedule).toHaveBeenLastCalledWith(expect.objectContaining({ monthly_archive: expect.objectContaining({ retain_count: 0 }) })) + }) + + it('空日期和非法份数不可保存,输入 0 不会误选永久保留', async () => { + const wrapper = mountBackupView() + await flushPromises() + await wrapper.get('[data-testid="archive-enabled"]').setValue(true) + await wrapper.get('#backup-archive-dates').trigger('click') + await wrapper.get('#backup-archive-options input[value="1"]').setValue(false) + const save = wrapper.get('[data-testid="backup-schedule"] .btn-primary') + expect(save.attributes('disabled')).toBeDefined() + await wrapper.get('#backup-archive-options input[value="15"]').setValue(true) + await wrapper.get('[data-testid="archive-forever"]').setValue(false) + for (const value of ['0', '-1', '1.5', '']) { + await wrapper.get('[data-testid="archive-count"]').setValue(value) + expect(save.attributes('disabled')).toBeDefined() + expect((wrapper.get('[data-testid="archive-forever"]').element as HTMLInputElement).checked).toBe(false) + } + await save.trigger('click') + expect(updateSchedule).not.toHaveBeenCalled() + }) + + it('加载归档设置并支持键盘关闭日期选择器', async () => { + getSchedule.mockResolvedValue({ enabled: true, cron_expr: '0 4 * * *', retain_days: 14, retain_count: 10, + monthly_archive: { enabled: true, days: [1, 15], include_month_end: true, retain_count: 23 } }) + const wrapper = mountBackupView() + await flushPromises() + expect((wrapper.get('[data-testid="archive-count"]').element as HTMLInputElement).value).toBe('23') + await wrapper.get('#backup-archive-dates').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + expect(wrapper.find('#backup-archive-options').exists()).toBe(true) + expect((wrapper.get('#backup-archive-options input[value="15"]').element as HTMLInputElement).checked).toBe(true) + await wrapper.get('#backup-archive-options').trigger('keydown', { key: 'Escape' }) + expect(wrapper.find('#backup-archive-options').exists()).toBe(false) + }) + + it('归档删除需要单独确认,有限归档不会显示永不过期', async () => { + listBackups.mockResolvedValue({ items: [{ ...baseRecord('archived'), monthly_archive: { dates: ['2026-09-01', '2026-09-15'], retain_count: 12 } }] }) + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) + const wrapper = mountBackupView() + await flushPromises() + expect(wrapper.text()).toContain('admin.backup.archive.badge') + expect(wrapper.get('tbody tr td:nth-child(6)').text()).toBe('admin.backup.archive.retainLatest') + const button = wrapper.findAll('button').find(button => button.text() === 'common.delete')! + await button.trigger('click') + expect(confirm).toHaveBeenCalledWith('admin.backup.archive.deleteConfirm') + expect(deleteBackup).not.toHaveBeenCalled() + confirm.mockReturnValue(true) + await button.trigger('click') + await flushPromises() + expect(deleteBackup).toHaveBeenCalledWith('archived', true) + }) }) From 94225b5ed31a7c0153715ac955237425227b55e4 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 18 Sep 2026 08:42:21 +0800 Subject: [PATCH 026/142] fix(admin): align platform quota editor with supported platforms --- frontend/src/api/admin/users.ts | 7 ++- .../admin/user/UserPlatformQuotaModal.vue | 7 +-- .../__tests__/UserPlatformQuotaModal.spec.ts | 58 ++++++++++++++----- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 738e0531c199..fae7ec6f834e 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -330,7 +330,12 @@ export async function bindUserAuthIdentity( /** * Platform quota types */ -export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' +// Keep aligned with backend/internal/service/domain_constants.go AllowedQuotaPlatforms. +export const PLATFORM_QUOTA_PLATFORMS = [ + 'anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', +] as const +export type PlatformQuotaPlatform = typeof PLATFORM_QUOTA_PLATFORMS[number] export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly' export interface PlatformQuotaItem { diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 57cb4b394def..b1c5e799dbcb 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -119,6 +119,7 @@ import { ref, reactive, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import { PLATFORM_QUOTA_PLATFORMS } from '@/api/admin/users' import type { AdminUser, PlatformQuotaItem, PlatformQuotaPlatform, PlatformQuotaWindow } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -128,8 +129,6 @@ const emit = defineEmits(['close', 'success']) const { t } = useI18n() const appStore = useAppStore() -const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] - interface QuotaRow { platform: PlatformQuotaPlatform daily_limit_usd: number | null @@ -176,7 +175,7 @@ function emptyRow(p: PlatformQuotaPlatform): QuotaRow { function normalize(items: PlatformQuotaItem[]): QuotaRow[] { const byPlatform = new Map() for (const it of items) byPlatform.set(it.platform, it) - return PLATFORMS.map((p) => { + return PLATFORM_QUOTA_PLATFORMS.map((p) => { const it = byPlatform.get(p) if (!it) return emptyRow(p) return { @@ -205,7 +204,7 @@ async function load() { savedConfigured.value = configuredPlatforms(data.platform_quotas || []) } catch { appStore.showError(t('admin.users.platformQuota.loadFailed')) - quotas.value = PLATFORMS.map(emptyRow) + quotas.value = PLATFORM_QUOTA_PLATFORMS.map(emptyRow) savedConfigured.value = new Set() } finally { loading.value = false diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index 50740b7cef7b..b102ab56c19a 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -50,7 +50,7 @@ vi.mock('@/components/common/BaseDialog.vue', () => ({ })) import UserPlatformQuotaModal from '../UserPlatformQuotaModal.vue' -import type { UserSubscription } from '@/types' +import type { PlatformQuotaUpdateItem, UserSubscription } from '@/types' function makeUser(overrides: { subscriptions?: UserSubscription[] } = {}) { return { id: 99, email: 'u@example.com', ...overrides } as any @@ -101,16 +101,48 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99) }) - it('空数据渲染 5 个 platform 行', async () => { + it('renders all ten supported platforms with empty limits', async () => { const w = await mountAndOpen() - const html = w.html() - expect(html).toContain('anthropic') - expect(html).toContain('openai') - expect(html).toContain('gemini') - expect(html).toContain('antigravity') - expect(html).toContain('grok') + const rows = w.findAll('tbody tr') + expect(rows.map(row => row.find('td').text())).toEqual([ + 'anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', + ]) + for (const row of rows) { + const inputs = row.findAll('input[type=number]') + expect(inputs).toHaveLength(3) + expect(inputs.map(input => input.element.value)).toEqual(['', '', '']) + } + w.unmount() }) + it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const)( + 'saves edits to %s without erasing existing platform limits', async (platform) => { + const existing: PlatformQuotaUpdateItem[] = [ + { platform: 'openai', daily_limit_usd: 10, weekly_limit_usd: 20, monthly_limit_usd: 100 }, + ...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const).map(p => ({ + platform: p, daily_limit_usd: 0, weekly_limit_usd: null, monthly_limit_usd: 50, + })), + ] + apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: existing }) + const w = await mountAndOpen() + const row = w.findAll('tbody tr').find(r => r.find('td').text() === platform)! + const inputs = row.findAll('input[type=number]') + expect(inputs.map(input => input.element.value)).toEqual(['0', '', '50']) + await inputs[1].setValue('12.5') + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + const expected = existing.map(item => item.platform === platform + ? { ...item, weekly_limit_usd: 12.5 } + : item) + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledWith(99, expect.arrayContaining(expected)) + expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(10) + expect(w.emitted('success')).toHaveLength(1) + w.unmount() + }, + ) + it('已有数据正确填充 limit input', async () => { apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: [ @@ -120,13 +152,13 @@ describe('UserPlatformQuotaModal', () => { }) const w = await mountAndOpen() const inputs = w.findAll('input[type=number]') - // 5 platforms × 3 windows = 15 inputs - expect(inputs.length).toBe(15) + // 10 platforms × 3 windows = 30 inputs + expect(inputs.length).toBe(30) // 第一个 input 是 anthropic.daily = 10 expect((inputs[0].element as HTMLInputElement).value).toBe('10') }) - it('保存提交完整 5 platform payload', async () => { + it('保存提交完整 10 platform payload', async () => { apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: [ { platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null, @@ -143,7 +175,7 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0] expect(uid).toBe(99) - expect(payload).toHaveLength(5) // 5 platforms always submitted + expect(payload).toHaveLength(10) // 10 platforms always submitted const openai = payload.find((p: any) => p.platform === 'openai') expect(openai.weekly_limit_usd).toBe(20) }) @@ -208,7 +240,7 @@ describe('UserPlatformQuotaModal', () => { it('未配置限额的平台重置按钮禁用并提示不可用', async () => { const w = await mountAndOpen() const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') - expect(resetBtns.length).toBe(15) // 5 平台 × 3 窗口 + expect(resetBtns.length).toBe(30) // 10 平台 × 3 窗口 for (const b of resetBtns) { expect((b.element as HTMLButtonElement).disabled).toBe(true) expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable') From c12d0131cb875030e96537480d6dc639d0bfb15e Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:31:48 +0800 Subject: [PATCH 027/142] fix(gateway): list mixed antigravity models for gemini groups --- .../internal/handler/gateway_models_test.go | 164 ++++++++++++++++++ backend/internal/service/gateway_service.go | 31 +++- 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 26c0c72ec436..81df742e9b3f 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -1315,3 +1316,166 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string { } return ids } + +// gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models, +// 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入, +// 非混合调度的异平台账号(anthropic)仍被过滤。 +func TestGatewayModels_GeminiGroupIncludesAntigravityGeminiMappings(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(22) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "gemini-synced-custom": "gemini-3.8-flash-high", + "gemini-3.8-flash-low": "gemini-3.8-flash-low", + "claude-sonnet-4-6": "claude-sonnet-4-6", + }, + }, + }, + {ID: 3, Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": false}, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-disabled-only": "gemini-3.8-flash-high"}}}, + {ID: 4, Platform: service.PlatformAntigravity, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-unset-only": "gemini-3.8-flash-high"}}}, + {ID: 5, Platform: service.PlatformGemini, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-native-only": "gemini-2.5-pro"}}}, + { + ID: 2, + Platform: service.PlatformAnthropic, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-opus-4-6": "claude-opus-4-6", + }, + }, + }, + }, + }, + }, + ) + + 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.PlatformGemini}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + ids := modelIDsForTest(got.Data) + // 账号显式配置的 gemini-* 映射必须出现(resolveModelMapping 还会补一组 antigravity + // 默认透传别名,这里只断言显式条目与排除项,不锁定补齐后的完整集合)。 + require.Contains(t, ids, "gemini-3.8-flash-high") + require.Contains(t, ids, "gemini-synced-custom") + require.Contains(t, ids, "gemini-3.8-flash-low") + require.Contains(t, ids, "gemini-native-only") + require.NotContains(t, ids, "gemini-disabled-only") + require.NotContains(t, ids, "gemini-unset-only") + // antigravity 账号的 claude-* 映射与 anthropic 账号的映射都不应出现在 gemini 分组。 + require.NotContains(t, ids, "claude-sonnet-4-6") + require.NotContains(t, ids, "claude-opus-4-6") + for _, id := range ids { + require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id) + } + // 没有回落到 geminicli 静态表。 + require.NotContains(t, ids, "gemini-2.0-flash") +} + +// antigravity 账号未配置 model_mapping 时使用 DefaultAntigravityModelMapping: +// gemini 分组应列出其中的 gemini-* 条目,而不是回落到 geminicli 静态表;claude-* 仍不出现。 +func TestGatewayModels_GeminiGroupUsesAntigravityDefaultMappingWhenUnset(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(23) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + {ID: 1, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{}}, + }, + }, + }, + ) + + 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.PlatformGemini}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "gemini-3.8-flash-high") + for _, id := range ids { + require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id) + } + // geminicli 静态表独有条目不应出现(说明没有回落到默认列表)。 + require.NotContains(t, ids, "gemini-2.0-flash") +} + +// Codex 通过 /models?client_version= 走 CodexModels,同样应看到混合调度账号的 gemini-* 映射。 +func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(24) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "gemini-synced-custom": "gemini-3.8-flash-high", + "claude-sonnet-4-6": "claude-sonnet-4-6", + }, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := codexModelSlugsForTest(got.Models) + require.Contains(t, slugs, "gemini-3.8-flash-high") + require.Contains(t, slugs, "gemini-synced-custom") + require.NotContains(t, slugs, "claude-sonnet-4-6") + require.NotContains(t, slugs, "gemini-2.0-flash") + for _, slug := range slugs { + require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 97e35fdb1b62..897979dec5fe 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1375,6 +1375,19 @@ func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account return respBytes, nil } +// mixedListingAccountAllowed mirrors the mixed-scheduling rule in +// GeminiMessagesCompatService.listSchedulableAccountsOnce: a gemini group may be +// served by antigravity accounts, so model listing must consider them too. +func mixedListingAccountAllowed(groupPlatform string, account *Account) bool { + return groupPlatform == PlatformGemini && account.IsMixedSchedulingEnabled() +} + +// mixedListingModelAllowed limits what a mixed-scheduling account may advertise +// on the group's platform: only gemini-* wire IDs are meaningful on a gemini group. +func mixedListingModelAllowed(groupPlatform, model string) bool { + return groupPlatform == PlatformGemini && isAntigravityGeminiModel(model) +} + func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { cacheKey := modelsListCacheKey(groupID, platform) if s.modelsListCache != nil { @@ -1400,11 +1413,13 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return nil } - // Filter by platform if specified + // Filter by platform if specified. Mixed scheduling (a gemini group routing + // to antigravity accounts) is honoured here as well, so the advertised list + // stays in sync with what the request path can actually serve. if platform != "" { filtered := make([]Account, 0) for _, acc := range accounts { - if acc.Platform == platform { + if acc.Platform == platform || mixedListingAccountAllowed(platform, &acc) { filtered = append(filtered, acc) } } @@ -1428,11 +1443,15 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } mapping := acc.GetModelMapping() - if len(mapping) > 0 { - hasAnyMapping = true - for model := range mapping { - modelSet[model] = struct{}{} + for model := range mapping { + // Accounts pulled in through mixed scheduling only contribute the + // models that belong to the listing platform (e.g. an antigravity + // account's claude-* mappings must not surface on a gemini group). + if platform != "" && acc.Platform != platform && !mixedListingModelAllowed(platform, model) { + continue } + modelSet[model] = struct{}{} + hasAnyMapping = true } } From cf10d6d01dc47ea7fa5ee00abf41ec71873c3e91 Mon Sep 17 00:00:00 2001 From: lizhaoxinlizhaoxin2-lab <288551507+lizhaoxinlizhaoxin2-lab@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:38:05 +0800 Subject: [PATCH 028/142] fix(responses): emit protocol-correct stream errors once --- .../handler/openai_gateway_handler_test.go | 14 +++++ .../openai_gateway_response_handling.go | 17 ++++-- .../service/openai_gateway_service_test.go | 55 +++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 29b17d165517..916063cae595 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -381,6 +381,20 @@ func TestOpenAIEnsureForwardErrorResponse_AfterDeltaAppendsSingleValidResponseFa require.Equal(t, 1, errorEvents) } +func TestOpenAIEnsureForwardErrorResponse_PreservesCommittedStreamError(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil) + body := "event: error\ndata: {\"type\":\"error\",\"code\":\"upstream_stream_read_error\",\"message\":\"Upstream response stream was interrupted\",\"param\":null,\"sequence_number\":0}\n\n" + _, err := c.Writer.WriteString(body) + require.NoError(t, err) + service.MarkResponseCommitted(c) + h := &OpenAIGatewayHandler{} + require.False(t, h.ensureForwardErrorResponse(c, true)) + require.Equal(t, body, w.Body.String(), "preserve the service error without a second terminal event") +} + func TestOpenAIEnsureForwardErrorResponse_CompactKeepaliveOnlyWritesResponseFailed(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index efb9f2e0d8d8..8956bb58a1d4 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -314,17 +314,19 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. eventStartsTTFTOutput = false eventShouldFlush = false } - sendErrorEvent := func(reason string) { + sendErrorEvent := func(code, message string) { if errorEventSent || clientDisconnected || failureDelivered { return } errorEventSent = true - payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}` + // Responses error events use top-level code/message/param fields. A nested + // Chat Completions error envelope loses the classification in strict clients. + payload := `{"type":"error","sequence_number":0,"code":` + strconv.Quote(code) + `,"message":` + strconv.Quote(message) + `,"param":null}` if err := flushBuffered(); err != nil { clientDisconnected = true return } - if _, err := writePendingString("data: " + payload + "\n\n"); err != nil { + if _, err := writePendingString("event: error\ndata: " + payload + "\n\n"); err != nil { clientDisconnected = true return } @@ -334,6 +336,8 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } clientOutputStarted = true lastDownstreamWriteAt = time.Now() + // The handler must not append its generic failure after this error event. + MarkResponseCommitted(c) } needModelReplace := originalModel != mappedModel @@ -450,7 +454,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } if errors.Is(scanErr, bufio.ErrTooLong) { logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) - sendErrorEvent("response_too_large") + sendErrorEvent("response_too_large", "Upstream response exceeded the size limit") return resultWithUsage(), scanErr, true } if !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush { @@ -465,7 +469,8 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true } s.recordOpenAIProxyStreamDisconnect(account, scanErr, upstreamRequestID) - sendErrorEvent("stream_read_error") + code, message := classifyOpenAIUpstreamStreamReadError(scanErr) + sendErrorEvent(code, message) return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true } processSSELine := func(line string, queueDrained bool) { @@ -914,7 +919,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. return resultWithUsage(), grokStreamIdleFailoverError(account, streamInterval) } } - sendErrorEvent("stream_timeout") + sendErrorEvent("stream_timeout", "Upstream response stream timed out") return resultWithUsage(), fmt.Errorf("stream data interval timeout") case <-firstOutputCh: diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index cf4611ccceb6..c3c5474eea07 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1610,6 +1610,10 @@ func TestOpenAIStreamingTimeout(t *testing.T) { if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "stream_timeout") { t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String()) } + payload := strings.TrimSpace(strings.TrimPrefix(rec.Body.String(), "event: error\ndata: ")) + require.Equal(t, "stream_timeout", gjson.Get(payload, "code").String()) + require.False(t, gjson.Get(payload, "error").Exists()) + require.True(t, IsResponseCommitted(c)) } func TestOpenAIStreamingContextCanceledReturnsIncompleteErrorWithoutInjectingErrorEvent(t *testing.T) { @@ -1674,6 +1678,53 @@ func TestOpenAIStreamingReadErrorBeforeOutputReturnsFailover(t *testing.T) { require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingReadErrorAfterOutputUsesResponsesErrorSchema(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + cause error + code string + }{ + {"reset", errors.New("read tcp 192.0.2.1:1234->192.0.2.2:443: connection reset by peer"), OpenAIUpstreamStreamReadErrorCode}, + {"http2", errors.New("stream error: stream ID 3; INTERNAL_ERROR; received from peer"), OpenAIUpstreamHTTP2StreamErrorCode}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: &openAIStreamReadThenErrorCloser{ + reader: strings.NewReader("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"), + err: tc.cause, + }} + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model") + require.ErrorIs(t, err, tc.cause) + body := rec.Body.String() + require.Contains(t, body, "partial") + require.NotContains(t, body, "192.0.2.") + require.NotContains(t, body, "stream ID") + require.NotContains(t, body, "response.completed") + require.NotContains(t, body, "[DONE]") + require.Equal(t, 1, strings.Count(body, "event: error\n")) + require.True(t, IsResponseCommitted(c), "the handler must not append another failure") + var events []gjson.Result + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "data: ") { + event := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if event.Get("type").String() == "error" { + events = append(events, event) + } + } + } + require.Len(t, events, 1) + require.Equal(t, tc.code, events[0].Get("code").String()) + require.NotEmpty(t, events[0].Get("message").String()) + require.False(t, events[0].Get("error").Exists(), "Responses errors have top-level fields") + require.True(t, events[0].Get("param").Exists()) + }) + } +} + func TestOpenAIStreamingPostOutputDisconnectQuarantinesSharedProxyWithoutSameStreamFailover(t *testing.T) { gin.SetMode(gin.TestMode) proxyID := int64(4698) @@ -2791,6 +2842,10 @@ func TestOpenAIStreamingTooLong(t *testing.T) { if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "response_too_large") { t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String()) } + payload := strings.TrimSpace(strings.TrimPrefix(rec.Body.String(), "event: error\ndata: ")) + require.Equal(t, "response_too_large", gjson.Get(payload, "code").String()) + require.False(t, gjson.Get(payload, "error").Exists()) + require.True(t, IsResponseCommitted(c)) } func TestOpenAINonStreamingContentTypePassThrough(t *testing.T) { From 3fdd54ca12433a53666f1a58de1ae6bcaa886b0f Mon Sep 17 00:00:00 2001 From: little-greenbean <62200636+little-greenbean@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:29:47 +0800 Subject: [PATCH 029/142] fix(upstream): cancel response attempt before closing body --- backend/internal/repository/http_upstream.go | 59 ++- .../http_upstream_billing_lifecycle_test.go | 148 +++++++ .../http_upstream_body_lifecycle_test.go | 377 ++++++++++++++++++ 3 files changed, 577 insertions(+), 7 deletions(-) create mode 100644 backend/internal/repository/http_upstream_billing_lifecycle_test.go create mode 100644 backend/internal/repository/http_upstream_body_lifecycle_test.go diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index e073659cc269..24df06ae0f24 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -216,7 +216,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i // 执行请求 client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) - resp, err := servertiming.Do(client, req) + resp, err := doUpstreamRequest(client, req) if err != nil { s.recordOpenAIHTTP2Failure(profile, entry.protocolMode, entry.proxyKey, err) // 请求失败,立即减少计数 @@ -226,9 +226,6 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i } s.recordOpenAIHTTP2Success(profile, entry.protocolMode, entry.proxyKey) - // 如果上游返回了压缩内容,解压后再交给业务层 - decompressResponseBody(resp) - // 包装响应体,在关闭时自动减少计数并更新时间戳 // 这确保了流式响应(如 SSE)在完全读取前不会被淘汰 resp.Body = wrapTrackedBody(resp.Body, func() { @@ -280,7 +277,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) - resp, err := servertiming.Do(client, req) + resp, err := doUpstreamRequest(client, req) if err != nil { atomic.AddInt64(&entry.inFlight, -1) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) @@ -288,8 +285,6 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return nil, err } - decompressResponseBody(resp) - resp.Body = wrapTrackedBody(resp.Body, func() { atomic.AddInt64(&entry.inFlight, -1) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) @@ -298,6 +293,56 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return resp, nil } +// doUpstreamRequest owns cancellation for one attempt, without cancelling the +// caller's context (which may be detached for billing or reused for retries). +func doUpstreamRequest(client *http.Client, req *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancel(req.Context()) + resp, err := servertiming.Do(client, req.WithContext(ctx)) + if err != nil { + cancel() + return resp, err + } + decompressResponseBody(resp) + resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} + return resp, nil +} + +type cancelOnCloseBody struct { + io.ReadCloser + cancel context.CancelFunc + once sync.Once + err error + readMu sync.Mutex + closed bool +} + +func (b *cancelOnCloseBody) Read(p []byte) (int, error) { + b.readMu.Lock() + defer b.readMu.Unlock() + if b.closed { + return 0, http.ErrBodyReadAfterClose + } + return b.ReadCloser.Read(p) +} + +func (b *cancelOnCloseBody) Close() error { + b.once.Do(func() { + // Cancel before closing, including before closing a decompressor. In Go + // 1.27 an early HTTP/1 close concurrent with Read can otherwise leave an + // EOF waiter on a reused connection and stall subsequent responses. + // Fully consumed responses have already released their transport request, + // so cancelling here preserves normal keep-alive reuse. + b.cancel() + // Wait for an active read to observe cancellation before touching the + // body or decompressor. Do not hold readMu while cancelling the request. + b.readMu.Lock() + defer b.readMu.Unlock() + b.closed = true + b.err = b.ReadCloser.Close() + }) + return b.err +} + // httpClientForUpstreamRequest 按请求上下文的标记派生客户端:禁用重定向,或对重定向的每一跳做主机校验。 // 派生的克隆与缓存客户端共享 Transport;未打标记时原样返回。 func (s *httpUpstreamService) httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client { diff --git a/backend/internal/repository/http_upstream_billing_lifecycle_test.go b/backend/internal/repository/http_upstream_billing_lifecycle_test.go new file mode 100644 index 000000000000..68186d80abf9 --- /dev/null +++ b/backend/internal/repository/http_upstream_billing_lifecycle_test.go @@ -0,0 +1,148 @@ +package repository + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type lifecycleUsageLogRepo struct { + service.UsageLogRepository + logs []*service.UsageLog +} + +func (r *lifecycleUsageLogRepo) Create(ctx context.Context, log *service.UsageLog) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + r.logs = append(r.logs, log) + return true, nil +} + +type lifecycleBillingRepo struct { + service.UsageBillingRepository + commands []*service.UsageBillingCommand +} + +func (r *lifecycleBillingRepo) Apply(ctx context.Context, cmd *service.UsageBillingCommand) (*service.UsageBillingApplyResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r.commands = append(r.commands, cmd) + return &service.UsageBillingApplyResult{Applied: true}, nil +} + +type lifecycleDisconnectedWriter struct { + gin.ResponseWriter + disconnect context.CancelFunc +} + +func (w *lifecycleDisconnectedWriter) Write([]byte) (int, error) { + w.disconnect() + return 0, errors.New("client disconnected") +} + +// Exercise the real forwarding and RecordUsage services with the real HTTP +// upstream. Only the persistence boundary is replaced: closing an attempt must +// not cancel the detached stream before its final usage reaches billing. +func TestHTTPUpstreamForwardDrainsUsageAfterClientDisconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + clientCtx, disconnect := context.WithCancel(t.Context()) + defer disconnect() + releaseUsage := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseUsage) }) } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Request-Id", "lifecycle-billing") + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n") + _ = http.NewResponseController(w).Flush() + select { + case <-releaseUsage: + case <-r.Context().Done(): + return + } + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lifecycle\",\"status\":\"completed\",\"usage\":{\"input_tokens\":17,\"output_tokens\":8,\"total_tokens\":25}}}\n\n") + })) + defer srv.Close() + defer srv.CloseClientConnections() + defer release() + + cfg := &config.Config{} + cfg.Default.RateMultiplier = 1 + cfg.Security.URLAllowlist.AllowPrivateHosts = true + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.StreamKeepaliveInterval = 1 + upstream := NewHTTPUpstream(cfg) + usageRepo := &lifecycleUsageLogRepo{} + billingRepo := &lifecycleBillingRepo{} + svc := service.NewOpenAIGatewayService( + nil, usageRepo, billingRepo, nil, nil, nil, nil, cfg, nil, nil, + service.NewBillingService(cfg, nil), nil, nil, upstream, + &service.DeferredService{}, nil, nil, nil, nil, nil, nil, nil, + ) + account := &service.Account{ + ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"base_url": srv.URL, "api_key": "test-key"}, + } + body := []byte(`{"model":"gpt-5.1","stream":true,"input":"hello"}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body))).WithContext(clientCtx) + c.Writer = &lifecycleDisconnectedWriter{ResponseWriter: c.Writer, disconnect: disconnect} + + type forwardResult struct { + result *service.OpenAIForwardResult + err error + } + done := make(chan forwardResult, 1) + go func() { + result, err := svc.Forward(clientCtx, c, account, body) + done <- forwardResult{result, err} + }() + select { + case <-clientCtx.Done(): + case got := <-done: + t.Fatalf("forward returned before downstream disconnect: %v", got.err) + case <-time.After(5 * time.Second): + t.Fatal("downstream did not receive the first event") + } + release() + select { + case got := <-done: + require.NoError(t, got.err) + require.NotNil(t, got.result) + require.Equal(t, 17, got.result.Usage.InputTokens) + require.Equal(t, 8, got.result.Usage.OutputTokens) + // RecordUsage must still work with the cancelled downstream context. + require.NoError(t, svc.RecordUsage(clientCtx, &service.OpenAIRecordUsageInput{ + Result: got.result, APIKey: &service.APIKey{ID: 2}, + User: &service.User{ID: 3}, Account: account, + })) + case <-time.After(5 * time.Second): + t.Fatal("forward did not finish draining usage") + } + require.Len(t, billingRepo.commands, 1) + require.Len(t, usageRepo.logs, 1) + require.Equal(t, 17, usageRepo.logs[0].InputTokens) + require.Equal(t, 8, usageRepo.logs[0].OutputTokens) + require.Greater(t, usageRepo.logs[0].ActualCost, 0.0) + s, ok := upstream.(*httpUpstreamService) + require.True(t, ok) + requireNoUpstreamInFlight(t, s) + for _, entry := range s.clients { + entry.client.CloseIdleConnections() + } +} diff --git a/backend/internal/repository/http_upstream_body_lifecycle_test.go b/backend/internal/repository/http_upstream_body_lifecycle_test.go new file mode 100644 index 000000000000..de72068b0249 --- /dev/null +++ b/backend/internal/repository/http_upstream_body_lifecycle_test.go @@ -0,0 +1,377 @@ +package repository + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/http/httptrace" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// Exercise both public entry points. The TLS case uses the test server's trusted +// transport so the test remains local and does not depend on system trust roots. +func lifecycleClient(t *testing.T, srv *httptest.Server, withTLS bool) (*httpUpstreamService, func(*http.Request) (*http.Response, error)) { + t.Helper() + s, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(t, ok) + profile := &tlsfingerprint.Profile{Name: "body-lifecycle-test"} + var entry *upstreamClientEntry + var err error + if withTLS { + entry, err = s.getClientEntryWithTLS("", 1, 1, profile, service.HTTPUpstreamProfileDefault, false, true) + } else { + entry, err = s.acquireClientWithProfile("", 1, 1, service.HTTPUpstreamProfileDefault) + if err == nil { + atomic.AddInt64(&entry.inFlight, -1) + } + } + require.NoError(t, err) + baseTransport, ok := srv.Client().Transport.(*http.Transport) + require.True(t, ok) + tr := baseTransport.Clone() + tr.MaxConnsPerHost = 1 + tr.MaxIdleConnsPerHost = 1 + tr.IdleConnTimeout = 10 * time.Second + entry.client = &http.Client{Transport: tr} + t.Cleanup(tr.CloseIdleConnections) + return s, func(req *http.Request) (*http.Response, error) { + if withTLS { + return s.DoWithTLS(req, "", 1, 1, profile) + } + return s.Do(req, "", 1, 1) + } +} + +func requireNoUpstreamInFlight(t *testing.T, s *httpUpstreamService) { + t.Helper() + s.mu.RLock() + defer s.mu.RUnlock() + for _, entry := range s.clients { + require.Zero(t, atomic.LoadInt64(&entry.inFlight)) + } +} + +type notifyReadCloser struct { + io.ReadCloser + started chan<- struct{} + once sync.Once +} + +func (r *notifyReadCloser) Read(p []byte) (int, error) { + r.once.Do(func() { close(r.started) }) + return r.ReadCloser.Read(p) +} + +func TestHTTPUpstreamConcurrentEarlyCloseDoesNotPoisonNextResponse(t *testing.T) { + for _, withTLS := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%v", withTLS), func(t *testing.T) { + finishFirst := make(chan struct{}) + firstFinished := make(chan struct{}) + var requests atomic.Int32 + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: begin\n\n") + _ = http.NewResponseController(w).Flush() + if requests.Add(1) == 1 { + defer close(firstFinished) + select { + case <-finishFirst: + case <-r.Context().Done(): + return + } + } + _, _ = io.WriteString(w, "data: complete\n\n") + })) + if withTLS { + srv.StartTLS() + } else { + srv.Start() + } + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, withTLS) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := do(req) + require.NoError(t, err) + prefix := make([]byte, len("data: begin\n\n")) + _, err = io.ReadFull(resp.Body, prefix) + require.NoError(t, err) + readerDone := make(chan struct{}) + readStarted := make(chan struct{}) + resp.Body = ¬ifyReadCloser{ReadCloser: resp.Body, started: readStarted} + go func() { _, _ = io.Copy(io.Discard, resp.Body); close(readerDone) }() + // Start Close only after the background reader has entered Read; + // the server is still holding the response open at this point. + <-readStarted + closed := make(chan error, 1) + go func() { closed <- resp.Body.Close() }() + select { + case err := <-closed: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Close blocked behind the active reader") + } + // Let the early-close drain finish only after Close has returned. + // Go 1.27 can reuse that connection while its old reader awaits EOF. + close(finishFirst) + <-firstFinished + for i := 0; i < 8; i++ { + resp, err := do(req) + require.NoError(t, err) + done := make(chan error, 1) + go func() { _, err := io.Copy(io.Discard, resp.Body); _ = resp.Body.Close(); done <- err }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("response EOF waits for a subsequent request") + } + } + select { + case <-readerDone: + case <-time.After(5 * time.Second): + t.Fatal("early-closed reader leaked") + } + require.NoError(t, req.Context().Err(), "closing an attempt must not cancel its caller or retries") + requireNoUpstreamInFlight(t, s) + }) + } +} + +func TestHTTPUpstreamCompletedBodyPreservesKeepAlive(t *testing.T) { + for _, encoding := range []string{"", "gzip"} { + t.Run("encoding="+encoding, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if encoding == "gzip" { + w.Header().Set("Content-Encoding", encoding) + zw := gzip.NewWriter(w) + _, _ = io.WriteString(zw, "complete response") + _ = zw.Close() + return + } + _, _ = io.WriteString(w, "complete response") + })) + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, false) + for i := 0; i < 20; i++ { + var reused bool + ctx := httptrace.WithClientTrace(t.Context(), &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }}) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + require.NoError(t, err) + req.Header.Set("Accept-Encoding", encoding) + resp, err := do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "complete response", string(body)) + var wg sync.WaitGroup + for j := 0; j < 4; j++ { + wg.Add(1) + go func() { defer wg.Done(); _ = resp.Body.Close() }() + } + wg.Wait() + if i > 0 { + require.True(t, reused, "fully consumed responses must keep their connection") + } + } + requireNoUpstreamInFlight(t, s) + }) + } +} + +func TestHTTPUpstreamCancellationReleasesAttempt(t *testing.T) { + for _, beforeHeaders := range []bool{false, true} { + t.Run(fmt.Sprintf("before_headers=%v", beforeHeaders), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !beforeHeaders { + w.WriteHeader(http.StatusOK) + _ = http.NewResponseController(w).Flush() + } + <-r.Context().Done() + })) + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, false) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := do(req) + if beforeHeaders { + require.ErrorIs(t, err, context.DeadlineExceeded) + } else { + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, resp.Body.Close()) + } + requireNoUpstreamInFlight(t, s) + }) + } +} + +func TestHTTPUpstreamCompressedEarlyCloseStopsReader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + zw := gzip.NewWriter(w) + _, _ = io.WriteString(zw, "data: begin\n\n") + _ = zw.Flush() + _ = http.NewResponseController(w).Flush() + <-r.Context().Done() + _ = zw.Close() + })) + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, false) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := do(req) + require.NoError(t, err) + prefix := make([]byte, len("data: begin\n\n")) + _, err = io.ReadFull(resp.Body, prefix) + require.NoError(t, err) + done := make(chan error, 1) + readStarted := make(chan struct{}) + resp.Body = ¬ifyReadCloser{ReadCloser: resp.Body, started: readStarted} + go func() { _, err := io.Copy(io.Discard, resp.Body); done <- err }() + <-readStarted + closed := make(chan error, 1) + go func() { closed <- resp.Body.Close() }() + select { + case err := <-closed: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("compressed body Close blocked") + } + select { + case err := <-done: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("compressed body reader leaked") + } + requireNoUpstreamInFlight(t, s) +} + +func TestHTTPUpstreamHTTP2CloseDoesNotCancelOtherStream(t *testing.T) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/held" { + w.WriteHeader(http.StatusOK) + _ = http.NewResponseController(w).Flush() + <-r.Context().Done() + return + } + _, _ = io.WriteString(w, "complete response") + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, false) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/held", nil) + require.NoError(t, err) + held, err := do(req) + require.NoError(t, err) + require.Equal(t, 2, held.ProtoMajor) + for i := 0; i < 2; i++ { + var reused bool + ctx := httptrace.WithClientTrace(t.Context(), &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }}) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := do(req) + require.NoError(t, err) + if i == 0 { + require.NoError(t, held.Body.Close()) + } + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "complete response", string(body)) + require.NoError(t, resp.Body.Close()) + require.True(t, reused, "closing one HTTP/2 stream must retain the shared connection") + } + requireNoUpstreamInFlight(t, s) +} + +func TestHTTPUpstreamDetachedRequestDrainsAfterClientDisconnect(t *testing.T) { + disconnectCtx, disconnect := context.WithCancel(t.Context()) + defer disconnect() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "begin ") + _ = http.NewResponseController(w).Flush() + <-disconnectCtx.Done() + _, _ = io.WriteString(w, "usage and completion") + })) + defer srv.Close() + defer srv.CloseClientConnections() + s, do := lifecycleClient(t, srv, false) + req, err := http.NewRequestWithContext(context.WithoutCancel(disconnectCtx), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := do(req) + require.NoError(t, err) + disconnect() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "begin usage and completion", string(body)) + require.NoError(t, resp.Body.Close()) + requireNoUpstreamInFlight(t, s) +} + +// Keep the same benchmark runnable against the base revision: it measures a +// complete public Do/read/Close lifecycle without network latency hiding the +// per-attempt context and per-Read synchronization costs. +func BenchmarkHTTPUpstreamBodyLifecycle(b *testing.B) { + for _, size := range []int{256, 64 * 1024} { + b.Run(fmt.Sprintf("bytes=%d", size), func(b *testing.B) { + s, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(b, ok) + entry, err := s.getOrCreateClient("", 1, 1) + require.NoError(b, err) + payload := strings.Repeat("x", size) + entry.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(payload)), + }, nil + })} + req, err := http.NewRequestWithContext(b.Context(), http.MethodGet, "https://example.com", nil) + require.NoError(b, err) + buf := make([]byte, 256) + b.ReportAllocs() + b.SetBytes(int64(size)) + b.ResetTimer() + for b.Loop() { + resp, err := s.Do(req, "", 1, 1) + if err != nil { + b.Fatal(err) + } + for { + _, err = resp.Body.Read(buf) + if err != nil { + break + } + } + if err != io.EOF { + b.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + b.Fatal(err) + } + } + }) + } +} From 0920e06a423abf7b2b87d788c9df55a8a2a4d668 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 18 Sep 2026 13:31:58 +0800 Subject: [PATCH 030/142] fix(gateway): restore public response model aliases --- .../openai_gateway_chat_completions_raw.go | 2 + ...penai_gateway_chat_completions_raw_test.go | 37 ++++++++ .../service/openai_gateway_passthrough.go | 2 +- .../service/openai_gateway_request_body.go | 7 +- .../openai_gateway_response_handling.go | 38 ++++---- .../service/openai_gateway_service_test.go | 16 ++-- .../openai_response_model_rewrite_test.go | 94 +++++++++++++++++++ 7 files changed, 163 insertions(+), 33 deletions(-) create mode 100644 backend/internal/service/openai_response_model_rewrite_test.go diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index f778a682bc9b..e5ddc972f892 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -353,6 +353,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( line = applyOllamaCloudRawChatCompletionsSSELine(account, line) line = stripEmptyChatToolCallIdentityFromSSELine(line) + line = s.replaceModelInSSELine(line, upstreamModel, originalModel) writeLine(line) if line == "" { if !clientDisconnected && clientOutputStarted { @@ -523,6 +524,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) } respBody = applyOllamaCloudRawChatCompletionsResponse(account, respBody) + respBody = s.replaceModelInResponseBody(respBody, upstreamModel, originalModel) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index e0152816d754..a67ad5fca92e 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -1240,3 +1241,39 @@ func largeRawChatCompletionsBody() []byte { strings.Repeat("x", openAISilentRefusalMinRequestBodyBytes) + `"}],"stream":true}`) } + +func TestForwardAsRawChatCompletions_RestoresMappedResponseModel(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, mapped := range []bool{false, true} { + for _, returned := range []string{"zhipu/glm-5.3", "glm-5.3-alias"} { + t.Run(fmt.Sprintf("stream=%v/mapped=%v/%s", stream, mapped, returned), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"public","messages":[{"role":"user","content":"hello"}],"stream":%v}`, stream)) + payload := `{"id":"chatcmpl_1","model":"` + returned + `","choices":[{"index":0,"delta":{"content":"keep alias"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}` + upstreamBody, contentType := payload, "application/json" + if stream { + upstreamBody = "data: " + payload + "\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(upstreamBody))}} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + expectedModel, expectedUpstream := returned, "public" + if mapped { + account.Credentials["model_mapping"] = map[string]any{"public": "ZHIPU/GLM-5.3"} + expectedModel = "public" + expectedUpstream = "ZHIPU/GLM-5.3" + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.Equal(t, expectedUpstream, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Contains(t, rec.Body.String(), strings.Replace(payload, `"model":"`+returned+`"`, `"model":"`+expectedModel+`"`, 1)) + require.Equal(t, returned, result.UpstreamResponseModel) + }) + } + } + } +} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index dd8f390079a2..f421a3f6bd54 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -1995,7 +1995,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( trimmedData := strings.TrimSpace(data) rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType) observer.ObserveOpenAI(dataBytes, rawEventType) - if needModelReplace && strings.Contains(data, mappedModel) { + if needModelReplace { line = s.replaceModelInSSELine(line, mappedModel, originalModel) if replacedData, replaced := extractOpenAISSEDataLine(line); replaced { dataBytes = []byte(replacedData) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 70c10d104a5b..2c8aa8d4b4cb 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -794,8 +794,11 @@ func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { } func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { - // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 - if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { + // A mapped request must retain its public name even when upstream uses an alias. + if fromModel == "" || toModel == "" || fromModel == toModel || !gjson.ValidBytes(body) { + return body + } + if m := gjson.GetBytes(body, "model"); m.Type == gjson.String { newBody, err := sjson.SetBytes(body, "model", toModel) if err != nil { return body diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index efb9f2e0d8d8..a23d11b1fb06 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -663,8 +663,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. line = "data: " + data } // Replace model in response if needed. - // Fast path: most events do not contain model field values. - if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { + if needModelReplace { line = s.replaceModelInSSELine(line, mappedModel, originalModel) } startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) @@ -1070,33 +1069,28 @@ func effectiveOpenAISSEEventType(payload []byte, eventType string) string { } func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { - data, ok := extractOpenAISSEDataLine(line) - if !ok { + if fromModel == "" || toModel == "" || fromModel == toModel { return line } - if data == "" || data == "[DONE]" { + data, ok := extractOpenAISSEDataLine(line) + if !ok || !gjson.Valid(data) { return line } - - // 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化 - if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "model", toModel) - if err != nil { - return line + updated := data + // Only protocol model fields are rewritten; text and tool payloads are untouched. + for _, path := range []string{"model", "response.model"} { + if m := gjson.Get(updated, path); m.Type == gjson.String { + var err error + updated, err = sjson.Set(updated, path, toModel) + if err != nil { + return line + } } - return "data: " + newData } - - // 检查嵌套的 response.model 字段 - if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "response.model", toModel) - if err != nil { - return line - } - return "data: " + newData + if updated == data { + return line } - - return line + return "data: " + updated } // correctToolCallsInResponseBody 修正响应体中的工具调用 diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index cf4611ccceb6..f5e3745b618f 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3324,11 +3324,11 @@ func TestReplaceModelInSSELine(t *testing.T) { expected: `data: {"type":"response","response":{"id":"resp-1","model":"my-model","output":[]}}`, }, { - name: "model 不匹配时不替换", + name: "上游别名仍替换", line: `data: {"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`, from: "gpt-4o", to: "my-model", - expected: `data: {"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`, + expected: `data: {"id":"chatcmpl-123","model":"my-model","choices":[]}`, }, { name: "无 model 字段时不替换", @@ -3394,11 +3394,11 @@ func TestReplaceModelInSSELine(t *testing.T) { expected: `data: {"id":"abc","object":"chat.completion.chunk","model":"alias","created":1234567890,"choices":[{"index":0,"delta":{"content":"hi"}}]}`, }, { - name: "顶层优先于嵌套:同时存在两个 model", + name: "同时替换两个 model", line: `data: {"model":"gpt-4o","response":{"model":"gpt-4o"}}`, from: "gpt-4o", to: "replaced", - expected: `data: {"model":"replaced","response":{"model":"gpt-4o"}}`, + expected: `data: {"model":"replaced","response":{"model":"replaced"}}`, }, } @@ -3428,11 +3428,11 @@ func TestReplaceModelInSSEBody(t *testing.T) { expected: "data: {\"model\":\"alias\",\"choices\":[]}\n\ndata: {\"model\":\"alias\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n", }, { - name: "无需替换的 body", + name: "上游别名 body", body: "data: {\"model\":\"gpt-3.5-turbo\"}\n\ndata: [DONE]\n", from: "gpt-4o", to: "alias", - expected: "data: {\"model\":\"gpt-3.5-turbo\"}\n\ndata: [DONE]\n", + expected: "data: {\"model\":\"alias\"}\n\ndata: [DONE]\n", }, { name: "混合 event 和 data 行", @@ -3476,11 +3476,11 @@ func TestReplaceModelInResponseBody(t *testing.T) { expected: `{"id":"chatcmpl-123","model":"alias","choices":[]}`, }, { - name: "model 不匹配不替换", + name: "上游别名仍替换", body: `{"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`, from: "gpt-4o", to: "alias", - expected: `{"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`, + expected: `{"id":"chatcmpl-123","model":"alias","choices":[]}`, }, { name: "无 model 字段不替换", diff --git a/backend/internal/service/openai_response_model_rewrite_test.go b/backend/internal/service/openai_response_model_rewrite_test.go new file mode 100644 index 000000000000..7c70351680b6 --- /dev/null +++ b/backend/internal/service/openai_response_model_rewrite_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestMappedResponseModelPreservesOtherData(t *testing.T) { + svc := &OpenAIGatewayService{} + body := `{"model":"alias","text":"mapped alias","tool":{"model":"mapped","arguments":"{\"model\":\"alias\"}"}}` + want := `{"model":"public","text":"mapped alias","tool":{"model":"mapped","arguments":"{\"model\":\"alias\"}"}}` + require.Equal(t, want, string(svc.replaceModelInResponseBody([]byte(body), "mapped", "public"))) + require.Equal(t, "data: "+want, svc.replaceModelInSSELine("data: "+body, "mapped", "public")) + for _, body := range []string{ + `{"model":"alias",`, `{"model":"alias"} trailing`, `{"model":null}`, `{"model":42}`, `{"model":{}}`, `{"model":[]}`, `{"model":true}`, + `{"text":"alias","tool":{"model":"alias"}}`, + } { + require.Equal(t, body, string(svc.replaceModelInResponseBody([]byte(body), "mapped", "public"))) + require.Equal(t, "data: "+body, svc.replaceModelInSSELine("data: "+body, "mapped", "public")) + } + for _, models := range [][2]string{{"same", "same"}, {"", "public"}, {"mapped", ""}} { + body := `{"model":"alias","response":{"model":"alias"}}` + require.Equal(t, body, string(svc.replaceModelInResponseBody([]byte(body), models[0], models[1]))) + require.Equal(t, "data: "+body, svc.replaceModelInSSELine("data: "+body, models[0], models[1])) + } + require.Equal(t, `data: {"response":{"model":42}}`, svc.replaceModelInSSELine(`data: {"response":{"model":42}}`, "mapped", "public")) + require.Equal(t, `{"model":"public"}`, string(svc.replaceModelInResponseBody([]byte(`{"model":""}`), "mapped", "public"))) +} + +// Exercise both streaming processors so substring fast paths cannot bypass the rewrite. +func TestMappedResponseModelForwarding(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, passthrough := range []bool{false, true} { + for _, returned := range []string{"zhipu/glm-5.3", "glm-5.3-alias"} { + for _, mapped := range []string{"ZHIPU/GLM-5.3", "public"} { + for _, kind := range []string{"json", "chat", "responses"} { + name := kind + "/" + returned + "/" + mapped + if passthrough { + name += "/passthrough" + } + t.Run(name, func(t *testing.T) { + model := returned + if mapped != "public" { + model = "public" + } + payload := `{"model":"` + returned + `","choices":[{"delta":{"content":"keep alias","tool_calls":[{"function":{"arguments":"{\"model\":\"alias\"}"}}]}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}` + want := strings.Replace(payload, `"model":"`+returned+`"`, `"model":"`+model+`"`, 1) + contentType := "application/json" + body := payload + if kind == "responses" { + payload = `{"type":"response.completed","response":{"id":"resp_1","model":"` + returned + `","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + want = strings.Replace(payload, `"model":"`+returned+`"`, `"model":"`+model+`"`, 1) + } + if kind != "json" { + contentType = "text/event-stream" + body = "data: " + payload + "\n\ndata: [DONE]\n\n" + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(body))} + svc := &OpenAIGatewayService{} + account := &Account{ID: 1} + var err error + if kind == "json" { + if passthrough { + _, err = svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, account, "public", mapped) + } else { + _, err = svc.handleNonStreamingResponse(context.Background(), resp, c, account, "public", mapped) + } + } else { + if passthrough { + _, err = svc.handleStreamingResponsePassthrough(context.Background(), resp, c, account, time.Now(), "public", mapped) + } else { + _, err = svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "public", mapped) + } + } + require.NoError(t, err) + require.Contains(t, rec.Body.String(), want) + require.Equal(t, returned, observedUpstreamResponseModel(c)) + }) + } + } + } + } +} From cfd2fc779bb53eafce8004e71425cc4f397f7578 Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:21:21 +0800 Subject: [PATCH 031/142] =?UTF-8?q?fix(codex-models):=20=E9=9D=9E=20GPT=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9A=84=20Codex=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=A8=A1=E6=9D=BF=E5=8E=BB=E6=8E=89=20GPT=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=A3=B0=E6=98=8E=EF=BC=8C=E9=81=BF=E5=85=8D=20Antigr?= =?UTF-8?q?avity=20429?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型目录对所有模型都用 openai.CodexBaseInstructionsForModel 填 instructions_template, gemini / claude 等因此下发 "You are Codex, a coding agent based on GPT-5."。Codex 把它作为 instructions 发出,Antigravity 对「Codex 提示词 + GPT-5 身份」返回 429 RESOURCE_EXHAUSTED, 并让账号进入限流冷却。 OpenAI GPT 模型保持原模板;其他模型族(含 gpt-oss)去掉 "based on GPT-N" / "You are GPT-N" 身份声明,提示词其余部分不变。 Refs #7307 --- .../service/openai_codex_models_service.go | 44 ++++++++++++++- .../openai_codex_models_service_test.go | 56 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index e2658581e798..cee3336c76b7 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "net/url" + "regexp" "sort" "strings" "sync" @@ -443,7 +444,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr Priority: configuredCodexModelPriority, AdditionalSpeedTiers: []string{}, ServiceTiers: []configuredCodexServiceTier{}, - ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)}, + ModelMessages: configuredCodexModelMessages{InstructionsTemplate: codexInstructionsTemplateForModel(modelID)}, SupportsReasoningSummaryParameter: true, DefaultReasoningSummary: "auto", WebSearchToolType: "text", @@ -636,6 +637,47 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin return levels } +// codexGPTIdentityPatterns match the GPT self-identification that the bundled +// Codex prompts open with: +// - "You are Codex, a coding agent based on GPT-5." / "…an agent based on GPT-6." / "You are Codex, based on GPT-5." +// - "You are GPT-5.1 running in the Codex CLI, …" +var codexGPTIdentityPatterns = []struct { + re *regexp.Regexp + repl string +}{ + {regexp.MustCompile(`,?\s*based on GPT-\d+(?:\.\d+)?(?:-[A-Za-z0-9]+)*`), ""}, + {regexp.MustCompile(`\bYou are GPT-\d+(?:\.\d+)?(?:-[A-Za-z0-9]+)*`), "You are Codex"}, +} + +// codexInstructionsTemplateForModel returns the Codex base prompt advertised for +// modelID in the models manifest. +// +// OpenAI GPT models keep the bundled prompt verbatim. Every other family the +// manifest can list (Gemini, Claude, DeepSeek, Grok, gpt-oss, custom aliases) +// gets the same prompt without the GPT self-identification: the claim is false +// for those models, and Google's Antigravity backend answers requests whose +// system prompt is the Codex prompt carrying a "GPT-5" identity with +// 429 RESOURCE_EXHAUSTED — which also puts the account into rate-limit +// cooldown. Codex sends this template as `instructions` for the selected model. +func codexInstructionsTemplateForModel(modelID string) string { + base := openai.CodexBaseInstructionsForModel(modelID) + if codexModelKeepsGPTIdentity(modelID) { + return base + } + for _, p := range codexGPTIdentityPatterns { + base = p.re.ReplaceAllString(base, p.repl) + } + return base +} + +// codexModelKeepsGPTIdentity reports whether modelID is an OpenAI GPT model +// whose Codex prompt may claim a GPT identity. gpt-oss models are open-weight +// and typically served by non-OpenAI providers, so they do not. +func codexModelKeepsGPTIdentity(modelID string) bool { + return isOpenAICodexGPTModel(modelID) && + !strings.HasPrefix(canonicalizeOpenAIModelAliasSpelling(modelID), "gpt-oss") +} + func isOpenAICodexGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) if normalized == "" || strings.HasPrefix(normalized, "gpt-image") { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 888a32f55ccd..5ab3b87f6c5e 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3703,3 +3703,59 @@ func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering require.Equal(t, []string{"model-b"}, got[92]) require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") } + +// Scenario: 非 OpenAI GPT 模型的 Codex 提示词不声称自己是 GPT。 +// Antigravity(Google) 对「Codex 提示词 + GPT-5 身份」直接回 429 RESOURCE_EXHAUSTED。 +func TestBuildCodexModelsManifestStripsGPTIdentityForNonGPTModels(t *testing.T) { + t.Parallel() + + nonGPT := []string{ + "gemini-3-flash", "claude-sonnet-4-6", "deepseek-v4-flash", "grok-4.3", + "gpt-oss-120b-medium", "company-coding-model", "company-codex-alias", + } + body, err := BuildCodexModelsManifest(nonGPT) + require.NoError(t, err) + + var manifest struct { + Models []struct { + Slug string `json:"slug"` + ModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + } `json:"model_messages"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &manifest)) + require.Len(t, manifest.Models, len(nonGPT)) + for _, m := range manifest.Models { + tmpl := m.ModelMessages.InstructionsTemplate + require.NotContains(t, tmpl, "GPT-", m.Slug) + require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", m.Slug, tmpl) + // Only the identity clause is removed; the rest of the bundled prompt is kept. + base := openai.CodexBaseInstructionsForModel(m.Slug) + require.Less(t, len(base)-len(tmpl), 32, m.Slug) + } +} + +// Scenario: OpenAI GPT 模型的 Codex 提示词保持原样。 +func TestBuildCodexModelsManifestKeepsGPTIdentityForGPTModels(t *testing.T) { + t.Parallel() + + for _, model := range []string{"gpt-5.5", "gpt-6-astra", "gpt-5.2", "gpt-5.3-codex-spark"} { + require.Equal(t, openai.CodexBaseInstructionsForModel(model), codexInstructionsTemplateForModel(model), model) + } +} + +// Scenario: 每份内置 Codex 提示词的身份声明都能被识别并去掉。 +func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { + t.Parallel() + + for _, model := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.5", "gpt-6-astra", "gpt-5-codex"} { + tmpl := openai.CodexBaseInstructionsForModel(model) + require.Contains(t, tmpl, "GPT-", "bundled prompt for %s changed; update this test", model) + for _, p := range codexGPTIdentityPatterns { + tmpl = p.re.ReplaceAllString(tmpl, p.repl) + } + require.NotContains(t, tmpl, "GPT-", model) + require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl) + } +} From 4805f069e4dffd14f70cdf62c712979a9291bd62 Mon Sep 17 00:00:00 2001 From: Yoga Sakti Date: Fri, 18 Sep 2026 13:37:58 +0700 Subject: [PATCH 032/142] fix(scheduler): keep account RPM config in the scheduler projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filterSchedulerExtra` 的白名单没有 `base_rpm` / `rpm_strategy` / `rpm_sticky_buffer`,写进 `sched:meta:` 的精简投影因此丢掉整套 RPM 配置。 候选过滤走 `ListSchedulableAccounts`,缓存命中时读的正是这份投影: `isAccountSchedulableForRPM` 拿到的账号上 `GetBaseRPM()` 返回 0,`baseRPM <= 0` 直接放行,已配置限流的账号在选号阶段被当作未开启 RPM,单个自然分钟内被超额调度。 缓存未命中时回退数据库读到完整 extra,所以现象是间歇性的。 选中的账号随后由 `hydrateSelectedAccount` 从 `sched:acc:` 重新水合, RPM 计数与用户消息队列限速读的是完整账号,不受影响;缺的只是选号阶段这道门。 修法沿用投影里既有的处理方式(见 `openai_passthrough` 一组注释):把判定依赖的 键补进白名单,而不是在判定侧另开一条读取完整账号的路径。 回归测试覆盖序列化往返后的三个键:`tiered` 子用例证明缓冲被裁掉时红区会被误判成 黄区,`sticky_exempt` 子用例证明策略被裁掉时会退回三区判定。 Refs #7225 --- .../internal/repository/scheduler_cache.go | 5 ++ .../repository/scheduler_cache_unit_test.go | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 02a1fcf3be6d..dc4577475453 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -998,6 +998,11 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "mixed_scheduling", "window_cost_limit", "window_cost_sticky_reserve", + // RPM 门与窗口费用门一样跑在本投影上:isAccountSchedulableForRPM 读 base_rpm, + // 缺失时 GetBaseRPM() 返回 0 并直接放行,已配置限流的账号会被超额调度。 + "base_rpm", + "rpm_strategy", + "rpm_sticky_buffer", "max_sessions", "session_idle_timeout_minutes", "openai_oauth_responses_websockets_v2_enabled", diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index f151e33cd89a..c67a8259fdaf 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -1121,3 +1121,64 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIPassthroughForModelGate(t *tes }) } } + +// 调度投影必须保留账号的 RPM 配置。 +// +// 候选过滤走 ListSchedulableAccounts,读的是 buildSchedulerMetadataAccount 产出的精简投影; +// isAccountSchedulableForRPM 靠 extra 上的 base_rpm 判定,缺失时 GetBaseRPM() 返回 0, +// baseRPM <= 0 直接放行,已配置限流的账号在选号阶段被当作未开启 RPM(#7225)。 +// rpm_strategy / rpm_sticky_buffer 同理:只保留 base_rpm 会让三区判定退回默认值, +// 红区(不可调度)被误判成黄区(仅粘性),超额请求照样被转发。 +func TestBuildSchedulerMetadataAccount_KeepsRPMFieldsForRPMGate(t *testing.T) { + newAccount := func(extra map[string]any) service.Account { + return service.Account{ + ID: 7225, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Extra: extra, + } + } + roundTrip := func(t *testing.T, account service.Account) service.Account { + t.Helper() + // 走一遍真实的序列化/反序列化路径(写入 sched:meta 再由 decodeCachedAccount 读回)。 + payload, err := json.Marshal(buildSchedulerMetadataAccount(account)) + require.NoError(t, err) + var restored service.Account + require.NoError(t, json.Unmarshal(payload, &restored)) + return restored + } + + t.Run("tiered", func(t *testing.T) { + account := newAccount(map[string]any{ + "base_rpm": 10, + "rpm_strategy": "tiered", + "rpm_sticky_buffer": 1, + }) + require.Equal(t, service.WindowCostNotSchedulable, account.CheckRPMSchedulability(11), + "前置条件:base_rpm=10 + 缓冲 1,当前 11 次已进红区") + + restored := roundTrip(t, account) + + require.Equal(t, 10, restored.GetBaseRPM()) + require.Equal(t, "tiered", restored.GetRPMStrategy()) + require.Equal(t, 1, restored.GetRPMStickyBuffer()) + require.Equal(t, service.WindowCostNotSchedulable, restored.CheckRPMSchedulability(11), + "投影裁掉 RPM 配置会让已配置限流的账号在候选过滤阶段被放行") + }) + + t.Run("sticky_exempt", func(t *testing.T) { + account := newAccount(map[string]any{ + "base_rpm": 10, + "rpm_strategy": "sticky_exempt", + }) + require.Equal(t, service.WindowCostStickyOnly, account.CheckRPMSchedulability(100), + "前置条件:粘性豁免策略没有红区") + + restored := roundTrip(t, account) + + require.Equal(t, 10, restored.GetBaseRPM()) + require.Equal(t, "sticky_exempt", restored.GetRPMStrategy()) + require.Equal(t, service.WindowCostStickyOnly, restored.CheckRPMSchedulability(100), + "投影裁掉 rpm_strategy 会让粘性豁免账号退回三区判定") + }) +} From 7b4de8b6ae7652c36fbdc859486f52d937173246 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 18 Sep 2026 14:54:18 +0800 Subject: [PATCH 033/142] fix(moderation): prevent reminder tags from bypassing keyword checks --- .../internal/service/content_moderation.go | 49 ++-- .../service/content_moderation_input.go | 107 +++++---- .../content_moderation_reminder_test.go | 226 ++++++++++++++++++ 3 files changed, 313 insertions(+), 69 deletions(-) create mode 100644 backend/internal/service/content_moderation_reminder_test.go diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index b25f5ec4dc83..66d38b52b881 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -909,30 +909,10 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer "configured_models", cfg.ModelFilter.Models) return allow, nil } - content := ExtractContentModerationInput(input.Protocol, input.Body) - if content.IsEmpty() { - slog.Info("content_moderation.skip_empty_input", - "user_id", input.UserID, - "api_key_id", input.APIKeyID, - "group_id", contentModerationLogGroupID(input.GroupID), - "endpoint", input.Endpoint, - "protocol", input.Protocol, - "body_bytes", len(input.Body)) - return allow, nil - } - content.Normalize() - slog.Info("content_moderation.input_extracted", - "user_id", input.UserID, - "api_key_id", input.APIKeyID, - "group_id", contentModerationLogGroupID(input.GroupID), - "endpoint", input.Endpoint, - "protocol", input.Protocol, - "text_runes", len([]rune(content.Text)), - "image_count", len(content.Images)) - hashText := content.Hash() if cfg.Mode == ContentModerationModePreBlock { if cfg.KeywordBlockingMode != ContentModerationKeywordModeAPIOnly && len(cfg.BlockedKeywords) > 0 { - if keyword, hit := runtimeSnapshot.matchBlockedKeyword(content.Text); hit { + keywordText := extractContentModerationKeywordText(input.Protocol, input.Body) + if keyword, hit := runtimeSnapshot.matchBlockedKeyword(keywordText); hit { s.recordPreBlockSyncMetric(0, ContentModerationActionKeywordBlock) slog.Info("content_moderation.keyword_block", "user_id", input.UserID, @@ -943,9 +923,9 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer "keyword_blocking_mode", cfg.KeywordBlockingMode, "keyword", keyword) scores := map[string]float64{contentModerationKeywordCategory: 1.0} - log := s.buildLog(input, cfg, ContentModerationActionKeywordBlock, true, contentModerationKeywordCategory, 1.0, scores, content.ExcerptText(), nil, nil, "") + log := s.buildLog(input, cfg, ContentModerationActionKeywordBlock, true, contentModerationKeywordCategory, 1.0, scores, keywordText, nil, nil, "") log.MatchedKeyword = keyword - s.enqueueRecord(input, cfg, log, hashText, false, true) + s.enqueueRecord(input, cfg, log, (ContentModerationInput{Text: keywordText}).Hash(), false, true) return &ContentModerationDecision{ Allowed: false, Blocked: true, @@ -970,6 +950,27 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer return allow, nil } } + content := ExtractContentModerationInput(input.Protocol, input.Body) + if content.IsEmpty() { + slog.Info("content_moderation.skip_empty_input", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "body_bytes", len(input.Body)) + return allow, nil + } + content.Normalize() + slog.Info("content_moderation.input_extracted", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "text_runes", len([]rune(content.Text)), + "image_count", len(content.Images)) + hashText := content.Hash() if cfg.PreHashCheckEnabled && s.hashCache != nil { matched, err := s.hashCache.HasFlaggedInputHash(ctx, hashText) if err != nil { diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 73d5b6973a2d..013c10c13de2 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -14,6 +14,21 @@ func ExtractContentModerationText(protocol string, body []byte) string { } func ExtractContentModerationInput(protocol string, body []byte) ContentModerationInput { + return extractContentModerationInput(protocol, body, true) +} + +// Keyword checks share semantic moderation's current-user boundaries, but must +// inspect client-supplied reminder blocks as ordinary user text. +func extractContentModerationKeywordText(protocol string, body []byte) string { + return extractContentModerationInput(protocol, body, false).Text +} + +type moderationTextCollector struct { + filterReminders bool +} + +func extractContentModerationInput(protocol string, body []byte, filterReminders bool) ContentModerationInput { + collector := moderationTextCollector{filterReminders: filterReminders} if len(body) == 0 || !gjson.ValidBytes(body) { return ContentModerationInput{} } @@ -21,30 +36,32 @@ func ExtractContentModerationInput(protocol string, body []byte) ContentModerati var images []string switch protocol { case ContentModerationProtocolAnthropicMessages: - collectLastAnthropicUserMessage(gjson.GetBytes(body, "messages"), &parts, &images) + collector.collectLastAnthropicUserMessage(gjson.GetBytes(body, "messages"), &parts, &images) case ContentModerationProtocolOpenAIChat: - collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) + collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) case ContentModerationProtocolOpenAIResponses: - collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) + collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) case ContentModerationProtocolGemini: - collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) + collector.collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) case ContentModerationProtocolOpenAIImages: - addModerationText(&parts, gjson.GetBytes(body, "prompt").String()) - collectContentValue(gjson.GetBytes(body, "images"), &parts, &images) + collector.addModerationText(&parts, gjson.GetBytes(body, "prompt").String()) + collector.collectContentValue(gjson.GetBytes(body, "images"), &parts, &images) default: - collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) - collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) - collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) + collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) + collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) + collector.collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) } out := ContentModerationInput{ Text: normalizeContentModerationText(strings.Join(parts, "\n")), Images: normalizeModerationImages(images), } - out.Normalize() + if filterReminders { + out.Normalize() + } return out } -func collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) { if !messages.IsArray() { return } @@ -58,7 +75,7 @@ func collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, } var candidate []string var candidateImages []string - collectContentValue(last.Get("content"), &candidate, &candidateImages) + collector.collectContentValue(last.Get("content"), &candidate, &candidateImages) if normalizeContentModerationText(strings.Join(candidate, "\n")) == "" && len(candidateImages) == 0 { return } @@ -66,7 +83,7 @@ func collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, *images = append(*images, candidateImages...) } -func collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, images *[]string) { if !messages.IsArray() { return } @@ -80,7 +97,7 @@ func collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, ima } var candidate []string var candidateImages []string - collectAnthropicUserContentValue(last.Get("content"), &candidate, &candidateImages) + collector.collectAnthropicUserContentValue(last.Get("content"), &candidate, &candidateImages) if normalizeContentModerationText(strings.Join(candidate, "\n")) == "" && len(candidateImages) == 0 { return } @@ -88,31 +105,31 @@ func collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, ima *images = append(*images, candidateImages...) } -func collectAnthropicUserContentValue(value gjson.Result, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectAnthropicUserContentValue(value gjson.Result, parts *[]string, images *[]string) { switch { case !value.Exists(): return case value.Type == gjson.String: - if !isAnthropicSystemReminderText(value.String()) { - addModerationText(parts, value.String()) + if !collector.filterReminders || !isAnthropicSystemReminderText(value.String()) { + collector.addModerationText(parts, value.String()) } case value.IsArray(): value.ForEach(func(_, item gjson.Result) bool { - collectAnthropicUserContentValue(item, parts, images) + collector.collectAnthropicUserContentValue(item, parts, images) return true }) case value.IsObject(): typ := strings.ToLower(strings.TrimSpace(value.Get("type").String())) switch typ { case "", "text", "input_text", "message": - if value.Get("text").Exists() && !isAnthropicSystemReminderText(value.Get("text").String()) { - addModerationText(parts, value.Get("text").String()) + if value.Get("text").Exists() && (!collector.filterReminders || !isAnthropicSystemReminderText(value.Get("text").String())) { + collector.addModerationText(parts, value.Get("text").String()) } if value.Get("content").Exists() { - collectAnthropicUserContentValue(value.Get("content"), parts, images) + collector.collectAnthropicUserContentValue(value.Get("content"), parts, images) } case "image_url", "input_image", "image": - collectContentValue(value, parts, images) + collector.collectContentValue(value, parts, images) } } } @@ -121,57 +138,57 @@ func isAnthropicSystemReminderText(text string) bool { return strings.HasPrefix(strings.TrimSpace(text), "") } -func collectLastResponsesInput(input gjson.Result, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectLastResponsesInput(input gjson.Result, parts *[]string, images *[]string) { switch { case !input.Exists(): return case input.Type == gjson.String: - addModerationText(parts, input.String()) + collector.addModerationText(parts, input.String()) case input.IsArray(): array := input.Array() if len(array) == 0 { return } last := array[len(array)-1] - if !isResponsesUserTextItem(last) { + if !collector.isResponsesUserTextItem(last) { return } - collectContentValue(last.Get("content"), parts, images) + collector.collectContentValue(last.Get("content"), parts, images) if last.Get("type").String() == "input_text" || last.Get("text").Exists() { - collectContentValue(last, parts, images) + collector.collectContentValue(last, parts, images) } case input.IsObject(): - if isResponsesUserTextItem(input) { - collectContentValue(input.Get("content"), parts, images) + if collector.isResponsesUserTextItem(input) { + collector.collectContentValue(input.Get("content"), parts, images) if input.Get("type").String() == "input_text" || input.Get("text").Exists() { - collectContentValue(input, parts, images) + collector.collectContentValue(input, parts, images) } } } } -func isResponsesUserTextItem(item gjson.Result) bool { +func (collector moderationTextCollector) isResponsesUserTextItem(item gjson.Result) bool { role := strings.ToLower(strings.TrimSpace(item.Get("role").String())) if role == "user" { - return responseItemHasModerationText(item) + return collector.responseItemHasModerationText(item) } if role != "" { return false } - return responseItemHasModerationText(item) + return collector.responseItemHasModerationText(item) } -func responseItemHasModerationText(item gjson.Result) bool { +func (collector moderationTextCollector) responseItemHasModerationText(item gjson.Result) bool { var parts []string var images []string - collectContentValue(item.Get("content"), &parts, &images) + collector.collectContentValue(item.Get("content"), &parts, &images) if item.Get("type").String() == "input_text" || item.Get("text").Exists() { - collectContentValue(item, &parts, &images) + collector.collectContentValue(item, &parts, &images) } return normalizeContentModerationText(strings.Join(parts, "\n")) != "" || len(images) > 0 } -func collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[]string) { if !contents.IsArray() { return } @@ -188,7 +205,7 @@ func collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[] var candidateImages []string if arr := last.Get("parts"); arr.IsArray() { arr.ForEach(func(_, part gjson.Result) bool { - addModerationText(&candidate, part.Get("text").String()) + collector.addModerationText(&candidate, part.Get("text").String()) addGeminiModerationImage(&candidateImages, part) return true }) @@ -200,15 +217,15 @@ func collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[] *images = append(*images, candidateImages...) } -func collectContentValue(value gjson.Result, parts *[]string, images *[]string) { +func (collector moderationTextCollector) collectContentValue(value gjson.Result, parts *[]string, images *[]string) { switch { case !value.Exists(): return case value.Type == gjson.String: - addModerationText(parts, value.String()) + collector.addModerationText(parts, value.String()) case value.IsArray(): value.ForEach(func(_, item gjson.Result) bool { - collectContentValue(item, parts, images) + collector.collectContentValue(item, parts, images) return true }) case value.IsObject(): @@ -227,10 +244,10 @@ func collectContentValue(value gjson.Result, parts *[]string, images *[]string) switch typ { case "", "text", "input_text", "message": if value.Get("text").Exists() { - addModerationText(parts, value.Get("text").String()) + collector.addModerationText(parts, value.Get("text").String()) } if value.Get("content").Exists() { - collectContentValue(value.Get("content"), parts, images) + collector.collectContentValue(value.Get("content"), parts, images) } case "image_url", "input_image", "image": } @@ -303,12 +320,12 @@ func limitContentModerationImages(images []string) []string { return []string{images[int(idx.Int64())]} } -func addModerationText(parts *[]string, text string) { +func (collector moderationTextCollector) addModerationText(parts *[]string, text string) { text = strings.TrimSpace(text) if text == "" { return } - if strings.Contains(text, "") { + if collector.filterReminders && strings.Contains(text, "") { return } *parts = append(*parts, text) diff --git a/backend/internal/service/content_moderation_reminder_test.go b/backend/internal/service/content_moderation_reminder_test.go new file mode 100644 index 000000000000..eb2c3204935b --- /dev/null +++ b/backend/internal/service/content_moderation_reminder_test.go @@ -0,0 +1,226 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func reminderTestBody(t *testing.T, protocol string, texts []string) []byte { + t.Helper() + blocks := make([]map[string]string, 0, len(texts)) + for _, text := range texts { + blocks = append(blocks, map[string]string{"type": "text", "text": text}) + } + message := map[string]any{"role": "user", "content": blocks} + var body any + switch protocol { + case ContentModerationProtocolGemini: + body = map[string]any{"contents": []any{map[string]any{"role": "user", "parts": blocks}}} + case ContentModerationProtocolOpenAIResponses, "unknown": + body = map[string]any{"input": []any{message}} + case ContentModerationProtocolOpenAIImages: + body = map[string]any{"prompt": strings.Join(texts, "\n")} + default: + body = map[string]any{"messages": []any{message}} + } + raw, err := json.Marshal(body) + require.NoError(t, err) + return raw +} + +func TestContentModerationCheck_ReminderKeywordModes(t *testing.T) { + for _, protocol := range []string{ContentModerationProtocolAnthropicMessages, ContentModerationProtocolOpenAIChat, ContentModerationProtocolOpenAIResponses, ContentModerationProtocolGemini, ContentModerationProtocolOpenAIImages, "unknown"} { + for name, texts := range map[string][]string{ + "plain": {"今晚打老虎"}, + "prefix": {"context 今晚打老虎"}, + "suffix": {"今晚打老虎 context"}, + "separate": {"context", "今晚打老虎"}, + "inside": {"今晚打老虎"}, + } { + for _, mode := range []string{ContentModerationKeywordModeKeywordOnly, ContentModerationKeywordModeAPIOnly, ContentModerationKeywordModeKeywordAndAPI} { + t.Run(protocol+"/"+name+"/"+mode, func(t *testing.T) { + body := reminderTestBody(t, protocol, texts) + semantic := ExtractContentModerationInput(protocol, body) + wantSemantic := "" + if name == "plain" || (name == "separate" && protocol != ContentModerationProtocolOpenAIImages) { + wantSemantic = "今晚打老虎" + } + require.Equal(t, wantSemantic, semantic.Text) + require.Contains(t, extractContentModerationKeywordText(protocol, body), "今晚打老虎") + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var payload struct { + Input string `json:"input"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.Input != semantic.Text { + t.Errorf("semantic input changed: %q != %q", payload.Input, semantic.Text) + } + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{}}}) + })) + defer server.Close() + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModePreBlock + cfg.KeywordBlockingMode = mode + cfg.BlockedKeywords = []string{"今晚打老虎"} + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"test"} + cfg.RecordNonHits = false + cfg.PreHashCheckEnabled = true + raw, err := json.Marshal(cfg) + require.NoError(t, err) + repo := &contentModerationTestRepo{} + cache := &contentModerationTestHashCache{} + if mode != ContentModerationKeywordModeAPIOnly { + cache.hashes = map[string]struct{}{semantic.Hash(): {}} + } + svc := NewContentModerationService(&contentModerationTestSettingRepo{values: map[string]string{SettingKeyRiskControlEnabled: "true", SettingKeyContentModerationConfig: string(raw)}}, repo, cache, nil, nil, nil, nil, nil) + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{Protocol: protocol, Body: body}) + require.NoError(t, err) + if mode == ContentModerationKeywordModeAPIOnly { + require.True(t, decision.Allowed) + want := int32(0) + if !semantic.IsEmpty() { + want = 1 + } + require.Equal(t, want, calls.Load()) + require.Empty(t, repo.snapshotLogs()) + } else { + require.Equal(t, ContentModerationActionKeywordBlock, decision.Action) + require.True(t, decision.Blocked) + require.Zero(t, calls.Load()) + require.Empty(t, cache.snapshotChecked()) + logs := requireContentModerationLogCount(t, repo, 1) + require.Equal(t, "今晚打老虎", logs[0].MatchedKeyword) + require.Contains(t, logs[0].InputExcerpt, "今晚打老虎") + require.Equal(t, int64(1), svc.asyncEnqueued.Load()) + } + }) + } + } + } +} + +func TestExtractContentModerationKeywordText_Boundaries(t *testing.T) { + for _, tc := range []struct{ protocol, body string }{ + {ContentModerationProtocolAnthropicMessages, `{"messages":[{"role":"user","content":"old"},{"role":"user","content":[{"type":"tool_result","content":"今晚打老虎"}]}]}`}, + {ContentModerationProtocolAnthropicMessages, `{"messages":[{"role":"user","content":"old"},{"role":"assistant","content":"今晚打老虎"}]}`}, + {ContentModerationProtocolOpenAIChat, `{"messages":[{"role":"user","content":"old"},{"role":"tool","content":"今晚打老虎"}]}`}, + {ContentModerationProtocolOpenAIChat, `{"messages":[{"role":"user","content":"old"},{"role":"assistant","content":"今晚打老虎"}]}`}, + {ContentModerationProtocolOpenAIResponses, `{"input":[{"role":"user","content":"old"},{"type":"function_call_output","output":"今晚打老虎"}]}`}, + {ContentModerationProtocolOpenAIResponses, `{"input":[{"role":"user","content":"old"},{"role":"assistant","content":"今晚打老虎"}]}`}, + {ContentModerationProtocolGemini, `{"contents":[{"role":"user","parts":[{"text":"old"}]},{"role":"user","parts":[{"functionResponse":{"response":{"text":"今晚打老虎"}}}]}]}`}, + {ContentModerationProtocolGemini, `{"contents":[{"role":"user","parts":[{"text":"old"}]},{"role":"model","parts":[{"text":"今晚打老虎"}]}]}`}, + } { + t.Run(tc.protocol+tc.body, func(t *testing.T) { + require.Empty(t, extractContentModerationKeywordText(tc.protocol, []byte(tc.body))) + }) + } +} + +func TestContentModerationCheck_ReminderPolicyPreserved(t *testing.T) { + for _, name := range []string{"keyword_miss", "combined_miss", "observe", "off", "disabled", "group_scope", "model_scope", "no_api_key", "zero_sample"} { + t.Run(name, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var payload struct { + Input string `json:"input"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.Input != "safe text" { + t.Errorf("unexpected semantic input: %q", payload.Input) + } + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{}}}) + })) + defer server.Close() + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModePreBlock + cfg.BlockedKeywords = []string{"今晚打老虎"} + cfg.KeywordBlockingMode = ContentModerationKeywordModeKeywordAndAPI + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"test"} + cfg.RecordNonHits = false + cfg.PreHashCheckEnabled = false + switch name { + case "keyword_miss": + cfg.KeywordBlockingMode = ContentModerationKeywordModeKeywordOnly + cfg.BlockedKeywords = []string{"absent"} + case "combined_miss": + cfg.BlockedKeywords = []string{"absent"} + case "observe": + cfg.Mode = ContentModerationModeObserve + case "off": + cfg.Mode = ContentModerationModeOff + case "disabled": + cfg.Enabled = false + case "group_scope": + cfg.AllGroups = false + cfg.GroupIDs = []int64{42} + case "model_scope": + cfg.ModelFilter = ContentModerationModelFilter{Type: ContentModerationModelFilterInclude, Models: []string{"other-model"}} + case "no_api_key": + cfg.APIKeys = nil + case "zero_sample": + cfg.SampleRate = 0 + } + raw, err := json.Marshal(cfg) + require.NoError(t, err) + repo := &contentModerationTestRepo{} + svc := NewContentModerationService(&contentModerationTestSettingRepo{values: map[string]string{SettingKeyRiskControlEnabled: "true", SettingKeyContentModerationConfig: string(raw)}}, repo, nil, nil, nil, nil, nil, nil) + body := reminderTestBody(t, ContentModerationProtocolAnthropicMessages, []string{"今晚打老虎", "safe text"}) + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{Protocol: ContentModerationProtocolAnthropicMessages, Body: body}) + require.NoError(t, err) + if name == "no_api_key" || name == "zero_sample" { + require.True(t, decision.Blocked) + requireContentModerationLogCount(t, repo, 1) + } else { + require.True(t, decision.Allowed) + } + switch name { + case "observe": + require.Eventually(t, func() bool { return calls.Load() == 1 }, time.Second, time.Millisecond*10) + case "combined_miss": + require.Equal(t, int32(1), calls.Load()) + default: + require.Zero(t, calls.Load()) + } + }) + } +} + +func TestExtractContentModerationKeywordText_ResponsesInputForms(t *testing.T) { + for _, body := range []string{ + `{"input":"今晚打老虎"}`, + `{"input":{"role":"user","content":"今晚打老虎"}}`, + `{"input":[{"type":"input_text","text":"今晚打老虎"}]}`, + } { + require.Contains(t, extractContentModerationKeywordText(ContentModerationProtocolOpenAIResponses, []byte(body)), "今晚打老虎") + require.Empty(t, ExtractContentModerationText(ContentModerationProtocolOpenAIResponses, []byte(body))) + } +} + +func TestExtractContentModerationKeywordText_LongReminder(t *testing.T) { + // Reminder context must not consume the semantic API's text limit and hide + // the actual user keyword from the local check. + body := reminderTestBody(t, ContentModerationProtocolAnthropicMessages, []string{ + "" + strings.Repeat("x", maxModerationInputRunes) + "", "今晚打老虎", + }) + require.Contains(t, extractContentModerationKeywordText(ContentModerationProtocolAnthropicMessages, body), "今晚打老虎") + require.Equal(t, "今晚打老虎", ExtractContentModerationText(ContentModerationProtocolAnthropicMessages, body)) +} From 9d5c8d961c61fcffa9d07bb99fa4aae7b9155c07 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 18 Sep 2026 16:37:03 +0800 Subject: [PATCH 034/142] fix(dialog): keep body locked while another dialog is open --- frontend/src/components/common/BaseDialog.vue | 13 +++-- .../__tests__/BaseDialog.scrollLock.spec.ts | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/__tests__/BaseDialog.scrollLock.spec.ts diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index e24c6fc22626..ba0fe6a05167 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -44,6 +44,7 @@ diff --git a/frontend/src/components/common/__tests__/BaseDialog.scrollLock.spec.ts b/frontend/src/components/common/__tests__/BaseDialog.scrollLock.spec.ts new file mode 100644 index 000000000000..3e4182f3c274 --- /dev/null +++ b/frontend/src/components/common/__tests__/BaseDialog.scrollLock.spec.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import BaseDialog from '../BaseDialog.vue' + +enableAutoUnmount(afterEach) + +function dialog(show = true) { + return mount(BaseDialog, { props: { show, title: 'Details' }, global: { stubs: { Icon: true } } }) +} + +const locked = () => document.body.classList.contains('modal-open') + +describe('dialog body scroll lock', () => { + it('keeps the body locked when a hidden sibling mounts or unmounts', () => { + dialog() + const hidden = dialog(false) + expect(locked()).toBe(true) + hidden.unmount() + expect(locked()).toBe(true) + }) + + it('keeps the lock until the last visible dialog closes', async () => { + const first = dialog() + const second = dialog() + await second.setProps({ show: false }) + expect(locked()).toBe(true) + await first.setProps({ show: false }) + expect(locked()).toBe(false) + }) + + it('keeps the lock until the last visible dialog unmounts', () => { + const first = dialog() + const second = dialog() + second.unmount() + expect(locked()).toBe(true) + first.unmount() + expect(locked()).toBe(false) + }) + + it('releases the lock after repeated open and close cycles', async () => { + const wrapper = dialog(false) + expect(locked()).toBe(false) + await wrapper.setProps({ show: true }) + expect(locked()).toBe(true) + await wrapper.setProps({ show: false }) + expect(locked()).toBe(false) + await wrapper.setProps({ show: true }) + wrapper.unmount() + expect(locked()).toBe(false) + }) +}) From 24f4736f64da056393ca89d69fa33c4004bd7ce9 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 18 Sep 2026 16:37:56 +0800 Subject: [PATCH 035/142] fix(upload): cancel superseded image reads --- .../src/components/common/ImageUpload.vue | 15 +++- .../__tests__/ImageUpload.readOrder.spec.ts | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/__tests__/ImageUpload.readOrder.spec.ts diff --git a/frontend/src/components/common/ImageUpload.vue b/frontend/src/components/common/ImageUpload.vue index 7bf1a21f0257..04612638b56d 100644 --- a/frontend/src/components/common/ImageUpload.vue +++ b/frontend/src/components/common/ImageUpload.vue @@ -56,7 +56,7 @@ v-if="modelValue" type="button" class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400" - @click="$emit('update:modelValue', '')" + @click="removeImage" > {{ resolvedRemoveLabel }} @@ -69,7 +69,7 @@ diff --git a/frontend/src/components/common/__tests__/SearchInput.spec.ts b/frontend/src/components/common/__tests__/SearchInput.spec.ts new file mode 100644 index 000000000000..02bee44af7f3 --- /dev/null +++ b/frontend/src/components/common/__tests__/SearchInput.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import SearchInput from '../SearchInput.vue' + +enableAutoUnmount(afterEach) +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) +function mountInput() { + return mount(SearchInput, { props: { modelValue: '', debounceMs: 300 }, global: { stubs: { Icon: true } } }) +} + +describe('SearchInput composition', () => { + it('waits for committed IME text before updating or searching', async () => { + const wrapper = mountInput() + const input = wrapper.get('input') + await input.trigger('compositionstart') + input.element.value = 'zhong' + await input.trigger('input') + await vi.advanceTimersByTimeAsync(500) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('search')).toBeUndefined() + input.element.value = '中文' + await input.trigger('compositionend') + expect(wrapper.emitted('update:modelValue')).toEqual([['中文']]) + await vi.advanceTimersByTimeAsync(300) + expect(wrapper.emitted('search')).toEqual([['中文']]) + }) + + it('still updates plain text immediately and debounces searches', async () => { + const wrapper = mountInput() + const input = wrapper.get('input') + await input.setValue('a') + await vi.advanceTimersByTimeAsync(200) + await input.setValue('ab') + expect(wrapper.emitted('update:modelValue')).toEqual([['a'], ['ab']]) + await vi.advanceTimersByTimeAsync(299) + expect(wrapper.emitted('search')).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(wrapper.emitted('search')).toEqual([['ab']]) + }) + + it('reflects external value changes without issuing a search', async () => { + const wrapper = mountInput() + await wrapper.setProps({ modelValue: 'restored' }) + expect(wrapper.get('input').element.value).toBe('restored') + await vi.advanceTimersByTimeAsync(500) + expect(wrapper.emitted('search')).toBeUndefined() + }) +}) From f3a2dcabba65be265200c880471ee0e029cf09ee Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 19 Sep 2026 19:43:23 +0800 Subject: [PATCH 057/142] fix(users): ignore superseded balance history requests --- .../admin/user/UserBalanceHistoryModal.vue | 11 ++- .../__tests__/UserBalanceHistoryModal.spec.ts | 82 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/admin/user/__tests__/UserBalanceHistoryModal.spec.ts diff --git a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue index 4bce43ad5379..b9e59433b6b3 100644 --- a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue +++ b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue @@ -172,7 +172,7 @@ diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index 280725bc5b65..53101d8f9267 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -3,11 +3,13 @@ import { flushPromises, mount } from '@vue/test-utils' import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import type { Account } from '@/types' -import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts' +import { refreshOpenAIQuota, resetOpenAIQuota, type OpenAIQuotaRefreshResult } from '@/api/admin/accounts' vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIQuota: vi.fn(), resetOpenAIQuota: vi.fn(), + refreshOpenAIReferrals: vi.fn(), + sendOpenAIReferralInvite: vi.fn(), })) vi.mock('vue-i18n', async () => { @@ -64,6 +66,89 @@ beforeEach(() => { vi.mocked(resetOpenAIQuota).mockReset() }) +describe('OpenAIQuotaResetCell — Codex 点数', () => { + const points = (wrapper: ReturnType) => wrapper.get('[data-testid="codex-credits"]') + const balance = { has_credits: true, unlimited: false, balance: '12345678901234567890.0123' } + + it.each([ + { name: 'decimal precision', credits: balance, expected: balance.balance }, + { name: 'zero', credits: { has_credits: false, unlimited: false, balance: '0' }, expected: '0' }, + { name: 'unlimited takes precedence', credits: { has_credits: false, unlimited: true, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsUnlimited' }, + { name: 'hidden balance', credits: { has_credits: true, unlimited: false, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'invalid balance', credits: { has_credits: true, unlimited: false, balance: 'NaN' }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'absent credits', credits: undefined, expected: '—' }, + { name: 'null credits', credits: null, expected: '—' }, + ])('queries and displays $name independently of reset cards', async ({ credits, expected }) => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits, fetched_at: 1770000000, cache_persisted: false, credits_cache_persisted: true, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + expect(points(wrapper).text()).toContain('—') + await points(wrapper).trigger('click') + await flushPromises() + expect(refreshOpenAIQuota).toHaveBeenCalledWith(1) + expect(points(wrapper).text()).toContain(expected) + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + expect(resetOpenAIQuota).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('rehydrates points with their query timestamp without an upstream request', () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + expect(points(wrapper).text()).toContain(balance.balance) + expect(points(wrapper).attributes('title')).toContain('admin.accounts.openaiQuotaReset.pointsUpdatedAt:') + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('keeps the last balance on a request failure, but clears it on a successful unknown response', async () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + vi.mocked(refreshOpenAIQuota).mockRejectedValueOnce(new Error('network unavailable')) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('network unavailable') + + vi.mocked(refreshOpenAIQuota).mockResolvedValueOnce({ fetched_at: 1770000001, cache_persisted: true, credits_cache_persisted: true }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(wrapper.text()).not.toContain(balance.balance) + wrapper.unmount() + }) + + it('reports a points-cache write failure while displaying the live balance', async () => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: false, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') + wrapper.unmount() + }) + + it('does not apply a pending response to another account row', async () => { + let resolve!: (value: OpenAIQuotaRefreshResult) => void + vi.mocked(refreshOpenAIQuota).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + expect(points(wrapper).attributes('disabled')).toBeDefined() + await wrapper.setProps({ account: makeAccount({ id: 2 }) }) + resolve({ credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: true }) + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(points(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) +}) + describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => { const account = makeAccount({ parent_account_id: 100 }) @@ -242,6 +327,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { cache_refreshed: true, account_state_recovered: true, quota: { + credits: { has_credits: true, unlimited: false, balance: '999.25' }, rate_limit_reset_credits: { available_count: 0, credits: [], @@ -267,6 +353,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { expect(resetOpenAIQuota).toHaveBeenCalledWith(1) expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="codex-credits"]').text()).toContain('999.25') expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:') expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess') expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]]) diff --git a/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts new file mode 100644 index 000000000000..f8bb1d991554 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import OpenAIReferralCell from '../OpenAIReferralCell.vue' +import type { Account } from '@/types' +import type { OpenAIReferralEligibility, OpenAIReferralRefreshResult } from '@/types/openaiReferrals' +import { refreshOpenAIReferrals, sendOpenAIReferralInvite } from '@/api/admin/accounts' + +vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIReferrals: vi.fn(), sendOpenAIReferralInvite: vi.fn() })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => params ? `${key}:${Object.values(params).join(',')}` : key }) })) + +const eligibility: OpenAIReferralEligibility = { + should_show: true, remaining_send_capacity: 3, remaining_reward_capacity: 2, + requires_explicit_confirmation: true, program_id: 'codex_referral_consumer', available_invites: 2, + fetched_at: 1770000000, title: 'Test offer', rules: ['Test eligibility rule'], +} +const account = (overrides: Partial = {}) => ({ + id: 1, name: 'Test account', platform: 'openai', type: 'oauth', + ...overrides, +}) as Account +const mountCell = (value = account()) => mount(OpenAIReferralCell, { + props: { account: value }, global: { stubs: { teleport: true } }, +}) +const open = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() +} +const completeForm = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) +} + +beforeEach(() => { + vi.mocked(refreshOpenAIReferrals).mockReset().mockResolvedValue({ eligibility: { ...eligibility }, cache_persisted: true }) + vi.mocked(sendOpenAIReferralInvite).mockReset() +}) + +describe('OpenAIReferralCell', () => { + it('shows cached capacity without sending requests, then refreshes when opening the form', async () => { + const wrapper = mountCell(account({ extra: { codex_referral_snapshot: eligibility } })) + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('2') + expect(refreshOpenAIReferrals).not.toHaveBeenCalled() + await open(wrapper) + expect(refreshOpenAIReferrals).toHaveBeenCalledWith(1) + expect(wrapper.text()).toContain('Test account') + expect(wrapper.text()).toContain('Test eligibility rule') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('requires a valid email and consent, and clears consent when the recipient changes', async () => { + const wrapper = mountCell() + await open(wrapper) + await wrapper.get('[data-testid="referral-email"]').setValue('invalid') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + expect((wrapper.get('[data-testid="referral-consent"]').element as HTMLInputElement).checked).toBe(false) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it.each([0, null])('blocks sends when remaining capacity is %s', async (capacity) => { + vi.mocked(refreshOpenAIReferrals).mockResolvedValue({ eligibility: { ...eligibility, available_invites: capacity }, cache_persisted: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.unavailable') + wrapper.unmount() + }) + + it('sends once and displays the recipient and refreshed count', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ + sent: true, email: 'friend@example.com', eligibility: { ...eligibility, available_invites: 1 }, cache_persisted: true, refresh_failed: false, + }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(sendOpenAIReferralInvite).toHaveBeenCalledWith(1, { email: 'friend@example.com', program_id: 'codex_referral_consumer', confirmed: true }) + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('1') + expect((wrapper.get('[data-testid="referral-email"]').element as HTMLInputElement).value).toBe('') + wrapper.unmount() + }) + + it('keeps send success when the capacity refresh fails and disables another send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ sent: true, email: 'friend@example.com', eligibility: null, cache_persisted: true, refresh_failed: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.refreshFailed') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + await completeForm(wrapper) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('does not automatically retry an uncertain send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockRejectedValue({ reason: 'OPENAI_REFERRAL_SEND_UNKNOWN' }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sendUnknown') + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('prevents sending from a shadow account', () => { + const wrapper = mountCell(account({ parent_account_id: 100 })) + expect(wrapper.get('[data-testid="referral-open"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-testid="referral-open"]').attributes('title')).toContain('shadowHint') + wrapper.unmount() + }) + + it('does not apply a pending query to a different account', async () => { + let resolve!: (value: OpenAIReferralRefreshResult) => void + vi.mocked(refreshOpenAIReferrals).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mountCell() + await wrapper.get('[data-testid="referral-count"]').trigger('click') + await wrapper.setProps({ account: account({ id: 2 }) }) + resolve({ eligibility, cache_persisted: true }) + await flushPromises() + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + wrapper.unmount() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 8c675a1c31cd..b25f0f8c8700 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1585,13 +1585,44 @@ export default { estimatedTotalCost: 'Est. total ${cost}', estimatedTotalCostTooltip: 'Estimated total cost at 100% utilization, based on current window cost and utilization' }, + openaiReferral: { + available: 'Invites left', + invite: 'Invite user', + fromAccount: 'Inviting account:', + personal: 'Invite a friend', + workspace: 'Invite a coworker', + email: 'Recipient email', + consent: 'I have this person’s consent to send them an invitation.', + send: 'Send invitation', + sending: 'Sending…', + sent: 'Invitation sent to {email}', + queryHint: 'Click to query remaining invitations', + checkedAt: 'Checked: {time}. Click to refresh.', + unavailable: 'Invitations are unavailable. Eligibility requirements may not be met, or the limit has been reached.', + invalidEmail: 'Enter one valid email address.', + rejected: 'The invitation was rejected. Check the email and offer eligibility.', + alreadyInvited: 'An invitation already exists for this email. Check its status in Codex.', + rateLimited: 'The invitation rate or capacity limit has been reached. Try again later.', + sendUnknown: 'The invitation outcome is unknown. Check its status in Codex before deciding whether to retry.', + programChanged: 'The account’s referral program changed. Refresh eligibility before sending.', + consentRequired: 'Confirm that you have the recipient’s consent first.', + shadowHint: 'Send invitations from the parent account.', + cacheFailed: 'Live capacity was fetched, but the cache could not be saved. Query again.', + refreshFailed: 'The invitation was sent, but remaining capacity could not be refreshed. Query again.' + }, openaiQuotaReset: { - count: 'Credits', + count: 'Resets', + points: 'Points', + pointsUnlimited: 'Unlimited', + pointsAvailable: 'Available', + pointsTooltip: 'Click to query Codex points and reset credits', + pointsUpdatedAt: 'Balance checked: {time}', + pointsCachePersistFailed: 'Showing live points, but the cache could not be saved. Query again.', reset: 'Reset', - countTooltipLoad: 'Click to load the available reset-credit count', - countTooltipRefresh: 'Click to refresh the available reset-credit count', + countTooltipLoad: 'Click to load the available reset-credit count and points balance', + countTooltipRefresh: 'Click to refresh the available reset-credit count and points balance', resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', - resetTooltipNeedQuery: 'Click Credits first to load the available count', + resetTooltipNeedQuery: 'Click Resets first to load the available count', resetTooltipNoCredits: 'No reset credits available', resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account', expiresAt: 'Expires {time}', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index a93e5484212d..fc4bb944269c 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -503,11 +503,42 @@ export default { estimatedTotalCost: '预计总费用 ${cost}', estimatedTotalCostTooltip: '根据当前窗口费用和使用率估算达到 100% 使用率时的总费用' }, + openaiReferral: { + available: '可邀请', + invite: '邀请用户', + fromAccount: '邀请账号:', + personal: '邀请朋友', + workspace: '邀请同事', + email: '受邀人邮箱', + consent: '我已取得此人的同意,可以向其发送邀请。', + send: '发送邀请', + sending: '正在发送…', + sent: '邀请已发送至 {email}', + queryHint: '点击查询剩余可邀请次数', + checkedAt: '查询时间:{time},点击刷新', + unavailable: '当前无法邀请用户,可能未满足活动条件或次数已用完。', + invalidEmail: '请输入一个有效的邮箱地址。', + rejected: '上游拒绝了邀请,请检查邮箱和活动资格。', + alreadyInvited: '此邮箱已有邀请,请在 Codex 中查看邀请状态。', + rateLimited: '邀请频率或次数已达上限,请稍后再试。', + sendUnknown: '暂时无法确认邀请是否已发送,请先在 Codex 中核对邀请状态,再决定是否重试。', + programChanged: '账号的邀请计划已变化,请刷新邀请资格后再发送。', + consentRequired: '请先确认已取得受邀人的同意。', + shadowHint: '请在母账号上发送邀请。', + cacheFailed: '实时次数已获取,但缓存保存失败,请重新查询。', + refreshFailed: '邀请已发送,但剩余次数刷新失败,请重新查询。' + }, openaiQuotaReset: { count: '次数', + points: '点数', + pointsUnlimited: '无限', + pointsAvailable: '可用', + pointsTooltip: '点击查询 Codex 点数余额和重置次数', + pointsUpdatedAt: '余额查询时间:{time}', + pointsCachePersistFailed: '已显示实时点数,但缓存保存失败,请重新查询。', reset: '重置', - countTooltipLoad: '点击查询剩余重置次数', - countTooltipRefresh: '点击刷新剩余重置次数', + countTooltipLoad: '点击查询剩余重置次数和点数余额', + countTooltipRefresh: '点击刷新剩余重置次数和点数余额', resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', resetTooltipNoCredits: '没有可用的重置次数', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3ae9838133d7..80cef707bd91 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1179,6 +1179,11 @@ export interface Account { available_count?: number credits?: { expires_at?: string }[] } + codex_credits_snapshot?: { + credits: { has_credits: boolean; unlimited: boolean; balance: string | null } | null + fetched_at: number + } + codex_referral_snapshot?: import('./openaiReferrals').OpenAIReferralEligibility | null auto_reset_credit_enabled?: boolean auto_reset_credit_5h_threshold?: number auto_reset_credit_7d_threshold?: number diff --git a/frontend/src/types/openaiReferrals.ts b/frontend/src/types/openaiReferrals.ts new file mode 100644 index 000000000000..a9ef1af965a1 --- /dev/null +++ b/frontend/src/types/openaiReferrals.ts @@ -0,0 +1,25 @@ +export interface OpenAIReferralEligibility { + should_show: boolean + remaining_send_capacity: number | null + remaining_reward_capacity: number | null + requires_explicit_confirmation: boolean | null + offer_id?: string + title?: string + description?: string + rules?: string[] + grants?: { grant_type: string; recipient: string; amount: number }[] + program_id: string + available_invites: number | null + fetched_at: number +} + +export interface OpenAIReferralRefreshResult { + eligibility: OpenAIReferralEligibility | null + cache_persisted: boolean +} + +export interface OpenAIReferralSendResult extends OpenAIReferralRefreshResult { + email: string + sent: boolean + refresh_failed: boolean +} From e50993738285b1895195f7ef8a26eb34fca1544d Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 16 Sep 2026 02:23:16 +0800 Subject: [PATCH 065/142] docs: add Codex referral UI validation screenshots --- .gitignore | 2 ++ docs/codex-credits.md | 8 ++++++++ docs/screenshots/codex-referral-dialog.png | Bin 0 -> 30837 bytes docs/screenshots/codex-referral-success.png | Bin 0 -> 33194 bytes 4 files changed, 10 insertions(+) create mode 100644 docs/screenshots/codex-referral-dialog.png create mode 100644 docs/screenshots/codex-referral-success.png diff --git a/.gitignore b/.gitignore index e017d0c07de9..5a7dd4e8c954 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,8 @@ docs/* !docs/screenshots/ docs/screenshots/* !docs/screenshots/mobile-account-actions-menu.png +!docs/screenshots/codex-referral-dialog.png +!docs/screenshots/codex-referral-success.png .serena/ .codex/ frontend/coverage/ diff --git a/docs/codex-credits.md b/docs/codex-credits.md index 2b7c48a7fdff..9abc17ca45bd 100644 --- a/docs/codex-credits.md +++ b/docs/codex-credits.md @@ -49,3 +49,11 @@ Content-Type: application/json 活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 发送成功后,后端以有时限的独立上下文刷新次数。回读失败仍返回 `sent: true`、`refresh_failed: true`,并尝试清空过期快照。发送请求不自动重试;网络错误或无法确认的响应要求先在 Codex 核对状态。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 + +### 界面测试 + +以下截图使用真实 Vue 组件和模拟 API 响应,不包含真实账号凭证,也没有发送邮件。 + +![邀请弹窗与同意确认](screenshots/codex-referral-dialog.png) + +![模拟发送成功后刷新剩余次数](screenshots/codex-referral-success.png) diff --git a/docs/screenshots/codex-referral-dialog.png b/docs/screenshots/codex-referral-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ec8a019d40a27ff57baf4df7347e4fad0c29f4 GIT binary patch literal 30837 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 BNg4nE literal 0 HcmV?d00001 diff --git a/docs/screenshots/codex-referral-success.png b/docs/screenshots/codex-referral-success.png new file mode 100644 index 0000000000000000000000000000000000000000..e443315e8b6bb0855abde9c0f8384f643e46ffb4 GIT binary patch literal 33194 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

vUv55m^TE3m5Zg^Dv{t*2^c*sK?bI-4g zUbRL;gZBLf*EqL<99yp%_I>Le)9M*h`COciA}so*d05TB_V1Y8@R9n{lMF zh&ko?e`U!7@)hUdeakNSeWkY;AcQnmL z_!usG!O)kvt@jkz$y0#pvlicHOgk>EMp^U(5iZOoVET{@BqhyH!%n|b@%pctb;|>V z)z0s>M})k)_~2xd?Us|uw+@WFDm?_Skyrh#baJl(R8(!BS?MJ~srp!btER$PNQ6(x zh?2)8>@Fd@QCTG*P_&atq9@X$NI*+|P))i&`;;B-$(mn^7{ z%hrI?LGm4s9WRB#Jy6|(P!ts$;!b3!rdWX19VPAN7)ydg7uT%Yj;ixSa#Pmp8{qYX zLxJC(iI4k@ZKJLnH2L}KU-hg0#!lPzzP@rLPG#@sy4r`Yx+&^=1w}>rXO#~0drTI` zI=z@`75(Oo5-%6n!(Ke~Br9$w|3q*PklgOr)ERj9x54$_O-EM?>M_x(KDLAgPpJ>Y z^6*zpFCHpw0v>XBKi5zFsdN#Y_(PcG!Jy2VpN6P>I8rlBg-x&`DC>C!zwi8HRdjO_ zzQW4R$@Tf3il+T$7$6$l3I)xOOMqoSpVz4?Gr|X$#ID&TzkC z-yCvUdGe7`5tqB8UAQQowdf5G2}vr_wy`V#*{a%|R(-7MD`!%BeDCU3-2;E)gY_%B zZ`*M^4G?$4{`)J)&LfY2=iVslTjg{o=znH^P~>&@*j9F)LE12OE1QPKb>0Ds Date: Sun, 20 Sep 2026 14:39:36 +0800 Subject: [PATCH 066/142] fix(account): preserve settings on OAuth reauth --- .../internal/handler/admin/account_handler.go | 4 ++ ...count_handler_long_context_billing_test.go | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index f97dc4886a7c..52500c42220a 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1609,6 +1609,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { return } + // Re-auth only returns authentication fields. Preserve account configuration + // stored alongside them (for example model_mapping), while allowing the new + // OAuth values to replace their existing counterparts. + req.Credentials = service.MergeCredentials(existing.Credentials, req.Credentials) // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk. req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials) diff --git a/backend/internal/handler/admin/account_handler_long_context_billing_test.go b/backend/internal/handler/admin/account_handler_long_context_billing_test.go index 4d40e93bf7b9..db4e70661196 100644 --- a/backend/internal/handler/admin/account_handler_long_context_billing_test.go +++ b/backend/internal/handler/admin/account_handler_long_context_billing_test.go @@ -142,6 +142,44 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta require.Zero(t, stub.updateAccountExtraCalls) } +func TestApplyOAuthCredentialsPreservesExistingNonAuthCredentials(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := newStubAdminService() + stub.getAccountResult = &service.Account{ + ID: 1, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "old-token", + "refresh_token": "old-refresh-token", + "model_mapping": map[string]any{"gpt-5": "gpt-5"}, + "account_id": "existing-account-id", + "password": "must-not-survive", + "sso_token": "must-not-survive", + "cookie": "must-not-survive", + }, + } + handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.POST("/accounts/:id/apply-oauth-credentials", handler.ApplyOAuthCredentials) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/accounts/1/apply-oauth-credentials", bytes.NewBufferString( + `{"type":"oauth","credentials":{"access_token":"new-token","refresh_token":"new-refresh-token"}}`, + )) + request.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, stub.updateAccountCalls) + require.Equal(t, map[string]any{ + "access_token": "new-token", + "refresh_token": "new-refresh-token", + "model_mapping": map[string]any{"gpt-5": "gpt-5"}, + "account_id": "existing-account-id", + }, stub.lastUpdateAccountInput.Credentials) +} + func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) { gin.SetMode(gin.TestMode) handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) From e009ea303602d07a11f34e72f0fab3fa8c67d578 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 20 Sep 2026 14:46:25 +0800 Subject: [PATCH 067/142] =?UTF-8?q?fix(upstream):=20=E6=81=A2=E5=A4=8D=20O?= =?UTF-8?q?penAI=20HTTP/2=20=E4=BF=9D=E6=B4=BB=E5=AE=B9=E9=94=99=E6=9C=9F?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/repository/http_upstream.go | 11 +- .../http_upstream_http2_keepalive_test.go | 114 ++++++---- .../http_upstream_http2_ping_test.go | 205 ++++++++++++++++++ 3 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 backend/internal/repository/http_upstream_http2_ping_test.go diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index e073659cc269..f765d0f66e07 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -76,6 +76,9 @@ const ( defaultOpenAIHTTP2FallbackErrorThreshold = 2 defaultOpenAIHTTP2FallbackWindow = 60 * time.Second defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute + // OpenAI 保留原有的探测与应答期限,避免中转站的延迟 PING 应答被长流策略提前判死。 + openAIHTTP2ReadIdleTimeout = 15 * time.Second + openAIHTTP2PingTimeout = 15 * time.Second // 长流 HTTP/2 连接健康探测:池化连接被代理/NAT // 静默掐断会成为“死连接”(两端都以为存活),请求落上去会挂到 TCP 重传超时 // (分钟级)。Go 的 http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING), @@ -1340,7 +1343,7 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo transport.ForceAttemptHTTP2 = true // 显式配置 http2 并启用 PING 健康探测,剔除代理/NAT 静默掐断的死连接, // 避免请求挂在死连接上直到 TCP 重传超时(分钟级)。 - if _, err := enableHTTP2KeepAlive(transport); err != nil { + if _, err := enableHTTP2KeepAlive(transport, protocolMode); err != nil { return nil, err } case upstreamProtocolModeOpenAIH1: @@ -1361,7 +1364,7 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo // Go 默认惰性配置 http2 且 ReadIdleTimeout=0(不发健康 PING),无法检测被代理/NAT // 静默掐断的死连接。此处主动设置 ReadIdleTimeout/PingTimeout,让死连接被提前 PING // 出并关闭,请求得以重建连接而非挂到 TCP 重传超时。返回底层 *http2.Transport 便于测试。 -func enableHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { +func enableHTTP2KeepAlive(transport *http.Transport, protocolMode string) (*http2.Transport, error) { h2, err := http2.ConfigureTransports(transport) if err != nil { return nil, err @@ -1369,6 +1372,10 @@ func enableHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { if h2 != nil { h2.ReadIdleTimeout = longStreamHTTP2ReadIdleTimeout h2.PingTimeout = longStreamHTTP2PingTimeout + if protocolMode == upstreamProtocolModeOpenAIH2 { + h2.ReadIdleTimeout = openAIHTTP2ReadIdleTimeout + h2.PingTimeout = openAIHTTP2PingTimeout + } } return h2, nil } diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index 29acf4b63187..249f246ff891 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -2,6 +2,7 @@ package repository import ( "context" + "crypto/tls" "crypto/x509" "net/http" "net/http/httptest" @@ -38,16 +39,24 @@ func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { // 必须显式启用主动 PING 探测,让死连接被提前剔除,而不是只靠 ResponseHeaderTimeout // 事后兜底。 func TestEnableHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { - tr := &http.Transport{} - - h2, err := enableHTTP2KeepAlive(tr) - require.NoError(t, err) - require.NotNil(t, h2, "必须返回已配置的 *http2.Transport") - - require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") - require.Equal(t, longStreamHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) - require.Equal(t, longStreamHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") - requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") + for _, tc := range []struct { + mode string + readIdleTimeout time.Duration + pingTimeout time.Duration + }{ + {upstreamProtocolModeOpenAIH2, 15 * time.Second, 15 * time.Second}, + {upstreamProtocolModeLongStreamH2, 10 * time.Second, 5 * time.Second}, + } { + t.Run(tc.mode, func(t *testing.T) { + tr := &http.Transport{} + h2, err := enableHTTP2KeepAlive(tr, tc.mode) + require.NoError(t, err) + require.NotNil(t, h2, "必须返回已配置的 *http2.Transport") + require.Equal(t, tc.readIdleTimeout, h2.ReadIdleTimeout) + require.Equal(t, tc.pingTimeout, h2.PingTimeout, "各模式应使用独立的 PING 应答期限") + requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") + }) + } } // long_stream_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 @@ -58,51 +67,70 @@ func TestBuildUpstreamTransport_LongStreamH2_EnablesPingHealthCheck(t *testing.T requireHTTP2Configured(t, tr, "long_stream_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } -// 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, -// 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 -func TestBuildUpstreamTransport_NonLongStreamH2_NotEagerlyConfigured(t *testing.T) { - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) - require.NoError(t, err) - require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") - require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") +// 默认、Grok 和显式 H1 模式不应主动启用 HTTP/2 保活,避免影响其他平台的传输策略。 +func TestBuildUpstreamTransport_NonHTTP2_NotEagerlyConfigured(t *testing.T) { + for _, mode := range []string{upstreamProtocolModeDefault, upstreamProtocolModeGrok, upstreamProtocolModeOpenAIH1, upstreamProtocolModeOpenAIH1Fallback} { + t.Run(mode, func(t *testing.T) { + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, mode) + require.NoError(t, err) + require.Nil(t, tr.Protocols, "非 H2 模式不应主动配置 http2 keepalive") + require.Nil(t, tr.TLSNextProto["h2"], "非 H2 模式不应主动配置 http2 keepalive") + }) + } } -// long_stream_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: -// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableHTTP2KeepAlive 的显式配置。 -func TestBuildUpstreamTransport_LongStreamH2_NegotiatesHTTP2(t *testing.T) { +// 使用同时支持 H1/H2 的 TLS 上游核对实际协议,确保保活配置不会改变其他模式的协商行为。 +func TestBuildUpstreamTransport_NegotiatesExpectedProtocol(t *testing.T) { srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) srv.EnableHTTP2 = true srv.StartTLS() defer srv.Close() - - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2) - require.NoError(t, err) - defer tr.CloseIdleConnections() - require.NotNil(t, tr.TLSClientConfig) - roots := x509.NewCertPool() - roots.AddCert(srv.Certificate()) - tr.TLSClientConfig.RootCAs = roots - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) - require.NoError(t, err) - resp, err := tr.RoundTrip(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, resp.ProtoMajor, "long_stream_h2 必须协商到 HTTP/2") + for _, tc := range []struct { + mode string + wantMajor int + }{ + {upstreamProtocolModeOpenAIH2, 2}, + {upstreamProtocolModeLongStreamH2, 2}, + {upstreamProtocolModeDefault, 1}, + {upstreamProtocolModeGrok, 1}, + {upstreamProtocolModeOpenAIH1, 1}, + {upstreamProtocolModeOpenAIH1Fallback, 1}, + } { + t.Run(tc.mode, func(t *testing.T) { + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, tc.mode) + require.NoError(t, err) + defer tr.CloseIdleConnections() + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + if tr.TLSClientConfig == nil { + tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + tr.TLSClientConfig.RootCAs = roots + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := tr.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tc.wantMajor, resp.ProtoMajor) + }) + } } // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 -func TestBuildUpstreamTransport_LongStreamH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { +func TestBuildUpstreamTransport_HTTP2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { proxyURL, err := url.Parse("http://127.0.0.1:8080") require.NoError(t, err) - - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeLongStreamH2) - require.NoError(t, err) - require.True(t, tr.ForceAttemptHTTP2) - requireHTTP2Configured(t, tr, "经代理的 long_stream_h2 也必须启用 http2 keepalive") - require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") + for _, mode := range []string{upstreamProtocolModeOpenAIH2, upstreamProtocolModeLongStreamH2} { + t.Run(mode, func(t *testing.T) { + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, mode) + require.NoError(t, err) + require.True(t, tr.ForceAttemptHTTP2) + requireHTTP2Configured(t, tr, "经代理的 H2 模式也必须启用 http2 keepalive") + require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") + }) + } } diff --git a/backend/internal/repository/http_upstream_http2_ping_test.go b/backend/internal/repository/http_upstream_http2_ping_test.go new file mode 100644 index 000000000000..2f7f5cc71e9a --- /dev/null +++ b/backend/internal/repository/http_upstream_http2_ping_test.go @@ -0,0 +1,205 @@ +package repository + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +// 使用真实传输构建器和 HTTP/2 帧验证保活行为;虚拟时间保持生产超时值且无需实际等待。 +func TestBuildUpstreamTransport_HTTP2PingPolicy(t *testing.T) { + for _, tc := range []struct { + name string + mode string + ackDelay time.Duration + finishDelay time.Duration + headersEarly bool + wantLost bool + wantElapsed time.Duration + }{ + {"OpenAI延迟应答", upstreamProtocolModeOpenAIH2, 6 * time.Second, 22 * time.Second, false, false, 22 * time.Second}, + {"OpenAI已开始输出后延迟应答", upstreamProtocolModeOpenAIH2, 6 * time.Second, 22 * time.Second, true, false, 22 * time.Second}, + {"OpenAI正常应答但长时间无业务输出", upstreamProtocolModeOpenAIH2, 0, 40 * time.Second, false, false, 40 * time.Second}, + {"OpenAI无应答且未收到响应头", upstreamProtocolModeOpenAIH2, -1, time.Minute, false, true, 30 * time.Second}, + {"OpenAI无应答且已开始输出", upstreamProtocolModeOpenAIH2, -1, time.Minute, true, true, 30 * time.Second}, + {"长流保持原有超时", upstreamProtocolModeLongStreamH2, 6 * time.Second, 22 * time.Second, false, true, 15 * time.Second}, + {"长流正常应答", upstreamProtocolModeLongStreamH2, 0, 40 * time.Second, false, false, 40 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer func() { require.NoError(t, clientConn.Close()) }() + defer func() { require.NoError(t, serverConn.Close()) }() + peerDone := make(chan error, 1) + go func() { + err := serveHTTP2PingTestPeer(serverConn, tc.ackDelay, tc.finishDelay, tc.headersEarly) + // 业务结果读完后客户端会关闭连接,此时尚在发送的 PING 应答可正常退出。 + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe) { + err = nil + } + peerDone <- err + }() + + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, tc.mode) + require.NoError(t, err) + defer tr.CloseIdleConnections() + // 仅把 TLS 建连替换为内存 HTTP/2 连接,保活参数仍来自生产构建器。 + tr.Protocols.SetHTTP1(false) + tr.Protocols.SetUnencryptedHTTP2(true) + tr.DialContext = func(context.Context, string, string) (net.Conn, error) { + return clientConn, nil + } + client := &http.Client{Transport: tr, Timeout: 50 * time.Second} + started := time.Now() + resp, requestErr := client.Post("http://upstream.test/v1/responses", "application/json", strings.NewReader(`{"stream":true}`)) + resultErr := requestErr + var body []byte + if resp != nil { + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, resp.ProtoMajor) + body, resultErr = io.ReadAll(resp.Body) + require.NoError(t, resp.Body.Close()) + } + if tc.wantLost { + require.ErrorContains(t, resultErr, "http2: client connection lost") + if tc.headersEarly { + require.NoError(t, requestErr) + require.Equal(t, "data: first\n\n", string(body)) + } else { + require.Nil(t, resp) + } + } else { + require.NoError(t, resultErr) + require.NotNil(t, resp) + wantBody := "data: [DONE]\n\n" + if tc.headersEarly { + wantBody = "data: first\n\n" + wantBody + } + require.Equal(t, wantBody, string(body)) + } + require.Equal(t, tc.wantElapsed, time.Since(started)) + require.NoError(t, clientConn.Close()) + require.NoError(t, <-peerDone) + }) + }) + } +} + +// 对端持续读取请求,只对 PING 的应答施加延迟;业务响应独立于 PING 定时发送。 +func serveHTTP2PingTestPeer(conn net.Conn, ackDelay, finishDelay time.Duration, headersEarly bool) error { + framer := http2.NewFramer(conn, conn) + type readResult struct { + frame http2.Frame + err error + } + // 缓冲握手帧,避免 net.Pipe 双向无缓冲写入造成 SETTINGS 握手互等。 + frames := make(chan readResult, 16) + done := make(chan struct{}) + defer close(done) + go func() { + preface := make([]byte, len(http2.ClientPreface)) + _, err := io.ReadFull(conn, preface) + if err == nil && string(preface) != http2.ClientPreface { + err = errors.New("HTTP/2 客户端前言不匹配") + } + for { + var frame http2.Frame + if err == nil { + frame, err = framer.ReadFrame() + } + select { + case frames <- readResult{frame, err}: + case <-done: + return + } + if err != nil { + return + } + } + }() + if err := framer.WriteSettings(); err != nil { + return err + } + sendHeaders := func(streamID uint32) error { + var block bytes.Buffer + encoder := hpack.NewEncoder(&block) + for _, field := range []hpack.HeaderField{{Name: ":status", Value: "200"}, {Name: "content-type", Value: "text/event-stream"}} { + if err := encoder.WriteField(field); err != nil { + return err + } + } + return framer.WriteHeaders(http2.HeadersFrameParam{StreamID: streamID, BlockFragment: block.Bytes(), EndHeaders: true}) + } + var finishCh, ackCh <-chan time.Time + var streamID uint32 + var pingData [8]byte + for { + select { + case result := <-frames: + if errors.Is(result.err, io.EOF) || errors.Is(result.err, net.ErrClosed) { + return nil + } + if result.err != nil { + return result.err + } + switch frame := result.frame.(type) { + case *http2.SettingsFrame: + if !frame.IsAck() { + if err := framer.WriteSettingsAck(); err != nil { + return err + } + } + case *http2.HeadersFrame: + if streamID != 0 { + return fmt.Errorf("意外重放请求:流 %d", frame.StreamID) + } + streamID = frame.StreamID + if headersEarly { + if err := sendHeaders(streamID); err != nil { + return err + } + if err := framer.WriteData(streamID, false, []byte("data: first\n\n")); err != nil { + return err + } + } + finishTimer := time.NewTimer(finishDelay) + defer finishTimer.Stop() + finishCh = finishTimer.C + case *http2.PingFrame: + if !frame.IsAck() && ackDelay >= 0 { + pingData = frame.Data + ackTimer := time.NewTimer(ackDelay) + defer ackTimer.Stop() + ackCh = ackTimer.C + } + } + case <-ackCh: + ackCh = nil + if err := framer.WritePing(true, pingData); err != nil { + return err + } + case <-finishCh: + finishCh = nil + if !headersEarly { + if err := sendHeaders(streamID); err != nil { + return err + } + } + if err := framer.WriteData(streamID, true, []byte("data: [DONE]\n\n")); err != nil { + return err + } + } + } +} From 8f6bbb59c742e9d3ce56615a8b21bf504b3a56b9 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 20 Sep 2026 15:04:15 +0800 Subject: [PATCH 068/142] test: stabilize Gemini keepalive test --- backend/internal/service/gemini_sse_comment_compat_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/gemini_sse_comment_compat_test.go b/backend/internal/service/gemini_sse_comment_compat_test.go index 360f51ad385e..c53e18ca0c35 100644 --- a/backend/internal/service/gemini_sse_comment_compat_test.go +++ b/backend/internal/service/gemini_sse_comment_compat_test.go @@ -81,7 +81,11 @@ func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle tim } func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) { - out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond) + // The keepalive interval is configured in whole seconds. Leave enough idle + // time for the first data event to be processed before the ticker fires; + // otherwise a busy CI runner can consume the first tick too early and close + // the stream before any heartbeat is written. + out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 2200*time.Millisecond) require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive") require.Contains(t, out, `"text":"partial"`) } From fc96132e7231132722f8737257f59a1ca55df347 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 20 Sep 2026 15:47:53 +0800 Subject: [PATCH 069/142] fix(openai): harden referral outcomes and isolate HTTP transport --- Makefile | 3 + backend/cmd/server/wire_gen.go | 6 +- .../handler/admin/openai_referral_handler.go | 13 +- .../admin/openai_referral_handler_test.go | 47 +++++- backend/internal/repository/account_repo.go | 2 + ...ccount_repo_codex_display_snapshot_test.go | 55 +++++++ .../repository/openai_referral_client.go | 99 +++++++++++++ .../repository/openai_referral_client_test.go | 140 ++++++++++++++++++ backend/internal/repository/wire.go | 1 + ...account_usage_service_spark_shadow_test.go | 2 +- .../service/openai_quota_credits_test.go | 2 +- .../openai_quota_reset_credits_test.go | 2 +- .../internal/service/openai_quota_service.go | 3 + .../service/openai_quota_spark_window_test.go | 18 +-- .../service/openai_referral_client.go | 19 +++ .../service/openai_referral_service.go | 74 ++------- .../service/openai_referral_service_test.go | 125 ++++++++-------- backend/internal/service/wire.go | 12 +- docs/codex-credits.md | 10 +- frontend/src/api/__tests__/client.spec.ts | 5 +- frontend/src/api/client.ts | 1 + .../components/account/OpenAIReferralCell.vue | 13 +- .../OpenAIReferralCell.transport.spec.ts | 65 ++++++++ 23 files changed, 567 insertions(+), 150 deletions(-) create mode 100644 backend/internal/repository/account_repo_codex_display_snapshot_test.go create mode 100644 backend/internal/repository/openai_referral_client.go create mode 100644 backend/internal/repository/openai_referral_client_test.go create mode 100644 backend/internal/service/openai_referral_client.go create mode 100644 frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts diff --git a/Makefile b/Makefile index fec155a8dc85..b99d6ca022f5 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/keys.bulkUpdate.spec.ts \ + src/components/account/__tests__/OpenAIReferralCell.spec.ts \ + src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \ + src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \ src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ src/views/user/__tests__/KeysView.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1c28eeb1a415..09a213e95293 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -199,14 +199,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) - openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) + openAIReferralClient := repository.NewOpenAIReferralClient(privacyClientFactory) + openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIReferralClient, openAIGatewayService) usageCache := service.NewUsageCache() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService) pluginRepository := repository.NewPluginRepository(db) pluginHostInfo := providePluginHostInfo(buildInfo) pluginKVStore := repository.NewPluginKVStore(redisClient) - pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore) - pluginManager.SetAccountDirectory(openAIGatewayService) + pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, openAIGatewayService) accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) diff --git a/backend/internal/handler/admin/openai_referral_handler.go b/backend/internal/handler/admin/openai_referral_handler.go index 4025fac65641..42a2226e58be 100644 --- a/backend/internal/handler/admin/openai_referral_handler.go +++ b/backend/internal/handler/admin/openai_referral_handler.go @@ -81,13 +81,18 @@ func (h *OpenAIOAuthHandler) SendReferralInvite(c *gin.Context) { } // The email is already sent. Refresh failure must not turn it into a failed // submission and encourage a duplicate send, even if the browser disconnects. - ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 8*time.Second) - defer cancel() - eligibility, refreshErr := h.referralService.QueryReferralEligibility(ctx, id) + baseCtx := context.WithoutCancel(c.Request.Context()) + refreshCtx, cancelRefresh := context.WithTimeout(baseCtx, 8*time.Second) + eligibility, refreshErr := h.referralService.QueryReferralEligibility(refreshCtx, id) + cancelRefresh() if refreshErr != nil { eligibility = nil } - cacheErr := h.referralService.CacheReferralSnapshot(ctx, id, eligibility) + // Refresh may exhaust its entire deadline. Give persistence (including + // invalidating a stale snapshot) a fresh, independent deadline. + cacheCtx, cancelCache := context.WithTimeout(baseCtx, 3*time.Second) + defer cancelCache() + cacheErr := h.referralService.CacheReferralSnapshot(cacheCtx, id, eligibility) response.Success(c, openAIReferralSendResponse{ OpenAIReferralSendResult: *result, openAIReferralRefreshResponse: openAIReferralRefreshResponse{Eligibility: eligibility, CachePersisted: cacheErr == nil}, diff --git a/backend/internal/handler/admin/openai_referral_handler_test.go b/backend/internal/handler/admin/openai_referral_handler_test.go index 4bfca710f407..5d0b7ad79404 100644 --- a/backend/internal/handler/admin/openai_referral_handler_test.go +++ b/backend/internal/handler/admin/openai_referral_handler_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "strings" "testing" + "testing/synctest" + "time" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -17,6 +19,8 @@ import ( ) type referralHandlerStub struct { + query func(context.Context) (*service.OpenAIReferralEligibility, error) + cache func(context.Context, *service.OpenAIReferralEligibility) error eligibility *service.OpenAIReferralEligibility queryErr, cacheErr error input service.OpenAIReferralSendRequest @@ -24,10 +28,16 @@ type referralHandlerStub struct { sends int } -func (s *referralHandlerStub) QueryReferralEligibility(context.Context, int64) (*service.OpenAIReferralEligibility, error) { +func (s *referralHandlerStub) QueryReferralEligibility(ctx context.Context, _ int64) (*service.OpenAIReferralEligibility, error) { + if s.query != nil { + return s.query(ctx) + } return s.eligibility, s.queryErr } -func (s *referralHandlerStub) CacheReferralSnapshot(_ context.Context, _ int64, e *service.OpenAIReferralEligibility) error { +func (s *referralHandlerStub) CacheReferralSnapshot(ctx context.Context, _ int64, e *service.OpenAIReferralEligibility) error { + if s.cache != nil { + return s.cache(ctx, e) + } s.cached = e return s.cacheErr } @@ -88,3 +98,36 @@ func TestOpenAIReferralHandlerRejectsInvalidInput(t *testing.T) { } require.Zero(t, stub.sends) } + +func TestOpenAIReferralHandlerInvalidatesCacheAfterRefreshDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cached := false + stub := &referralHandlerStub{ + query: func(ctx context.Context) (*service.OpenAIReferralEligibility, error) { + <-ctx.Done() + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + return nil, ctx.Err() + }, + cache: func(ctx context.Context, e *service.OpenAIReferralEligibility) error { + require.NoError(t, ctx.Err(), "cache invalidation needs a fresh deadline") + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, 3*time.Second, time.Until(deadline)) + require.Nil(t, e, "expired count must be cleared") + cached = true + return nil + }, + } + rec := referralHandlerRequest(t, stub, "/100/referrals/invite", `{"email":"friend@example.com","program_id":"codex_referral_consumer","confirmed":true}`) + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralSendResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.True(t, cached) + require.True(t, body.Data.Sent) + require.True(t, body.Data.RefreshFailed) + require.True(t, body.Data.CachePersisted) + require.Equal(t, 1, stub.sends) + }) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1c0c34fb0e6b..8b8c82dd668f 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -65,6 +65,8 @@ var schedulerNeutralExtraKeyPrefixes = []string{ var schedulerNeutralExtraKeys = map[string]struct{}{ "codex_usage_updated_at": {}, + "codex_credits_snapshot": {}, + "codex_referral_snapshot": {}, "grok_billing_snapshot": {}, "session_window_utilization": {}, } diff --git a/backend/internal/repository/account_repo_codex_display_snapshot_test.go b/backend/internal/repository/account_repo_codex_display_snapshot_test.go new file mode 100644 index 000000000000..038810603898 --- /dev/null +++ b/backend/internal/repository/account_repo_codex_display_snapshot_test.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "encoding/json" + "regexp" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestUpdateExtraCodexDisplaySnapshotsAvoidSchedulerOutbox(t *testing.T) { + for _, key := range []string{"codex_credits_snapshot", "codex_referral_snapshot"} { + for _, tc := range []struct { + name string + value any + schedulingChange bool + }{ + {"refresh", map[string]any{"fetched_at": 1770000000}, false}, + {"invalidate", nil, false}, + {"mixed scheduling update", nil, true}, + } { + t.Run(key+"/"+tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + repo := newAccountRepositoryWithSQL(client, db, nil) + updates := map[string]any{key: tc.value} + if tc.schedulingChange { + updates["openai_compact_enabled"] = true + mock.ExpectBegin() + } + payload, err := json.Marshal(updates) + require.NoError(t, err) + mock.ExpectExec(regexp.QuoteMeta("UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL")). + WithArgs(string(payload), int64(27)).WillReturnResult(sqlmock.NewResult(0, 1)) + if tc.schedulingChange { + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(27), nil, nil, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + } + // Snapshot-only changes must perform just the UPDATE, without even a + // transaction or account_changed event that would rebuild scheduler buckets. + require.NoError(t, repo.UpdateExtra(context.Background(), 27, updates)) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} diff --git a/backend/internal/repository/openai_referral_client.go b/backend/internal/repository/openai_referral_client.go new file mode 100644 index 000000000000..5589986a5442 --- /dev/null +++ b/backend/internal/repository/openai_referral_client.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "encoding/json" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/imroc/req/v3" +) + +// Desktop 26.908.40834, app-primary-44ec287874b7.js: SIt, EIt, PIt, FIt. +// Referrals use /backend-api/referrals, not /backend-api/wham. +const openAIReferralURL = "https://chatgpt.com/backend-api/referrals/invite" +const openAIReferralEntrypoint = "persistent" + +type openAIReferralClient struct { + clientFactory service.PrivacyClientFactory + baseURL string +} + +func NewOpenAIReferralClient(factory service.PrivacyClientFactory) service.OpenAIReferralClient { + return &openAIReferralClient{clientFactory: factory, baseURL: openAIReferralURL} +} + +func (c *openAIReferralClient) request(ctx context.Context, call service.OpenAIReferralCall) (*req.Request, error) { + client, err := c.clientFactory(call.ProxyURL) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_CLIENT_ERROR", "failed to build referral client") + } + // Sending has no documented idempotency key. Override any factory retries. + return client.R().SetContext(ctx).SetHeaders(call.Headers).SetRetryCount(0), nil +} + +func (c *openAIReferralClient) QueryEligibility(ctx context.Context, call service.OpenAIReferralCall) (*service.OpenAIReferralEligibility, error) { + r, err := c.request(ctx, call) + if err != nil { + return nil, err + } + resp, err := r.SetQueryParams(map[string]string{"program_id": call.ProgramID, "entrypoint": openAIReferralEntrypoint}).Get(c.baseURL + "/eligibility") + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_QUERY_FAILED", "failed to query invitation eligibility") + } + if !resp.IsSuccessState() { + return nil, referralHTTPError(resp.StatusCode) + } + var result *service.OpenAIReferralEligibility + if err := json.Unmarshal(resp.Bytes(), &result); err != nil || result == nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_INVALID_RESPONSE", "invalid invitation eligibility response") + } + return result, nil +} + +func (c *openAIReferralClient) SendInvite(ctx context.Context, call service.OpenAIReferralCall, email string) error { + r, err := c.request(ctx, call) + if err != nil { + return err + } + resp, err := r.SetBody(map[string]any{ + "program_id": call.ProgramID, "entrypoint": openAIReferralEntrypoint, "emails": []string{email}, + }).Post(c.baseURL) + if err != nil { + return referralSendUnknown() + } + if !resp.IsSuccessState() { + return referralHTTPError(resp.StatusCode) + } + var payload struct { + Invites []json.RawMessage `json:"invites"` + } + if err := json.Unmarshal(resp.Bytes(), &payload); err != nil || len(payload.Invites) != 1 || string(payload.Invites[0]) == "null" { + return referralSendUnknown() + } + return nil +} + +func referralSendUnknown() error { + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") +} + +// Upstream bodies can contain personal data or credentials. Return only stable +// error codes distinguishing validation failures, duplicates and limits. +func referralHTTPError(status int) error { + switch status { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_REJECTED", "the invitation request was rejected; check the email and eligibility") + case http.StatusUnauthorized: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "upstream authentication failed; refresh the account credentials") + case http.StatusForbidden: + return infraerrors.New(http.StatusForbidden, "OPENAI_REFERRAL_FORBIDDEN", "this account is not eligible for invitations") + case http.StatusConflict: + return infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_ALREADY_EXISTS", "an invitation already exists for this recipient") + case http.StatusTooManyRequests: + return infraerrors.New(http.StatusTooManyRequests, "OPENAI_REFERRAL_RATE_LIMITED", "invitation limit reached; try again later") + default: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_UPSTREAM_ERROR", "invitation service is unavailable") + } +} diff --git a/backend/internal/repository/openai_referral_client_test.go b/backend/internal/repository/openai_referral_client_test.go new file mode 100644 index 000000000000..1143e37e238b --- /dev/null +++ b/backend/internal/repository/openai_referral_client_test.go @@ -0,0 +1,140 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/imroc/req/v3" + "github.com/stretchr/testify/require" +) + +func referralTestClient(t *testing.T, handler http.HandlerFunc) *openAIReferralClient { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + adapter := NewOpenAIReferralClient(func(proxy string) (*req.Client, error) { + require.Equal(t, "test-proxy", proxy) + // A shared factory may have retries; the adapter must override them. + return req.C().SetCommonRetryCount(2).SetCommonRetryCondition(func(_ *req.Response, _ error) bool { return true }), nil + }) + client, ok := adapter.(*openAIReferralClient) + require.True(t, ok) + client.baseURL = srv.URL + "/backend-api/referrals/invite" + return client +} + +func referralTestCall() service.OpenAIReferralCall { + return service.OpenAIReferralCall{ProxyURL: "test-proxy", ProgramID: "codex_referral_consumer", + Headers: map[string]string{"Authorization": "Bearer test-token", "ChatGPT-Account-ID": "workspace-test"}} +} + +func TestOpenAIReferralClientProtocol(t *testing.T) { + var gets, posts atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.Equal(t, "workspace-test", r.Header.Get("ChatGPT-Account-ID")) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + gets.Add(1) + require.Equal(t, "/backend-api/referrals/invite/eligibility", r.URL.Path) + require.Equal(t, "codex_referral_consumer", r.URL.Query().Get("program_id")) + require.Equal(t, "persistent", r.URL.Query().Get("entrypoint")) + _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":8,"remaining_reward_capacity":3,"grants":[{"grant_type":"rate_limit_reset_credit","amount":1,"recipient":"referrer"}],"rules":["Offer rule"]}`)) + return + } + posts.Add(1) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/backend-api/referrals/invite", r.URL.Path) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{"program_id": "codex_referral_consumer", "entrypoint": "persistent", "emails": []any{"friend@example.com"}}, body) + _, _ = w.Write([]byte(`{"invites":[{"referral_id":"test-invite"}]}`)) + }) + result, err := client.QueryEligibility(context.Background(), referralTestCall()) + require.NoError(t, err) + require.True(t, result.ShouldShow) + require.Equal(t, 8, *result.RemainingSendCapacity) + require.Equal(t, 3, *result.RemainingRewardCapacity) + require.Equal(t, "rate_limit_reset_credit", result.Grants[0].GrantType) + require.Equal(t, []string{"Offer rule"}, result.Rules) + require.NoError(t, client.SendInvite(context.Background(), referralTestCall(), "friend@example.com")) + require.EqualValues(t, 1, gets.Load()) + require.EqualValues(t, 1, posts.Load()) +} + +func TestOpenAIReferralClientSanitizesErrorsWithoutRetry(t *testing.T) { + for _, tc := range []struct { + status int + reason string + }{ + {400, "OPENAI_REFERRAL_REJECTED"}, {422, "OPENAI_REFERRAL_REJECTED"}, + {401, "OPENAI_REFERRAL_AUTH_ERROR"}, {403, "OPENAI_REFERRAL_FORBIDDEN"}, + {409, "OPENAI_REFERRAL_ALREADY_EXISTS"}, {429, "OPENAI_REFERRAL_RATE_LIMITED"}, + {500, "OPENAI_REFERRAL_UPSTREAM_ERROR"}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"detail":"secret-token someone@example.com"}`)) + }) + _, queryErr := client.QueryEligibility(context.Background(), referralTestCall()) + sendErr := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + for _, err := range []error{queryErr, sendErr} { + require.Equal(t, tc.reason, infraerrors.Reason(err)) + require.NotContains(t, err.Error(), "secret-token") + require.NotContains(t, err.Error(), "someone@example.com") + } + require.EqualValues(t, 2, requests.Load(), "one query and one send, no retries") + }) + } +} + +func TestOpenAIReferralClientInvalidResponses(t *testing.T) { + for _, body := range []string{"null", "invalid json", `{"invites":[]}`, `{"invites":[null]}`, `{"invites":[{},{}]}`} { + t.Run(body, func(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(body)) + }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_SEND_UNKNOWN", infraerrors.Reason(err)) + require.EqualValues(t, 1, requests.Load()) + if body == "null" || body == "invalid json" { + _, err := client.QueryEligibility(context.Background(), referralTestCall()) + require.Equal(t, "OPENAI_REFERRAL_INVALID_RESPONSE", infraerrors.Reason(err)) + } + }) + } +} + +func TestOpenAIReferralClientLostResponseIsUnknownWithoutRetry(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + hijacker, ok := w.(http.Hijacker) + require.True(t, ok) + conn, _, err := hijacker.Hijack() + require.NoError(t, err) + _ = conn.Close() // Upstream received the request but its response was lost. + }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_SEND_UNKNOWN", infraerrors.Reason(err)) + require.EqualValues(t, 1, requests.Load()) +} + +func TestOpenAIReferralClientFactoryErrorIsSanitized(t *testing.T) { + client := NewOpenAIReferralClient(func(string) (*req.Client, error) { return nil, errors.New("secret proxy credentials") }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_CLIENT_ERROR", infraerrors.Reason(err)) + require.NotContains(t, err.Error(), "secret") +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 4e2b0c5f85d4..f56fee1a5892 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -162,6 +162,7 @@ var ProviderSet = wire.NewSet( NewClaudeOAuthClient, NewHTTPUpstream, NewOpenAIOAuthClient, + NewOpenAIReferralClient, NewGrokOAuthClient, NewGeminiOAuthClient, NewGeminiCliCodeAssistClient, diff --git a/backend/internal/service/account_usage_service_spark_shadow_test.go b/backend/internal/service/account_usage_service_spark_shadow_test.go index 8aa131f03a77..442ac6b4b0d5 100644 --- a/backend/internal/service/account_usage_service_spark_shadow_test.go +++ b/backend/internal/service/account_usage_service_spark_shadow_test.go @@ -117,7 +117,7 @@ func TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows(t *test })) defer srv.Close() - quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) svc := &AccountUsageService{ accountRepo: repo, openAIQuotaService: quotaService, diff --git a/backend/internal/service/openai_quota_credits_test.go b/backend/internal/service/openai_quota_credits_test.go index 9a9a6b1adac2..37f2a613b7e3 100644 --- a/backend/internal/service/openai_quota_credits_test.go +++ b/backend/internal/service/openai_quota_credits_test.go @@ -44,7 +44,7 @@ func TestQueryUsageCodexCredits(t *testing.T) { http.Error(w, "unavailable", http.StatusServiceUnavailable) })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(context.Background(), 100) require.NoError(t, err) require.Positive(t, usage.FetchedAt) diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 2deb01ed8915..02edef3e4bb5 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -175,7 +175,7 @@ func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(context.Background(), 100) require.NoError(t, err) require.NotNil(t, usage) diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 6ff3e6df8add..7f24c1d153fb 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -136,6 +136,7 @@ type OpenAIQuotaService struct { proxyRepo ProxyRepository tokenProvider *OpenAITokenProvider privacyClientFactory PrivacyClientFactory + referralClient OpenAIReferralClient agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator } @@ -148,12 +149,14 @@ func NewOpenAIQuotaService( proxyRepo ProxyRepository, tokenProvider *OpenAITokenProvider, privacyClientFactory PrivacyClientFactory, + referralClient OpenAIReferralClient, ) *OpenAIQuotaService { return &OpenAIQuotaService{ accountRepo: accountRepo, proxyRepo: proxyRepo, tokenProvider: tokenProvider, privacyClientFactory: privacyClientFactory, + referralClient: referralClient, } } diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index d5cd81767f19..8bda444bf93d 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -208,7 +208,7 @@ func TestResetCreditTargetedSendsStableCreditAndRedeemIDs(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) result, err := svc.ResetCreditTargeted(context.Background(), account.ID, "credit-123", "redeem-456") require.NoError(t, err) require.Equal(t, "ok", result.Code) @@ -261,7 +261,7 @@ func TestResetCreditAgentIdentityUsesAssertionAndRecoversInvalidTaskOnce(t *test t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) invalidator := &agentIdentityWSInvalidationRecorder{} - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) svc.agentIdentityWS = invalidator result, err := svc.ResetCredit(context.Background(), account.ID) @@ -323,7 +323,7 @@ func TestResetCreditAgentIdentityReusesConcurrentlyRecoveredTask(t *testing.T) { openAIAgentIdentityAuthAPIBaseURL = srv.URL t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) result, err := svc.ResetCredit(context.Background(), account.ID) require.NoError(t, err) require.Equal(t, "ok", result.Code) @@ -376,7 +376,7 @@ func TestPrepareUpstreamCallShadowResolve(t *testing.T) { // privacyClientFactory 可以是任意合法工厂;prepareUpstreamCall 在返回前不调用它 svc := NewOpenAIQuotaService(repo, nil, tokenProvider, func(_ string) (*req.Client, error) { return req.C(), nil - }) + }, nil) _, chatGPTAccountID, _, _, err := svc.prepareUpstreamCall(ctx, 200) require.NoError(t, err, "shadow resolve should succeed; got error: %v", err) @@ -414,7 +414,7 @@ func TestQueryUsageAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) { _, _ = w.Write([]byte(`{"plan_type":"pro","rate_limit":{"allowed":true}}`)) })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(context.Background(), account.ID) require.NoError(t, err) require.NotNil(t, usage) @@ -468,7 +468,7 @@ func TestQueryUsageAgentIdentityRecoversInvalidTaskOnce(t *testing.T) { t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) invalidator := &agentIdentityWSInvalidationRecorder{} - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) svc.agentIdentityWS = invalidator usage, err := svc.QueryUsage(context.Background(), account.ID) require.NoError(t, err) @@ -564,7 +564,7 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 100) require.NoError(t, err) require.NotNil(t, usage) @@ -625,7 +625,7 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 100) require.NoError(t, err) require.NotNil(t, usage) @@ -774,7 +774,7 @@ func TestQueryUsageShadowResolve_EndToEnd(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 200) require.NoError(t, err) require.NotNil(t, usage) diff --git a/backend/internal/service/openai_referral_client.go b/backend/internal/service/openai_referral_client.go new file mode 100644 index 000000000000..1136ccf09823 --- /dev/null +++ b/backend/internal/service/openai_referral_client.go @@ -0,0 +1,19 @@ +package service + +import "context" + +// OpenAIReferralCall carries the account's refreshed authentication and proxy +// settings to the transport adapter. Headers must never be logged or persisted. +type OpenAIReferralCall struct { + ProxyURL string + Headers map[string]string + ProgramID string +} + +// OpenAIReferralClient isolates invitation transport from eligibility policy. +// QueryEligibility returns a non-nil result on success. SendInvite must not +// retry and returns nil only when the upstream confirms one invitation. +type OpenAIReferralClient interface { + QueryEligibility(context.Context, OpenAIReferralCall) (*OpenAIReferralEligibility, error) + SendInvite(context.Context, OpenAIReferralCall, string) error +} diff --git a/backend/internal/service/openai_referral_service.go b/backend/internal/service/openai_referral_service.go index be9eeb568e47..37cc84368774 100644 --- a/backend/internal/service/openai_referral_service.go +++ b/backend/internal/service/openai_referral_service.go @@ -2,24 +2,18 @@ package service import ( "context" - "encoding/json" "net/http" "net/mail" "strings" "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/imroc/req/v3" ) -// Desktop 26.908.40834, app-primary-44ec287874b7.js: SIt, EIt, PIt, FIt. -// Referrals use /backend-api/referrals, not /backend-api/wham. const ( - openAIReferralURL = "https://chatgpt.com/backend-api/referrals/invite" openAIReferralSnapshotKey = "codex_referral_snapshot" openAIReferralConsumer = "codex_referral_consumer" openAIReferralWorkspace = "codex_referral_workspace" - openAIReferralEntrypoint = "persistent" ) type OpenAIReferralGrant struct { @@ -98,21 +92,19 @@ func referralProgram(a *Account) string { } } -func (s *OpenAIQuotaService) referralRequest(ctx context.Context, id int64) (*req.Request, error) { - token, accountID, proxy, fedRAMP, err := s.prepareUpstreamCall(ctx, id) - if err != nil { - return nil, err +func (s *OpenAIQuotaService) referralCall(ctx context.Context, id int64, program string) (OpenAIReferralCall, error) { + if s.referralClient == nil { + return OpenAIReferralCall{}, infraerrors.New(http.StatusServiceUnavailable, "OPENAI_REFERRAL_NOT_CONFIGURED", "referral service is unavailable") } - client, err := s.privacyClientFactory(proxy) + token, accountID, proxy, fedRAMP, err := s.prepareUpstreamCall(ctx, id) if err != nil { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_CLIENT_ERROR", "failed to build referral client") + return OpenAIReferralCall{}, err } headers, _, err := s.buildCodexQuotaHeaders(ctx, id, token, accountID, fedRAMP) if err != nil { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "failed to authenticate referral request") + return OpenAIReferralCall{}, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "failed to authenticate referral request") } - // Sending an invitation has no documented idempotency key. Never retry it. - return client.R().SetContext(ctx).SetHeaders(headers).SetRetryCount(0), nil + return OpenAIReferralCall{ProxyURL: proxy, Headers: headers, ProgramID: program}, nil } func (s *OpenAIQuotaService) QueryReferralEligibility(ctx context.Context, id int64) (*OpenAIReferralEligibility, error) { @@ -122,21 +114,14 @@ func (s *OpenAIQuotaService) QueryReferralEligibility(ctx context.Context, id in } callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) defer cancel() - r, err := s.referralRequest(callCtx, id) + program := referralProgram(account) + call, err := s.referralCall(callCtx, id, program) if err != nil { return nil, err } - program := referralProgram(account) - resp, err := r.SetQueryParams(map[string]string{"program_id": program, "entrypoint": openAIReferralEntrypoint}).Get(openAIReferralURL + "/eligibility") + result, err := s.referralClient.QueryEligibility(callCtx, call) if err != nil { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_QUERY_FAILED", "failed to query invitation eligibility") - } - if !resp.IsSuccessState() { - return nil, referralHTTPError(resp.StatusCode) - } - var result *OpenAIReferralEligibility - if err := json.Unmarshal(resp.Bytes(), &result); err != nil || result == nil { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_INVALID_RESPONSE", "invalid invitation eligibility response") + return nil, err } result.ProgramID = program result.AvailableInvites = referralCapacity(result) @@ -185,43 +170,12 @@ func (s *OpenAIQuotaService) SendReferralInvite(ctx context.Context, id int64, i } callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) defer cancel() - r, err := s.referralRequest(callCtx, id) + call, err := s.referralCall(callCtx, id, eligibility.ProgramID) if err != nil { return nil, err } - resp, err := r.SetBody(map[string]any{ - "program_id": eligibility.ProgramID, "entrypoint": openAIReferralEntrypoint, "emails": []string{email}, - }).Post(openAIReferralURL) - if err != nil { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") - } - if !resp.IsSuccessState() { - return nil, referralHTTPError(resp.StatusCode) - } - var payload struct { - Invites []json.RawMessage `json:"invites"` - } - if err := json.Unmarshal(resp.Bytes(), &payload); err != nil || len(payload.Invites) != 1 || string(payload.Invites[0]) == "null" { - return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") + if err := s.referralClient.SendInvite(callCtx, call, email); err != nil { + return nil, err } return &OpenAIReferralSendResult{Email: email, Sent: true}, nil } - -// Avoid exposing upstream response bodies, which can contain personal data or -// credentials. The status still distinguishes validation, duplicates and limits. -func referralHTTPError(status int) error { - switch status { - case http.StatusBadRequest, http.StatusUnprocessableEntity: - return infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_REJECTED", "the invitation request was rejected; check the email and eligibility") - case http.StatusUnauthorized: - return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "upstream authentication failed; refresh the account credentials") - case http.StatusForbidden: - return infraerrors.New(http.StatusForbidden, "OPENAI_REFERRAL_FORBIDDEN", "this account is not eligible for invitations") - case http.StatusConflict: - return infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_ALREADY_EXISTS", "an invitation already exists for this recipient") - case http.StatusTooManyRequests: - return infraerrors.New(http.StatusTooManyRequests, "OPENAI_REFERRAL_RATE_LIMITED", "invitation limit reached; try again later") - default: - return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_UPSTREAM_ERROR", "invitation service is unavailable") - } -} diff --git a/backend/internal/service/openai_referral_service_test.go b/backend/internal/service/openai_referral_service_test.go index 47efba6712d4..579a01a87b03 100644 --- a/backend/internal/service/openai_referral_service_test.go +++ b/backend/internal/service/openai_referral_service_test.go @@ -3,23 +3,44 @@ package service import ( "context" "encoding/json" + "errors" "net/http" - "net/http/httptest" "testing" + "github.com/imroc/req/v3" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) -func referralTestService(t *testing.T, plan string, handler http.HandlerFunc) (*OpenAIQuotaService, *stubQuotaAccountRepo) { +type referralClientStub struct { + eligibility *OpenAIReferralEligibility + queryErr, sendErr error + calls []OpenAIReferralCall + emails []string +} + +func (s *referralClientStub) QueryEligibility(_ context.Context, call OpenAIReferralCall) (*OpenAIReferralEligibility, error) { + s.calls = append(s.calls, call) + return s.eligibility, s.queryErr +} +func (s *referralClientStub) SendInvite(_ context.Context, call OpenAIReferralCall, email string) error { + s.calls = append(s.calls, call) + s.emails = append(s.emails, email) + return s.sendErr +} + +func referralTestService(t *testing.T, plan string, client OpenAIReferralClient) (*OpenAIQuotaService, *stubQuotaAccountRepo) { t.Helper() a := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "workspace-test", "plan_type": plan}} repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: a}} tokens := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(a): "test-token"}} - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - return NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv)), repo + factory := func(string) (*req.Client, error) { + t.Fatal("referral service must use the business interface, not the HTTP factory") + return nil, nil + } + return NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), factory, client), repo } func TestOpenAIReferralSend(t *testing.T) { @@ -28,29 +49,13 @@ func TestOpenAIReferralSend(t *testing.T) { {"self_serve_business_usage_based", openAIReferralWorkspace}, } { t.Run(tc.plan, func(t *testing.T) { - var gets, posts int - svc, repo := referralTestService(t, tc.plan, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - require.Equal(t, "workspace-test", r.Header.Get("ChatGPT-Account-ID")) - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/backend-api/referrals/invite/eligibility": - gets++ - require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, tc.program, r.URL.Query().Get("program_id")) - require.Equal(t, "persistent", r.URL.Query().Get("entrypoint")) - _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":8,"remaining_reward_capacity":3,"grants":[{"grant_type":"rate_limit_reset_credit","amount":1,"recipient":"referrer"}],"rules":["Offer rule"]}`)) - case "/backend-api/referrals/invite": - posts++ - require.Equal(t, http.MethodPost, r.Method) - var body map[string]any - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Equal(t, map[string]any{"program_id": tc.program, "entrypoint": "persistent", "emails": []any{"friend@example.com"}}, body) - _, _ = w.Write([]byte(`{"invites":[{"referral_id":"test-invite","email":"friend@example.com"}]}`)) - default: - t.Errorf("unexpected path: %s", r.URL.Path) - } - }) + send, reward := 8, 3 + client := &referralClientStub{eligibility: &OpenAIReferralEligibility{ + ShouldShow: true, RemainingSendCapacity: &send, RemainingRewardCapacity: &reward, + Grants: []OpenAIReferralGrant{{GrantType: "rate_limit_reset_credit", Amount: 1, Recipient: "referrer"}}, + Rules: []string{"Offer rule"}, + }} + svc, repo := referralTestService(t, tc.plan, client) eligibility, err := svc.QueryReferralEligibility(context.Background(), 100) require.NoError(t, err) require.Equal(t, 3, *eligibility.AvailableInvites) @@ -64,8 +69,17 @@ func TestOpenAIReferralSend(t *testing.T) { require.NoError(t, err) require.True(t, result.Sent) require.Equal(t, "friend@example.com", result.Email) - require.Equal(t, 2, gets, "send must recheck eligibility") - require.Equal(t, 1, posts) + require.Equal(t, []string{"friend@example.com"}, client.emails) + require.Len(t, client.calls, 3, "send must recheck eligibility") + for _, call := range client.calls { + require.Equal(t, tc.program, call.ProgramID) + headers := make(http.Header) + for key, value := range call.Headers { + headers.Set(key, value) + } + require.Equal(t, "Bearer test-token", headers.Get("Authorization")) + require.Equal(t, "workspace-test", headers.Get("ChatGPT-Account-ID")) + } }) } } @@ -85,48 +99,39 @@ func TestOpenAIReferralSendGuards(t *testing.T) { {"unknown capacity", "a@example.com", `{"should_show":true}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, {"reward exhausted", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"offer_id":"credits_250","remaining_reward_capacity":0}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, {"unknown reward capacity", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"grants":[{}]}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, - {"null response", "a@example.com", `null`, "OPENAI_REFERRAL_INVALID_RESPONSE", true, false}, {"shadow", "a@example.com", `{}`, "OPENAI_REFERRAL_SHADOW_ACCOUNT", true, true}, } { t.Run(tc.name, func(t *testing.T) { - posts := 0 - svc, repo := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - posts++ - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(tc.body)) - }) + client := &referralClientStub{} + require.NoError(t, json.Unmarshal([]byte(tc.body), &client.eligibility)) + svc, repo := referralTestService(t, "plus", client) if tc.shadow { parentID := int64(200) repo.accounts[100].ParentAccountID = &parentID } _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: tc.email, ProgramID: openAIReferralConsumer, Confirmed: tc.confirmed}) require.Equal(t, tc.reason, infraerrors.Reason(err)) - require.Zero(t, posts) + require.Empty(t, client.emails) }) } } -func TestOpenAIReferralSendDoesNotRetryOrExposeUpstreamErrors(t *testing.T) { - for _, status := range []int{400, 401, 403, 409, 429, 500} { - t.Run(http.StatusText(status), func(t *testing.T) { - posts := 0 - svc, _ := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":2,"requires_explicit_confirmation":false,"offer_id":"none"}`)) - return - } - posts++ - w.WriteHeader(status) - _, _ = w.Write([]byte(`{"detail":"secret-token someone@example.com"}`)) - }) - _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer}) - require.Error(t, err) - require.NotContains(t, err.Error(), "secret-token") - require.NotContains(t, err.Error(), "someone@example.com") - require.Equal(t, 1, posts) - }) +func TestOpenAIReferralSendStopsOnQueryError(t *testing.T) { + client := &referralClientStub{queryErr: errors.New("eligibility unavailable")} + svc, _ := referralTestService(t, "plus", client) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer, Confirmed: true}) + require.ErrorIs(t, err, client.queryErr) + require.Empty(t, client.emails) +} + +func TestOpenAIReferralSendPreservesUnknownOutcomeWithoutRetry(t *testing.T) { + count := 2 + client := &referralClientStub{ + eligibility: &OpenAIReferralEligibility{ShouldShow: true, RemainingSendCapacity: &count}, + sendErr: infraerrors.New(502, "OPENAI_REFERRAL_SEND_UNKNOWN", "outcome unknown"), } + svc, _ := referralTestService(t, "plus", client) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer, Confirmed: true}) + require.ErrorIs(t, err, client.sendErr) + require.Len(t, client.emails, 1) } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3944ca5f07cf..2af85ff6d806 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -174,6 +174,13 @@ func ProvideOpenAITokenProvider( return p } +// ProvidePluginManager preserves account-directory wiring when regenerating Wire. +func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore, gateway *OpenAIGatewayService) *PluginManager { + manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kvStore) + manager.SetAccountDirectory(gateway) + return manager +} + // ProvideOpenAIQuotaService wires the OpenAI quota query/reset service. // It depends on the OpenAI token provider for refreshed access tokens and the // privacy client factory for the impersonated upstream HTTP client. @@ -182,9 +189,10 @@ func ProvideOpenAIQuotaService( proxyRepo ProxyRepository, tokenProvider *OpenAITokenProvider, privacyClientFactory PrivacyClientFactory, + referralClient OpenAIReferralClient, openAIGatewayService *OpenAIGatewayService, ) *OpenAIQuotaService { - service := NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory) + service := NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory, referralClient) service.agentIdentityWS = openAIGatewayService return service } @@ -924,7 +932,7 @@ var ProviderSet = wire.NewSet( NewTotpService, NewErrorPassthroughService, NewTLSFingerprintProfileService, - NewPluginManager, + ProvidePluginManager, NewDigestSessionStore, ProvideIdempotencyCoordinator, ProvideSystemOperationLockService, diff --git a/docs/codex-credits.md b/docs/codex-credits.md index 9abc17ca45bd..2327becee920 100644 --- a/docs/codex-credits.md +++ b/docs/codex-credits.md @@ -48,7 +48,15 @@ Content-Type: application/json 活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 -发送成功后,后端以有时限的独立上下文刷新次数。回读失败仍返回 `sent: true`、`refresh_failed: true`,并尝试清空过期快照。发送请求不自动重试;网络错误或无法确认的响应要求先在 Codex 核对状态。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 +发送成功后,后端以脱离请求取消的 8 秒上下文刷新次数,再使用独立的 3 秒上下文保存结果。回读耗尽期限仍可清空过期快照,并返回 `sent: true`、`refresh_failed: true`。两个展示快照字段不触发调度事件或调度桶重建。 + +发送请求不自动重试。公共 Axios 拦截器保留网络错误码;发送时断网、超时或取消均提示结果不确定,要求先在 Codex 核对邀请状态,并禁用再次发送,直到手动刷新资格。只读查询失败显示查询错误。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 + +邀请业务通过 `service.OpenAIReferralClient` 接口调用仓储层 HTTP 适配器。Service 负责账号、计划、次数和同意校验;适配器负责代理客户端、请求协议、响应解析、错误脱敏与禁止重试,通过 Wire 注入。 + +### 自动回归检查 + +`make test-frontend` 的 CI 测试清单包含邀请组件、真实 Axios 拦截器与邀请组件联动测试、点数组件测试。后端单元测试覆盖回读期限耗尽后清理缓存、展示快照不写调度 outbox,以及邀请适配器协议、错误脱敏和断连不重试。 ### 界面测试 diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index f0c70e929180..debcb6a67e7d 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -494,9 +494,9 @@ describe('API Client', () => { // --- 网络错误 --- describe('网络错误', () => { - it('网络错误返回 status 0 的错误', async () => { + it.each(['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT', undefined])('网络错误保留错误码 %s', async (code) => { const adapter = vi.fn().mockRejectedValue({ - code: 'ERR_NETWORK', + code, message: 'Network Error', config: { url: '/test' }, // 没有 response @@ -506,6 +506,7 @@ describe('API Client', () => { await expect(apiClient.get('/test')).rejects.toEqual( expect.objectContaining({ status: 0, + code: code || 'ERR_NETWORK', message: 'Network error. Please check your connection.', }) ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 04d893d26f0f..064f8f83ee63 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -268,6 +268,7 @@ apiClient.interceptors.response.use( // Network error return Promise.reject({ status: 0, + code: error.code || 'ERR_NETWORK', message: 'Network error. Please check your connection.' }) } diff --git a/frontend/src/components/account/OpenAIReferralCell.vue b/frontend/src/components/account/OpenAIReferralCell.vue index 8496a07796ac..65df69a57f50 100644 --- a/frontend/src/components/account/OpenAIReferralCell.vue +++ b/frontend/src/components/account/OpenAIReferralCell.vue @@ -128,14 +128,19 @@ const canSend = computed(() => fresh.value && !isShadow.value && !loading.value && eligibility.value?.should_show === true && count.value !== null && count.value > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim()) && (!needsConsent.value || confirmed.value)) -function errorMessage(value: unknown): string { - const err = value as { reason?: string; code?: string; message?: string } +function errorMessage(value: unknown, duringSend = false): string { + const err = value as { status?: number; reason?: string; code?: string; message?: string } const key = err.reason || err.code || '' + // The server may have sent the email before the response was lost. Only + // sends have an uncertain outcome; a failed eligibility query is read-only. + if (duringSend && (err.status === 0 || ['ECONNABORTED', 'ETIMEDOUT', 'ERR_NETWORK', 'ERR_CANCELED'].includes(key))) { + return t('admin.accounts.openaiReferral.sendUnknown') + } const keys: Record = { OPENAI_REFERRAL_UNAVAILABLE: 'unavailable', OPENAI_REFERRAL_FORBIDDEN: 'unavailable', OPENAI_REFERRAL_INVALID_EMAIL: 'invalidEmail', OPENAI_REFERRAL_REJECTED: 'rejected', OPENAI_REFERRAL_ALREADY_EXISTS: 'alreadyInvited', OPENAI_REFERRAL_RATE_LIMITED: 'rateLimited', - OPENAI_REFERRAL_SEND_UNKNOWN: 'sendUnknown', ECONNABORTED: 'sendUnknown', + OPENAI_REFERRAL_SEND_UNKNOWN: 'sendUnknown', OPENAI_REFERRAL_PROGRAM_CHANGED: 'programChanged', OPENAI_REFERRAL_CONFIRMATION_REQUIRED: 'consentRequired', } return keys[key] ? t(`admin.accounts.openaiReferral.${keys[key]}`) : err.message || t('common.error') @@ -197,7 +202,7 @@ async function sendInvite() { } catch (err) { if (current !== generation) return fresh.value = false - error.value = errorMessage(err) + error.value = errorMessage(err, true) } finally { if (current === generation) sending.value = false } diff --git a/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts b/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts new file mode 100644 index 000000000000..4f7cf1b6db77 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { AxiosError } from 'axios' +import apiClient from '@/api/client' +import OpenAIReferralCell from '../OpenAIReferralCell.vue' +import type { Account } from '@/types' + +vi.mock('@/i18n', () => ({ getLocale: () => 'en' })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +// Exercise the actual accounts API and shared Axios interceptor together. +const originalAdapter = apiClient.defaults.adapter +const mountCell = () => mount(OpenAIReferralCell, { + props: { account: { id: 1, name: 'Test account', platform: 'openai', type: 'oauth' } as Account }, + global: { stubs: { teleport: true } }, +}) +let wrapper: ReturnType | undefined +afterEach(() => { + wrapper?.unmount() + apiClient.defaults.adapter = originalAdapter +}) + +describe('invitation transport failures', () => { + it.each(['ECONNABORTED', 'ETIMEDOUT', 'ERR_NETWORK', 'ERR_CANCELED', undefined])( + 'warns of an uncertain send after %s without retrying', async (code) => { + const adapter = vi.fn(async (config) => { + if (config.url.endsWith('/invite')) throw new AxiosError('Connection lost', code, config) + return { + status: 200, statusText: 'OK', headers: {}, config, + data: { code: 0, data: { cache_persisted: true, eligibility: { + should_show: true, available_invites: 2, requires_explicit_confirmation: true, + program_id: 'codex_referral_consumer', fetched_at: 1770000000, + } } }, + } + }) + apiClient.defaults.adapter = adapter + wrapper = mountCell() + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('admin.accounts.openaiReferral.sendUnknown') + expect(wrapper.text()).not.toContain('Network error.') + expect(wrapper.text()).not.toContain('admin.accounts.openaiReferral.sent') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(adapter.mock.calls.filter(([config]) => config.url.endsWith('/invite'))).toHaveLength(1) + } + ) + + it('keeps a query timeout separate from an uncertain send', async () => { + apiClient.defaults.adapter = vi.fn(async (config) => { + throw new AxiosError('Timed out', 'ECONNABORTED', config) + }) + wrapper = mountCell() + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Network error. Please check your connection.') + expect(wrapper.text()).not.toContain('admin.accounts.openaiReferral.sendUnknown') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + }) +}) From 95134201b2529fb8fdc759af625a3431da2d7d1e Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 20 Sep 2026 16:12:11 +0800 Subject: [PATCH 070/142] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=E8=AF=AF?= =?UTF-8?q?=E5=90=88=E5=85=A5=E7=9A=84=20Codex=20=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/codex-credits.md | 67 -------------------- docs/screenshots/codex-referral-dialog.png | Bin 30837 -> 0 bytes docs/screenshots/codex-referral-success.png | Bin 33194 -> 0 bytes 3 files changed, 67 deletions(-) delete mode 100644 docs/codex-credits.md delete mode 100644 docs/screenshots/codex-referral-dialog.png delete mode 100644 docs/screenshots/codex-referral-success.png diff --git a/docs/codex-credits.md b/docs/codex-credits.md deleted file mode 100644 index 2327becee920..000000000000 --- a/docs/codex-credits.md +++ /dev/null @@ -1,67 +0,0 @@ -# Codex 点数查询 - -Codex 源码核对版本:`41ece455b7fa7166f4fc38522952afdaa2604e18`。 - -- `codex-rs/backend-client/src/client/rate_limit_resets.rs`:`get_rate_limit_status()` 使用 `GET /wham/usage`;ChatGPT 完整地址为 `https://chatgpt.com/backend-api/wham/usage`。Codex API 路由模式使用 `/api/codex/usage`。 -- `codex-rs/backend-client/src/client.rs`:`headers()` 添加身份认证和 `ChatGPT-Account-Id`,`map_credits()` 读取点数对象。 -- `codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs`:`has_credits`、`unlimited` 为布尔值,`balance` 为可缺省、可空的字符串。 -- `codex-rs/codex-api/src/rate_limits.rs`:推理响应也可以通过 `x-codex-credits-has-credits`、`x-codex-credits-unlimited` 和 `x-codex-credits-balance` 响应头携带点数快照。 - -点数是 `credits`;重置卡是 `rate_limit_reset_credits`。两者独立,不能混用。点数余额按原始十进制字符串保留,不折算成 sub2api 用户余额或美元。 - -## sub2api 接入 - -复用现有 OAuth token 刷新、账号绑定及代理客户端。管理员账户用量区域点击「点数」或「次数」,通过 `POST /api/v1/admin/openai/accounts/:id/quota/refresh` 一次刷新两种数据。只读查询仍使用 `GET /api/v1/admin/openai/accounts/:id/quota`。 - -`credits` 随额度查询和重置后的查询结果返回。点数缓存保存在账户 `extra.codex_credits_snapshot`,包含 `credits` 和 Unix 秒时间戳 `fetched_at`,无需数据库迁移。`credits_cache_persisted` 单独报告点数缓存是否保存成功,不受重置卡到期明细缺失影响。余额按钮提示中显示查询时间,点击可刷新。 - -- 有限点数:显示余额字符串,保留小数。 -- `unlimited: true`:显示「无限」,优先于其他字段。 -- `has_credits: false`:显示 `0`。 -- 有点数但余额未公开:显示「可用」。 -- 缺失或空的 `credits`:显示 `—`,不当作零余额;成功刷新会清除旧余额快照。 -- 请求失败:保留最近一次查询结果并显示错误。 -- Spark 影子账号:使用既有母账号凭证解析逻辑查询,缓存保存在被查询行。 - -本接入只查询和展示点数,不触发购买或消费;原有重置操作仍只使用重置卡。 - -## 可邀请次数与邀请用户 - -邀请接口核对来源为本机桌面应用 `26.908.40834` 的 `app.asar/webview/assets/app-primary-44ec287874b7.js`,对应 `SIt`(资格查询)、`EIt`(发送)、`PIt`(邀请计划)、`FIt`(次数约束)。邀请功能不在本机 Rust CLI 源码中。 - -上游接口: - -```http -GET https://chatgpt.com/backend-api/referrals/invite/eligibility?program_id=codex_referral_consumer&entrypoint=persistent -POST https://chatgpt.com/backend-api/referrals/invite -Content-Type: application/json - -{"program_id":"codex_referral_consumer","entrypoint":"persistent","emails":["friend@example.com"]} -``` - -个人邀请使用 `codex_referral_consumer`,工作空间邀请使用 `codex_referral_workspace`。sub2api 优先使用已保存的 `account_type` 判断工作空间,否则根据 `plan_type` 选择。资格接口返回的 `should_show` 决定是否可发送;`remaining_send_capacity` 是剩余发送次数,有奖励时还受 `remaining_reward_capacity` 限制。保留空值表示未知,不硬编码历史活动的邀请上限。每次发送一个邮箱。 - -管理员入口:账户用量区域的「可邀请」查询按钮与「邀请用户」弹窗。支持以下接口: - -- `POST /api/v1/admin/openai/accounts/:id/referrals/refresh`:查询并缓存资格和次数到 `extra.codex_referral_snapshot`,包含查询时间。返回 `eligibility` 和 `cache_persisted`。 -- `POST /api/v1/admin/openai/accounts/:id/referrals/invite`:请求体为 `email`、`program_id`、`confirmed`。后端重新查询资格,验证计划、邮箱、次数及收件人同意状态,再发送邀请。 - -活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 - -发送成功后,后端以脱离请求取消的 8 秒上下文刷新次数,再使用独立的 3 秒上下文保存结果。回读耗尽期限仍可清空过期快照,并返回 `sent: true`、`refresh_failed: true`。两个展示快照字段不触发调度事件或调度桶重建。 - -发送请求不自动重试。公共 Axios 拦截器保留网络错误码;发送时断网、超时或取消均提示结果不确定,要求先在 Codex 核对邀请状态,并禁用再次发送,直到手动刷新资格。只读查询失败显示查询错误。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 - -邀请业务通过 `service.OpenAIReferralClient` 接口调用仓储层 HTTP 适配器。Service 负责账号、计划、次数和同意校验;适配器负责代理客户端、请求协议、响应解析、错误脱敏与禁止重试,通过 Wire 注入。 - -### 自动回归检查 - -`make test-frontend` 的 CI 测试清单包含邀请组件、真实 Axios 拦截器与邀请组件联动测试、点数组件测试。后端单元测试覆盖回读期限耗尽后清理缓存、展示快照不写调度 outbox,以及邀请适配器协议、错误脱敏和断连不重试。 - -### 界面测试 - -以下截图使用真实 Vue 组件和模拟 API 响应,不包含真实账号凭证,也没有发送邮件。 - -![邀请弹窗与同意确认](screenshots/codex-referral-dialog.png) - -![模拟发送成功后刷新剩余次数](screenshots/codex-referral-success.png) diff --git a/docs/screenshots/codex-referral-dialog.png b/docs/screenshots/codex-referral-dialog.png deleted file mode 100644 index b0ec8a019d40a27ff57baf4df7347e4fad0c29f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30837 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 BNg4nE diff --git a/docs/screenshots/codex-referral-success.png b/docs/screenshots/codex-referral-success.png deleted file mode 100644 index e443315e8b6bb0855abde9c0f8384f643e46ffb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33194 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

vUv55m^TE3m5Zg^Dv{t*2^c*sK?bI-4g zUbRL;gZBLf*EqL<99yp%_I>Le)9M*h`COciA}so*d05TB_V1Y8@R9n{lMF zh&ko?e`U!7@)hUdeakNSeWkY;AcQnmL z_!usG!O)kvt@jkz$y0#pvlicHOgk>EMp^U(5iZOoVET{@BqhyH!%n|b@%pctb;|>V z)z0s>M})k)_~2xd?Us|uw+@WFDm?_Skyrh#baJl(R8(!BS?MJ~srp!btER$PNQ6(x zh?2)8>@Fd@QCTG*P_&atq9@X$NI*+|P))i&`;;B-$(mn^7{ z%hrI?LGm4s9WRB#Jy6|(P!ts$;!b3!rdWX19VPAN7)ydg7uT%Yj;ixSa#Pmp8{qYX zLxJC(iI4k@ZKJLnH2L}KU-hg0#!lPzzP@rLPG#@sy4r`Yx+&^=1w}>rXO#~0drTI` zI=z@`75(Oo5-%6n!(Ke~Br9$w|3q*PklgOr)ERj9x54$_O-EM?>M_x(KDLAgPpJ>Y z^6*zpFCHpw0v>XBKi5zFsdN#Y_(PcG!Jy2VpN6P>I8rlBg-x&`DC>C!zwi8HRdjO_ zzQW4R$@Tf3il+T$7$6$l3I)xOOMqoSpVz4?Gr|X$#ID&TzkC z-yCvUdGe7`5tqB8UAQQowdf5G2}vr_wy`V#*{a%|R(-7MD`!%BeDCU3-2;E)gY_%B zZ`*M^4G?$4{`)J)&LfY2=iVslTjg{o=znH^P~>&@*j9F)LE12OE1QPKb>0Ds Date: Sun, 20 Sep 2026 16:31:55 +0800 Subject: [PATCH 071/142] fix(antigravity): neutralize Claude Agent SDK identity in system prompts Stripping the x-anthropic-billing-header line is not enough. Claude Code's Agent SDK prompts (session title generation, and other SDK-driven calls) open a separate system block with You are a Claude agent, built on Anthropic's Claude Agent SDK. and the Google upstream still rejects those requests with 429 RESOURCE_EXHAUSTED. Measured on Sub2API 0.2.7, which already contains the attribution fix, using an isolated single-account group, waiting for the per-model cooldown to expire before every request so that a previous failure could not turn later results into 503: original request replayed 429 (8/8) same request, generic system prompt 200 (8/8) attribution also removed client-side 429 (3/3) attribution removed + identity neutralized 200 (3/3) only 'Claude' replaced in system 200 (2/2) only 'Anthropic' removed from system 200 (2/2) output_config / max_tokens / non-stream no effect The rewrite is anchored at the start of a system block, so user instructions that merely mention Claude or Anthropic, and model ids such as claude-sonnet-4-6, are left untouched. Scoped to the Antigravity transformer, as with the existing attribution handling. --- .../pkg/antigravity/attribution_test.go | 51 +++++++++++++++++++ .../pkg/antigravity/request_transformer.go | 30 ++++++++++- docs/ANTIGRAVITY_ATTRIBUTION_429.md | 45 ++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go index 8a1274e95d88..b37ceba6f99d 100644 --- a/backend/internal/pkg/antigravity/attribution_test.go +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -86,3 +86,54 @@ func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) { require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text) require.False(t, strings.Contains(got.Parts[0].Text, "cc_version=")) } + +func TestNeutralizeClaudeIdentity(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "agent sdk opener", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK. You are naming a coding session.", + want: "You are an AI agent. You are naming a coding session.", + }, + { + name: "cli opener", + text: "You are Claude Code, Anthropic's official CLI for Claude. Help with engineering tasks.", + want: "You are an AI agent. Help with engineering tasks.", + }, + { + name: "leading whitespace", + text: "\n You are a Claude agent, built on Anthropic's Claude Agent SDK.\nRest.", + want: "You are an AI agent.\nRest.", + }, + // User instructions that merely mention the vendor must stay untouched: + // they are content, not a client identity declaration. + { + name: "mention inside instructions", + text: "Follow CLAUDE.md. Prefer claude-sonnet-4-6 for long tasks.", + want: "Follow CLAUDE.md. Prefer claude-sonnet-4-6 for long tasks.", + }, + { + name: "opener not at start", + text: "Example: You are a Claude agent, built on Anthropic's Claude Agent SDK.", + want: "Example: You are a Claude agent, built on Anthropic's Claude Agent SDK.", + }, + {name: "ordinary text", text: "Keep these instructions.", want: "Keep these instructions."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, neutralizeClaudeIdentity(tt.text)) + }) + } +} + +// The attribution line and the identity sentence arrive as separate system +// blocks in real Claude Code traffic; both have to be handled. +func TestAttributionAndIdentityTogether(t *testing.T) { + attribution := "x-anthropic-billing-header: cc_version=2.1.278.0a9; cc_entrypoint=claude-vscode;" + identity := "You are a Claude agent, built on Anthropic's Claude Agent SDK." + require.Equal(t, "", stripClaudeAttribution(attribution)) + require.Equal(t, "You are an AI agent.", neutralizeClaudeIdentity(identity)) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index c4af10dcd85e..f9499c1cca97 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "math/rand" + "regexp" "strconv" "strings" "sync" @@ -297,6 +298,31 @@ func filterOpenCodePrompt(text string) string { // from Antigravity system text. This is prompt metadata, not an HTTP header; it // can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the // Antigravity transformer: native Anthropic OAuth may require the attribution. +// claudeIdentityOpeners are the Claude Agent SDK identity sentences that some +// Anthropic clients put at the start of a system block. Stripping the +// x-anthropic-billing-header line alone is not enough: a request whose system +// text still opens with this sentence is still rejected by the Google upstream +// with 429 RESOURCE_EXHAUSTED (see docs/ANTIGRAVITY_ATTRIBUTION_429.md). +// +// The patterns are anchored at the start of a system block on purpose, so user +// instructions that merely mention Claude or Anthropic are left untouched. +var claudeIdentityOpeners = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^[ \t\r\n]*You are a Claude agent, built on Anthropic'?s Claude Agent SDK\.?`), + regexp.MustCompile(`(?i)^[ \t\r\n]*You are Claude Code, Anthropic'?s official CLI for Claude\.?`), +} + +// neutralizeClaudeIdentity rewrites a leading vendor identity sentence into a +// vendor-neutral one. It keeps the rest of the block, including any user +// instructions that follow. +func neutralizeClaudeIdentity(text string) string { + for _, re := range claudeIdentityOpeners { + if loc := re.FindStringIndex(text); loc != nil { + return "You are an AI agent." + text[loc[1]:] + } + } + return text +} + func stripClaudeAttribution(text string) string { trimmed := strings.TrimLeft(text, " \t\r\n") if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { @@ -325,7 +351,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans // 尝试解析为字符串 var sysStr string if err := json.Unmarshal(system, &sysStr); err == nil { - sysStr = stripClaudeAttribution(sysStr) + sysStr = neutralizeClaudeIdentity(stripClaudeAttribution(sysStr)) if strings.TrimSpace(sysStr) != "" { if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true @@ -341,7 +367,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans var sysBlocks []SystemBlock if err := json.Unmarshal(system, &sysBlocks); err == nil { for _, block := range sysBlocks { - block.Text = stripClaudeAttribution(block.Text) + block.Text = neutralizeClaudeIdentity(stripClaudeAttribution(block.Text)) if block.Type == "text" && strings.TrimSpace(block.Text) != "" { if strings.Contains(block.Text, "You are Antigravity") { userHasAntigravityIdentity = true diff --git a/docs/ANTIGRAVITY_ATTRIBUTION_429.md b/docs/ANTIGRAVITY_ATTRIBUTION_429.md index 1a50a20ebe14..e4b3cc18224f 100644 --- a/docs/ANTIGRAVITY_ATTRIBUTION_429.md +++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md @@ -59,3 +59,48 @@ Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/do 回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。 修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。 + +## 补充:仅移除 attribution 行仍不足(2026-09-20) + +在另一套部署上(**Sub2API 0.2.7,已包含上一节的 `stripClaudeAttribution` 修复**, +Claude Code 2.1.278 / claude-vscode 入口,映射模型 `gemini-3.8-flash-high`), +同一类 429 再次出现在 Claude Code 的**会话标题生成**请求上。 +这类请求很小:约 4 KB、system 3201 字符、0 个工具、1 条消息。 + +也就是说,下面的“原始请求回放”本身就是在**已经移除 attribution 行**的代码上跑的。 + +对照实验在一个只绑定单个账号的隔离分组里进行,每次请求之前都等模型冷却到期, +避免上一次失败造成的冷却把后续结果污染成 503: + +| 对照项 | 结果 | +| --- | --- | +| 原始请求回放 | 429(8/8) | +| 仅把 system 换成普通提示词,其余不变 | 200(8/8) | +| 回放前先由客户端侧移除 attribution 块(与网关行为叠加,确认无额外影响) | **429(3/3)** | +| 在上一行基础上再中性化身份开场白 | **200(3/3)** | +| 仅把 system 里的 `Claude` 替换为中性词 | 200(2/2) | +| 仅把 system 里的 `Anthropic` 移除 | 200(2/2) | +| 去掉 `output_config` / 改小 `max_tokens` / 改非流式 | 429(各 1/1,无影响) | + +该请求的 system 是三个文本块: + +1. 仅 attribution 元数据(本文上一节的修复会整块丢弃); +2. `You are a Claude agent, built on Anthropic's Claude Agent SDK.`; +3. 其余任务说明,不含厂商名。 + +也就是说,触发拒绝的不只是 attribution 元数据,**第 2 块的身份声明同样会触发**。 +Claude Code 的主对话提示词以 `You are an interactive CLI tool` 开头,已被 +`filterOpenCodePrompt` 处理,所以只有 Agent SDK 这类提示词漏网。 + +### 本次修复 + +在 attribution 处理之后,追加一次**开头锚定**的身份句中性化: + +- 命中 `You are a Claude agent, built on Anthropic's Claude Agent SDK.` 与 + `You are Claude Code, Anthropic's official CLI for Claude.` 两种开场白,替换为 + `You are an AI agent.`,块内其余内容原样保留。 +- 只在 system 文本块**开头**匹配:正文中间提到 Claude/Anthropic 的用户指令不受影响, + `claude-sonnet-4-6` 这类模型名也不会被改写。 +- 不修改用户消息、工具定义、模型映射与认证;仅作用于 Antigravity 转换器。 + +同样地,这些对照只支持“这段文本在本次请求中触发拒绝”的判断,不能推断 Google 的内部策略。 From 0892ef3a9a320dad79ff87930dcd5ae56a9a140a Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Sun, 20 Sep 2026 16:36:24 +0800 Subject: [PATCH 072/142] ci: build release targets on independent matrix runners --- .github/release-tools/README.md | 30 ++ .github/release-tools/release-images.sh | 38 +++ .github/release-tools/release_matrix.py | 189 +++++++++++ .../release-tools/requirements-release.txt | 1 + .github/release-tools/test_release_matrix.py | 163 +++++++++ .github/workflows/backend-ci.yml | 11 + .github/workflows/release.yml | 310 +++++++++++------- 7 files changed, 627 insertions(+), 115 deletions(-) create mode 100644 .github/release-tools/README.md create mode 100644 .github/release-tools/release-images.sh create mode 100644 .github/release-tools/release_matrix.py create mode 100644 .github/release-tools/requirements-release.txt create mode 100644 .github/release-tools/test_release_matrix.py diff --git a/.github/release-tools/README.md b/.github/release-tools/README.md new file mode 100644 index 000000000000..17946ff637ed --- /dev/null +++ b/.github/release-tools/README.md @@ -0,0 +1,30 @@ +# Release matrix + +The release workflow builds the frontend once, then runs each configured Go target on its own Linux runner. `CGO_ENABLED=0` permits cross-compilation. The target matrix is read from `.goreleaser.yaml`, including its exclusions; simple releases select Linux amd64 only. + +Each build uses GoReleaser OSS in snapshot mode with the selected release version and one target. Archive naming, bundled files, Go flags and release templates remain in the existing GoReleaser configurations. Every archive is accompanied by its source commit, target, version and SHA256. The publishing job verifies the complete matrix before building images or publishing. It uses GoReleaser's `extra_files` support to publish existing archives and checksums, with builds disabled. No Pro license is needed. + +Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. + +All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. The workflow serializes release runs to prevent simultaneous updates to moving image tags. + +## Validate without publication + +From a branch containing this workflow: + +```bash +gh workflow run release.yml --ref \ + -f tag= -f dry_run=true -f simple_release=false +``` + +A dry run builds all selected archives and both runtime images, verifies artifact provenance and produces the final checksum file. It exports images locally as OCI archives instead of pushing them. It skips registry logins, GitHub Release publication, DockerHub description updates, Telegram notifications and VERSION synchronization. Test the simple path separately with `simple_release=true`. + +Dry-run artifacts are available in the Actions run, including `release-dry-run-report`. Compare job start/end times, GoReleaser's build duration and cache restore results. Do not present an initial cold-cache run as a warmed-cache benchmark; publishing network time is not measured by dry runs. + +Helper checks: + +```bash +python -m pip install -r .github/release-tools/requirements-release.txt +python -m unittest discover -s .github/release -p 'test_release_matrix.py' +bash -n .github/release-tools/release-images.sh +``` diff --git a/.github/release-tools/release-images.sh b/.github/release-tools/release-images.sh new file mode 100644 index 000000000000..532d176c899c --- /dev/null +++ b/.github/release-tools/release-images.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${RELEASE_VERSION:?}" "${RELEASE_SHA:?}" "${GITHUB_REPOSITORY:?}" "${RUNNER_TEMP:?}" +owner=${GITHUB_REPOSITORY%%/*} +registries=("ghcr.io/${owner,,}/sub2api") +if [[ ${SIMPLE_RELEASE:-false} != true && ${DOCKERHUB_USERNAME:-skip} != skip ]]; then + registries+=("${DOCKERHUB_USERNAME}/sub2api") +fi +arches=(amd64 arm64) +if [[ ${SIMPLE_RELEASE:-false} == true ]]; then arches=(amd64); fi +for arch in "${arches[@]}"; do + args=(--platform "linux/$arch" --file ".release-context/$arch/Dockerfile" + --label "org.opencontainers.image.version=$RELEASE_VERSION" + --label "org.opencontainers.image.revision=$RELEASE_SHA" + --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY") + for registry in "${registries[@]}"; do + args+=(--tag "$registry:$RELEASE_VERSION-$arch") + if [[ ${SIMPLE_RELEASE:-false} == true ]]; then + args+=(--tag "$registry:$RELEASE_VERSION" --tag "$registry:latest") + fi + done + if [[ ${DRY_RUN:-false} == true ]]; then + args+=(--output "type=oci,dest=$RUNNER_TEMP/sub2api-$arch.oci.tar") + else + args+=(--push) + fi + docker buildx build "${args[@]}" ".release-context/$arch" +done +if [[ ${DRY_RUN:-false} != true && ${SIMPLE_RELEASE:-false} != true ]]; then + major=${RELEASE_VERSION%%.*} + minor=${RELEASE_VERSION#*.}; minor=${minor%%.*} + for registry in "${registries[@]}"; do + docker buildx imagetools create \ + --tag "$registry:$RELEASE_VERSION" --tag "$registry:latest" \ + --tag "$registry:$major.$minor" --tag "$registry:$major" \ + "$registry:$RELEASE_VERSION-amd64" "$registry:$RELEASE_VERSION-arm64" + done +fi diff --git a/.github/release-tools/release_matrix.py b/.github/release-tools/release_matrix.py new file mode 100644 index 000000000000..4ea183879f24 --- /dev/null +++ b/.github/release-tools/release_matrix.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Split GoReleaser builds across runners without requiring GoReleaser Pro.""" +import argparse +import hashlib +import itertools +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +from datetime import datetime, timezone + +import yaml + +FULL_CONFIG = Path('.goreleaser.yaml') +SIMPLE_CONFIG = Path('.goreleaser.simple.yaml') +VERSION_FILE = Path('backend/cmd/server/VERSION') +VERSION_RE = re.compile(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?') + + +def config(simple=False): + return yaml.safe_load((SIMPLE_CONFIG if simple else FULL_CONFIG).read_text()) + + +def targets(simple=False): + build = config()['builds'][0] + result = [] + for goos, goarch in itertools.product(build['goos'], build['goarch']): + item = {'goos': goos, 'goarch': goarch} + if any(all(item.get(k) == v for k, v in rule.items()) for rule in build.get('ignore', [])): + continue + if not simple or item == {'goos': 'linux', 'goarch': 'amd64'}: + result.append(item) + if not result: + raise ValueError('empty release target matrix') + return result + + +def archive_name(version, target): + if not VERSION_RE.fullmatch(version) or target not in targets(): + raise ValueError('invalid release version or target') + suffix = 'zip' if target['goos'] == 'windows' else 'tar.gz' + return f"sub2api_{version}_{target['goos']}_{target['goarch']}.{suffix}" + + +def sha256(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def plan(args): + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + if args.dry_run: + version = VERSION_FILE.read_text().strip() + tag = 'v' + version + else: + tag = args.ref + version = tag.removeprefix('v') + if not tag.startswith('v') or not VERSION_RE.fullmatch(version): + raise ValueError('publishing requires a v-prefixed release version tag') + tagged_sha = subprocess.check_output(['git', 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'], text=True).strip() + if sha != tagged_sha: + raise ValueError('checkout does not match the selected release tag') + if not VERSION_RE.fullmatch(version): + raise ValueError('invalid VERSION') + VERSION_FILE.write_text(version + '\n') + result = {'sha': sha, 'tag': tag, 'version': version, + 'owner_lower': os.environ.get('GITHUB_REPOSITORY_OWNER', '').lower(), + 'simple': str(args.simple).lower(), 'dry_run': str(args.dry_run).lower(), + 'date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'matrix': json.dumps({'include': targets(args.simple)}, separators=(',', ':'))} + with Path(os.environ['GITHUB_OUTPUT']).open('a') as output: + for key, value in result.items(): + output.write(f'{key}={value}\n') + + +def generate_config(args): + data = config(args.simple if args.mode == 'publish' else False) + data['snapshot'] = {'version_template': '{{ .Env.RELEASE_VERSION }}'} + data['dockers'] = [] + data['docker_manifests'] = [] + if args.mode == 'build': + target = {'goos': args.goos, 'goarch': args.goarch} + if target not in targets(): + raise ValueError('unsupported build target') + for build in data['builds']: + build['goos'], build['goarch'], build['ignore'] = [args.goos], [args.goarch], [] + build['ldflags'] = [re.sub(r'{{\s*\.Date\s*}}', '{{ .Env.RELEASE_DATE }}', flag) + for flag in build.get('ldflags', [])] + else: + # Artifacts are supplied through the OSS extra_files mechanism. No build + # is repeated on the publishing runner, and release templates stay intact. + data['before'] = {'hooks': []} + data['builds'] = [{'id': 'sub2api', 'skip': True}] + data['archives'] = [] + extra = [{'glob': 'release-input/sub2api_*.tar.gz'}, {'glob': 'release-input/sub2api_*.zip'}] + if args.simple: + data['checksum'] = {'disable': True} + else: + data['release']['extra_files'] = extra + data['checksum'] = {'name_template': 'checksums.txt', 'algorithm': 'sha256', 'extra_files': extra} + Path(args.output).write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) + + +def collect(args): + target = {'goos': args.goos, 'goarch': args.goarch} + name = archive_name(args.version, target) + source = Path('dist') / name + checksums = {line.split()[1].lstrip('*'): line.split()[0] for line in Path('dist/checksums.txt').read_text().splitlines()} + digest = sha256(source) + if checksums.get(name) != digest: + raise ValueError('archive does not match the build checksum') + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, output / name) + manifest = {'sha': args.sha, 'version': args.version, 'target': target, 'archive': name, 'sha256': digest} + (output / f"manifest-{args.goos}-{args.goarch}.json").write_text(json.dumps(manifest) + '\n') + + +def verify(args): + directory = Path(args.input) + expected = set() + for target in targets(args.simple): + name = archive_name(args.version, target) + manifest_name = f"manifest-{target['goos']}-{target['goarch']}.json" + expected.update((name, manifest_name)) + manifest = json.loads((directory / manifest_name).read_text()) + if manifest != {'sha': args.sha, 'version': args.version, 'target': target, + 'archive': name, 'sha256': sha256(directory / name)}: + raise ValueError(f'build provenance or checksum mismatch: {name}') + if {p.name for p in directory.iterdir()} != expected: + raise ValueError('missing or unexpected release artifacts') + + +def contexts(args): + verify(args) + for target in targets(args.simple): + if target['goos'] != 'linux': + continue + dest = Path(args.output) / target['goarch'] + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(Path(args.input) / archive_name(args.version, target), 'r:gz') as archive: + members = [member for member in archive.getmembers() if member.name in ('sub2api', './sub2api')] + if len(members) != 1 or not members[0].isfile(): + raise ValueError('archive must contain one regular sub2api binary') + with archive.extractfile(members[0]) as source, (dest / 'sub2api').open('wb') as output: + shutil.copyfileobj(source, output) + (dest / 'sub2api').chmod(0o755) + shutil.copy2('Dockerfile.goreleaser', dest / 'Dockerfile') + (dest / 'deploy').mkdir(exist_ok=True) + shutil.copy2('deploy/docker-entrypoint.sh', dest / 'deploy/docker-entrypoint.sh') + shutil.copytree('backend/resources', dest / 'backend/resources', dirs_exist_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + p = commands.add_parser('plan') + p.add_argument('--ref', required=True) + p.add_argument('--simple', action='store_true') + p.add_argument('--dry-run', action='store_true') + p.set_defaults(run=plan) + p = commands.add_parser('config') + p.add_argument('mode', choices=['build', 'publish']) + p.add_argument('--goos') + p.add_argument('--goarch') + p.add_argument('--simple', action='store_true') + p.add_argument('--output', required=True) + p.set_defaults(run=generate_config) + p = commands.add_parser('collect') + for arg in ('version', 'sha', 'goos', 'goarch', 'output'): + p.add_argument('--' + arg, required=True) + p.set_defaults(run=collect) + for command, handler in [('verify', verify), ('contexts', contexts)]: + p = commands.add_parser(command) + for arg in ('version', 'sha', 'input'): + p.add_argument('--' + arg, required=True) + p.add_argument('--simple', action='store_true') + if command == 'contexts': + p.add_argument('--output', required=True) + p.set_defaults(run=handler) + args = parser.parse_args() + args.run(args) + + +if __name__ == '__main__': + main() diff --git a/.github/release-tools/requirements-release.txt b/.github/release-tools/requirements-release.txt new file mode 100644 index 000000000000..f62ce0c56db2 --- /dev/null +++ b/.github/release-tools/requirements-release.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/.github/release-tools/test_release_matrix.py b/.github/release-tools/test_release_matrix.py new file mode 100644 index 000000000000..b1a215518d15 --- /dev/null +++ b/.github/release-tools/test_release_matrix.py @@ -0,0 +1,163 @@ +import argparse +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('release_matrix', Path(__file__).with_name('release_matrix.py')) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ReleaseMatrixTest(unittest.TestCase): + def setUp(self): + self.previous = Path.cwd() + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + os.chdir(self.temp.name) + self.addCleanup(os.chdir, self.previous) + for name in ('.goreleaser.yaml', '.goreleaser.simple.yaml'): + shutil.copyfile(ROOT / name, name) + Path('backend/cmd/server').mkdir(parents=True) + release.VERSION_FILE.write_text('9.8.7\n') + + def fixture_artifacts(self, simple=False): + directory = Path('release-input') + directory.mkdir() + for target in release.targets(simple): + name = release.archive_name('9.8.7', target) + archive = directory / name + if target['goos'] == 'linux': + with tarfile.open(archive, 'w:gz') as out: + info = tarfile.TarInfo('sub2api') + info.size = 7 + info.mode = 0o755 + out.addfile(info, io.BytesIO(b'fixture')) + else: + archive.write_bytes(b'fixture archive') + metadata = {'version': '9.8.7', 'sha': 'a' * 40, 'target': target, + 'archive': name, 'sha256': release.sha256(archive)} + (directory / f"manifest-{target['goos']}-{target['goarch']}.json").write_text(json.dumps(metadata)) + return argparse.Namespace(input='release-input', version='9.8.7', sha='a' * 40, simple=simple, output='contexts') + + def test_full_and_simple_matrix_match_existing_targets(self): + full = release.targets() + self.assertEqual(len(full), 5) + self.assertNotIn({'goos': 'windows', 'goarch': 'arm64'}, full) + self.assertEqual(release.targets(True), [{'goos': 'linux', 'goarch': 'amd64'}]) + + def test_leaf_keeps_packaging_and_selects_only_one_target(self): + original = release.config() + release.generate_config(argparse.Namespace(mode='build', simple=False, goos='darwin', goarch='arm64', output='leaf.yaml')) + leaf = yaml.safe_load(Path('leaf.yaml').read_text()) + self.assertEqual(leaf['builds'][0]['goos'], ['darwin']) + self.assertEqual(leaf['builds'][0]['goarch'], ['arm64']) + self.assertEqual(leaf['builds'][0]['ignore'], []) + self.assertEqual(leaf['archives'], original['archives']) + self.assertEqual(leaf['release'], original['release']) + self.assertFalse(leaf['dockers']) + self.assertIn('{{ .Env.RELEASE_DATE }}', '\n'.join(leaf['builds'][0]['ldflags'])) + + def test_publication_config_has_no_compilation_or_docker_work(self): + for simple in (False, True): + with self.subTest(simple=simple): + original = release.config(simple) + release.generate_config(argparse.Namespace(mode='publish', simple=simple, output='publisher.yaml')) + data = yaml.safe_load(Path('publisher.yaml').read_text()) + self.assertTrue(data['builds'][0]['skip']) + self.assertFalse(data['archives']) + self.assertFalse(data['dockers']) + self.assertEqual(data['release']['header'], original['release']['header']) + self.assertEqual(data['release']['footer'], original['release']['footer']) + if simple: + self.assertTrue(data['checksum']['disable']) + self.assertTrue(data['release']['skip_upload']) + else: + self.assertEqual(data['checksum']['extra_files'], data['release']['extra_files']) + + def test_collect_and_verify_hash_and_source_binding(self): + args = self.fixture_artifacts() + release.verify(args) + file = next(Path(args.input).glob('*.tar.gz')) + file.write_bytes(b'corrupted') + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): + release.verify(args) + + def test_missing_extra_and_wrong_commit_artifacts_are_rejected(self): + args = self.fixture_artifacts(True) + args.sha = 'b' * 40 + with self.assertRaises(ValueError): + release.verify(args) + args.sha = 'a' * 40 + Path('release-input/unexpected').write_text('not an asset') + with self.assertRaises(ValueError): + release.verify(args) + Path('release-input/unexpected').unlink() + next(Path('release-input').glob('*.tar.gz')).unlink() + with self.assertRaises(FileNotFoundError): + release.verify(args) + + def test_linux_context_preserves_binary_executable_mode(self): + args = self.fixture_artifacts() + Path('Dockerfile.goreleaser').write_text('FROM scratch\nCOPY sub2api /sub2api\n') + Path('deploy').mkdir() + Path('deploy/docker-entrypoint.sh').write_text('#!/bin/sh\nexec /app/sub2api\n') + Path('backend/resources').mkdir() + Path('backend/resources/data').write_text('fixture') + release.contexts(args) + for arch in ('amd64', 'arm64'): + binary = Path('contexts') / arch / 'sub2api' + self.assertEqual(binary.read_bytes(), b'fixture') + self.assertEqual(binary.stat().st_mode & 0o777, 0o755) + + def test_plan_requires_a_tag_for_publication(self): + args = argparse.Namespace(ref='main', dry_run=False, simple=False) + with patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): + with self.assertRaisesRegex(ValueError, 'version tag'): + release.plan(args) + args.ref = 'v9.8.7' + with patch.object(subprocess, 'check_output', side_effect=['a' * 40 + '\n', 'b' * 40 + '\n']): + with self.assertRaisesRegex(ValueError, 'does not match'): + release.plan(args) + + def test_dry_run_plan_resolves_matrix_without_a_new_tag(self): + with patch.dict(os.environ, {'GITHUB_OUTPUT': 'outputs', 'GITHUB_REPOSITORY_OWNER': 'ExampleOwner'}), patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): + release.plan(argparse.Namespace(ref='feature/matrix', dry_run=True, simple=False)) + output = dict(line.split('=', 1) for line in Path('outputs').read_text().splitlines()) + self.assertEqual(output['dry_run'], 'true') + self.assertEqual(output['owner_lower'], 'exampleowner') + self.assertEqual(len(json.loads(output['matrix'])['include']), 5) + + def test_docker_commands_do_not_publish_during_dry_run(self): + fake_bin = Path('bin') + fake_bin.mkdir() + docker = fake_bin / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') + docker.chmod(0o755) + env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], + 'DOCKER_LOG': str(Path('docker.log').resolve()), 'RUNNER_TEMP': self.temp.name, + 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', + 'DRY_RUN': 'true', 'SIMPLE_RELEASE': 'false', 'DOCKERHUB_USERNAME': 'skip'} + subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) + log = Path('docker.log').read_text() + self.assertEqual(log.count('buildx build'), 2) + self.assertIn('linux/arm64', log) + self.assertNotIn('--push', log) + self.assertNotIn('imagetools', log) + self.assertNotIn('skip/sub2api', log) + self.assertIn('ghcr.io/exampleowner/sub2api', log) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 2bac7259a9e4..cbbc6ae12233 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -80,3 +80,14 @@ jobs: version: v2.13 args: --timeout=30m working-directory: backend + + release-helpers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m unittest discover -s .github/release -p 'test_release_matrix.py' + - run: bash -n .github/release-tools/release-images.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abdfc9c81daa..71f4f5d25365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g., v1.0.0)' + description: 'Tag to release (or a branch/ref for dry run)' required: true type: string simple_release: @@ -15,179 +15,264 @@ on: required: false type: boolean default: false + dry_run: + description: 'Build and verify all selected artifacts without publishing' + required: false + type: boolean + default: false -# 环境变量:合并 workflow_dispatch 输入和 repository variable -# tag push 触发时读取 vars.SIMPLE_RELEASE,workflow_dispatch 时使用输入参数 -env: - SIMPLE_RELEASE: ${{ github.event.inputs.simple_release == 'true' || vars.SIMPLE_RELEASE == 'true' }} +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false permissions: - contents: write - packages: write + contents: read + +env: + SIMPLE_RELEASE: ${{ inputs.simple_release == true || vars.SIMPLE_RELEASE == 'true' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} jobs: - # Update VERSION file with tag version - update-version: + prepare: runs-on: ubuntu-latest + outputs: + sha: ${{ steps.plan.outputs.sha }} + owner_lower: ${{ steps.plan.outputs.owner_lower }} + tag: ${{ steps.plan.outputs.tag }} + version: ${{ steps.plan.outputs.version }} + date: ${{ steps.plan.outputs.date }} + matrix: ${{ steps.plan.outputs.matrix }} + simple: ${{ steps.plan.outputs.simple }} + dry_run: ${{ steps.plan.outputs.dry_run }} steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Update VERSION file + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r .github/release-tools/requirements-release.txt + - name: Resolve one source commit and target matrix + id: plan + env: + REQUESTED_REF: ${{ inputs.tag || github.ref_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION=${{ github.event.inputs.tag }} - VERSION=${VERSION#v} - else - VERSION=${GITHUB_REF#refs/tags/v} - fi - echo "$VERSION" > backend/cmd/server/VERSION - echo "Updated VERSION file to: $VERSION" - - - name: Upload VERSION artifact - uses: actions/upload-artifact@v7 + args=(--ref "$REQUESTED_REF") + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi + python .github/release-tools/release_matrix.py plan "${args[@]}" + - uses: actions/upload-artifact@v7 with: name: version-file path: backend/cmd/server/VERSION retention-days: 1 build-frontend: + needs: prepare runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - uses: pnpm/action-setup@v6 with: version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v6 + - uses: actions/setup-node@v6 with: node-version: '20' - cache: 'pnpm' + cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - run: pnpm install --frozen-lockfile working-directory: frontend - - - name: Build frontend - run: pnpm run build + - run: pnpm run build working-directory: frontend - - - name: Upload frontend artifact - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v7 with: name: frontend-dist path: backend/internal/web/dist/ retention-days: 1 - release: - needs: [update-version, build-frontend] + build-binaries: + needs: [prepare, build-frontend] runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_DATE: ${{ needs.prepare.outputs.date }} steps: - - name: Checkout - uses: actions/checkout@v6 + - uses: actions/checkout@v6 with: + ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - ref: ${{ github.event.inputs.tag || github.ref }} - - - name: Download VERSION artifact - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8 with: name: version-file path: backend/cmd/server/ - - - name: Download frontend artifact - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8 with: name: frontend-dist path: backend/internal/web/dist/ - - - name: Setup Go - uses: actions/setup-go@v6 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod check-latest: false - cache-dependency-path: backend/go.sum - + cache: false - name: Verify Go version - run: | - go version | grep -q 'go1.27.0' + run: go version | grep -q 'go1.27.0' + - name: Restore target-specific Go cache + id: go-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} + restore-keys: | + release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}- + release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}- + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r .github/release-tools/requirements-release.txt + - name: Select one GoReleaser target + env: + TARGET_OS: ${{ matrix.goos }} + TARGET_ARCH: ${{ matrix.goarch }} + run: python .github/release-tools/release_matrix.py config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output .goreleaser.matrix.yaml + - name: Build and archive without publishing + uses: goreleaser/goreleaser-action@v7 + with: + version: '~> v2' + args: release --snapshot --clean --skip=validate,publish,docker,announce --config=.goreleaser.matrix.yaml + env: + GITHUB_REPO_OWNER: ${{ github.repository_owner }} + GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} + GITHUB_REPO_NAME: ${{ github.event.repository.name }} + DOCKERHUB_USERNAME: skip + TAG_MESSAGE: '' + - name: Verify and collect the target archive + env: + TARGET_OS: ${{ matrix.goos }} + TARGET_ARCH: ${{ matrix.goarch }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + run: python .github/release-tools/release_matrix.py collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output + - uses: actions/upload-artifact@v7 + with: + name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: matrix-output/ + if-no-files-found: error + retention-days: 1 + - name: Save updated target cache + if: ${{ steps.go-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} - # Docker setup for GoReleaser + release: + needs: [prepare, build-binaries] + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + pattern: release-binary-* + path: release-input + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r .github/release-tools/requirements-release.txt + - name: Verify complete matrix and prepare Docker contexts + run: | + args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context) + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + python .github/release-tools/release_matrix.py contexts "${args[@]}" + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache: false - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - if: ${{ env.DOCKERHUB_USERNAME != '' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: docker/login-action@v3 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry + if: ${{ env.DRY_RUN != 'true' }} uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Fetch tags with annotations - run: | - # 确保获取完整的 annotated tag 信息 - git fetch --tags --force - - - name: Get tag message + - name: Build images and publish manifests + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} + run: bash .github/release-tools/release-images.sh + - name: Get annotated tag notes id: tag_message run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG_NAME=${{ github.event.inputs.tag }} - else - TAG_NAME=${GITHUB_REF#refs/tags/} - fi - echo "Processing tag: $TAG_NAME" - - # 获取完整的 tag message(跳过第一行标题) - TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$TAG_NAME") - - # 调试输出 - echo "Tag message length: ${#TAG_MESSAGE}" - echo "Tag message preview:" - echo "$TAG_MESSAGE" | head -10 - - # 使用 EOF 分隔符处理多行内容 - echo "message<> $GITHUB_OUTPUT - echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Set lowercase owner for GHCR - id: lowercase - run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - - - name: Run GoReleaser + TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$RELEASE_TAG") + DELIMITER="TAG_MESSAGE_$(openssl rand -hex 16)" + { + echo "message<<$DELIMITER" + echo "$TAG_MESSAGE" + echo "$DELIMITER" + } >> "$GITHUB_OUTPUT" + - name: Prepare publication-only GoReleaser config + run: | + args=(publish --output .goreleaser.publisher.yaml) + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + python .github/release-tools/release_matrix.py config "${args[@]}" + - name: Publish existing archives and release notes uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }} + args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=.goreleaser.publisher.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }} TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} - GITHUB_REPO_OWNER_LOWER: ${{ steps.lowercase.outputs.owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} + GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} + - name: Upload dry-run checksums and metadata + if: ${{ env.DRY_RUN == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: release-dry-run-report + path: | + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + if-no-files-found: error + retention-days: 3 # Update DockerHub description - name: Update DockerHub description - if: ${{ env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: peter-evans/dockerhub-description@v5 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -200,8 +285,9 @@ jobs: # Send Telegram notification - name: Send Telegram Notification - if: ${{ env.SIMPLE_RELEASE != 'true' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' }} env: + TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -213,17 +299,12 @@ jobs: exit 0 fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG_NAME=${{ github.event.inputs.tag }} - else - TAG_NAME=${GITHUB_REF#refs/tags/} - fi + TAG_NAME="$RELEASE_TAG" VERSION=${TAG_NAME#v} REPO="${{ github.repository }}" GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase # 获取 tag message 内容并转义 Markdown 特殊字符 - TAG_MESSAGE='${{ steps.tag_message.outputs.message }}' TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g') # 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容) @@ -273,9 +354,13 @@ jobs: }')" sync-version-file: - needs: [release] - if: ${{ needs.release.result == 'success' }} + needs: [prepare, release] + if: ${{ needs.release.result == 'success' && needs.prepare.outputs.dry_run != 'true' }} runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} steps: - name: Checkout default branch uses: actions/checkout@v6 @@ -284,12 +369,7 @@ jobs: - name: Sync VERSION file to released tag run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION=${{ github.event.inputs.tag }} - VERSION=${VERSION#v} - else - VERSION=${GITHUB_REF#refs/tags/v} - fi + VERSION="$RELEASE_VERSION" CURRENT_VERSION=$(tr -d '\r\n' < backend/cmd/server/VERSION || true) if [ "$CURRENT_VERSION" = "$VERSION" ]; then From d2e319b2a17006122cd2d53d828c44a0bf21bd9b Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 20 Sep 2026 16:37:07 +0800 Subject: [PATCH 073/142] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=20WebSocket?= =?UTF-8?q?=20=E5=90=8C=E7=BA=BF=E7=A8=8B=E6=8A=A2=E5=8D=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=E5=85=B3=E9=97=AD=E6=97=B6=E5=BA=8F=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...i_ws_forwarder_ingress_execution_scope_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go b/backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go index c9a18fc74851..db7ea8e5e1c9 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go @@ -310,10 +310,21 @@ func runOpenAIWSCodexThreadPair(t *testing.T, threadA, threadB string) (serverEr close(gatedConn.gate) readCtxA, cancelA := context.WithTimeout(context.Background(), 5*time.Second) - _, completedA, aReadErr := connA.Read(readCtxA) + defer cancelA() + for { + var completedA []byte + _, completedA, aReadErr = connA.Read(readCtxA) + if aReadErr != nil { + break + } + require.Equal(t, "resp_thread_a", gjson.GetBytes(completedA, "response.id").String()) + if threadA != threadB { + break + } + // 抢占关闭帧异步发送,已在飞的响应可能先到达;同线程必须继续读到关闭帧。 + } cancelA() if aReadErr == nil { - require.Equal(t, "resp_thread_a", gjson.GetBytes(completedA, "response.id").String()) require.NoError(t, connA.Close(coderws.StatusNormalClosure, "done")) } require.NoError(t, connB.Close(coderws.StatusNormalClosure, "done")) From b8d52fad34f70a2b3ac69dfb5c0ec8724a19099c Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 20 Sep 2026 16:41:49 +0800 Subject: [PATCH 074/142] feat(billing): support configurable reasoning effort multipliers --- .../internal/handler/admin/channel_handler.go | 153 ++++++----- .../handler/admin/channel_handler_test.go | 45 ++-- .../handler/available_channel_handler.go | 44 ++-- .../handler/available_channel_handler_test.go | 4 +- ...oning_effort_migration_integration_test.go | 85 ++++++ ...nnel_reasoning_pricing_integration_test.go | 181 +++++++++++++ .../channel_repo_account_stats_pricing.go | 20 +- .../repository/channel_repo_pricing.go | 53 +++- .../channel_repo_pricing_reasoning_test.go | 175 +++++++++++++ .../channel_repo_pricing_time_test.go | 16 +- .../internal/service/account_stats_pricing.go | 6 +- .../service/account_stats_pricing_test.go | 4 +- backend/internal/service/billing_service.go | 107 ++++---- .../internal/service/billing_service_test.go | 3 +- .../billing_token_cost_request_test.go | 11 +- backend/internal/service/channel.go | 44 ++-- backend/internal/service/channel_available.go | 36 +-- .../service/channel_reasoning_pricing_test.go | 42 +++ backend/internal/service/channel_service.go | 22 ++ backend/internal/service/channel_test.go | 5 +- .../gateway_compat_reasoning_pricing_test.go | 115 ++++++++ .../gateway_forward_as_chat_completions.go | 15 +- .../service/gateway_forward_as_responses.go | 9 +- .../service/gateway_reasoning_pricing_test.go | 70 +++++ .../internal/service/model_plaza_service.go | 22 +- .../service/model_plaza_service_test.go | 19 +- .../service/model_pricing_resolver.go | 5 +- .../openai_gateway_chat_completions_raw.go | 8 +- ...penai_gateway_chat_completions_raw_test.go | 3 + .../service/openai_gateway_request_body.go | 4 +- .../openai_gateway_service_hotpath_test.go | 8 +- .../internal/service/openai_gateway_usage.go | 6 +- .../service/reasoning_effort_billing_test.go | 245 ++++++++++++++++++ ...9_channel_reasoning_effort_multipliers.sql | 56 ++++ ...oning_effort_multipliers_migration_test.go | 25 ++ docs/screenshots/reasoning-effort-pricing.png | Bin 0 -> 191361 bytes frontend/src/api/admin/channels.ts | 4 +- frontend/src/api/channels.ts | 2 +- .../admin/channel/PricingEntryCard.vue | 65 ++++- .../PricingEntryCard.timePricing.spec.ts | 83 +++++- .../admin/channel/__tests__/types.spec.ts | 28 ++ .../src/components/admin/channel/types.ts | 27 +- .../modelPlaza/PlazaModelPricingTable.vue | 19 +- .../__tests__/PlazaModelPricingTable.spec.ts | 22 +- frontend/src/constants/channel.ts | 4 + .../src/i18n/locales/en/admin/channels.ts | 11 +- frontend/src/i18n/locales/en/dashboard.ts | 4 +- .../src/i18n/locales/zh/admin/channels.ts | 11 +- frontend/src/i18n/locales/zh/dashboard.ts | 4 +- frontend/src/views/admin/ChannelsView.vue | 32 ++- frontend/src/views/admin/GroupsView.vue | 22 ++ .../__tests__/GroupsView.duplicate.spec.ts | 59 +++++ 52 files changed, 1709 insertions(+), 354 deletions(-) create mode 100644 backend/internal/repository/channel_reasoning_effort_migration_integration_test.go create mode 100644 backend/internal/repository/channel_reasoning_pricing_integration_test.go create mode 100644 backend/internal/repository/channel_repo_pricing_reasoning_test.go create mode 100644 backend/internal/service/channel_reasoning_pricing_test.go create mode 100644 backend/internal/service/gateway_compat_reasoning_pricing_test.go create mode 100644 backend/internal/service/gateway_reasoning_pricing_test.go create mode 100644 backend/internal/service/reasoning_effort_billing_test.go create mode 100644 backend/migrations/239_channel_reasoning_effort_multipliers.sql create mode 100644 backend/migrations/channel_reasoning_effort_multipliers_migration_test.go create mode 100644 docs/screenshots/reasoning-effort-pricing.png diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 3b65ac97a883..376e08f88ecb 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,22 +57,22 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` - FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` - TimePricing *channelTimePricingRequest `json:"time_pricing"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` } type channelTimePricingRequest struct { @@ -130,23 +130,23 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` - TimePricing *channelTimePricingResponse `json:"time_pricing"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` } type channelTimePricingResponse struct { @@ -260,23 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon intervals = append(intervals, intervalToResponse(iv)) } return channelModelPricingResponse{ - ID: p.ID, - Platform: platform, - Models: models, - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - FastMultiplier: p.FastMultiplier, - FlexMultiplier: p.FlexMultiplier, - MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingToResponse(p.TimePricing), + ID: p.ID, + Platform: platform, + Models: models, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, + ReasoningEffortMultipliers: p.ReasoningEffortMultipliers, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), } } @@ -353,29 +353,28 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult SortOrder: iv.SortOrder, }) } - var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64 + var fastMultiplier, flexMultiplier *float64 if allowChannelMultipliers { fastMultiplier = r.FastMultiplier flexMultiplier = r.FlexMultiplier - maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier } result = append(result, service.ChannelModelPricing{ - Platform: platform, - Models: r.Models, - BillingMode: billingMode, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - CacheWritePrice: r.CacheWritePrice, - CacheWrite1hPrice: r.CacheWrite1hPrice, - CacheReadPrice: r.CacheReadPrice, - FastMultiplier: fastMultiplier, - FlexMultiplier: flexMultiplier, - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier, - ImageInputPrice: r.ImageInputPrice, - ImageOutputPrice: r.ImageOutputPrice, - PerRequestPrice: r.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingRequestToService(r.TimePricing), + Platform: platform, + Models: r.Models, + BillingMode: billingMode, + InputPrice: r.InputPrice, + OutputPrice: r.OutputPrice, + CacheWritePrice: r.CacheWritePrice, + CacheWrite1hPrice: r.CacheWrite1hPrice, + CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, + ReasoningEffortMultipliers: r.ReasoningEffortMultipliers, + ImageInputPrice: r.ImageInputPrice, + ImageOutputPrice: r.ImageOutputPrice, + PerRequestPrice: r.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result @@ -620,15 +619,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { } response.Success(c, gin.H{ - "found": true, - "input_price": pricing.InputPricePerToken, - "output_price": pricing.OutputPricePerToken, - "cache_write_price": cacheWritePrice, - "cache_write_1h_price": cacheWrite1hPrice, - "cache_read_price": pricing.CacheReadPricePerToken, - "max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier, - "image_input_price": pricing.ImageInputPricePerToken, - "image_output_price": pricing.ImageOutputPricePerToken, + "found": true, + "input_price": pricing.InputPricePerToken, + "output_price": pricing.OutputPricePerToken, + "cache_write_price": cacheWritePrice, + "cache_write_1h_price": cacheWrite1hPrice, + "cache_read_price": pricing.CacheReadPricePerToken, + "reasoning_effort_multipliers": pricing.ReasoningEffortMultipliers, + "image_input_price": pricing.ImageInputPricePerToken, + "image_output_price": pricing.ImageOutputPricePerToken, }) } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index d735c5470754..5678a9499be5 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -449,15 +449,14 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) { require.Nil(t, got[0].TimePricing) } -// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃, -// 避免渠道倍率意外污染账号成本口径。 +// Fast/Flex 和区间倍率只用于渠道售价;思考等级倍率可在账号统计规则中独立配置。 func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { req := channelModelPricingRequest{ - Models: []string{"gpt-5"}, - BillingMode: "token", - FastMultiplier: float64Ptr(2.5), - FlexMultiplier: float64Ptr(0.5), - MaxReasoningEffortMultiplier: float64Ptr(3), + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, Intervals: []pricingIntervalRequest{{ MinTokens: 272000, InputMultiplier: float64Ptr(2), @@ -470,7 +469,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) - require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier) + require.Equal(t, req.ReasoningEffortMultipliers, allowed[0].ReasoningEffortMultipliers) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) @@ -479,7 +478,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) require.Nil(t, dropped[0].FastMultiplier) require.Nil(t, dropped[0].FlexMultiplier) - require.Nil(t, dropped[0].MaxReasoningEffortMultiplier) + require.Equal(t, req.ReasoningEffortMultipliers, dropped[0].ReasoningEffortMultipliers) require.Nil(t, dropped[0].Intervals[0].InputMultiplier) require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) @@ -511,6 +510,23 @@ func TestPricingToResponse_TimePricingNil(t *testing.T) { require.Nil(t, got.TimePricing) } +func TestPricingRequestAndResponse_ReasoningEffortMultipliers(t *testing.T) { + var req channelModelPricingRequest + require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{"none":0.5,"high":1.5,"max":3}}`), &req)) + pricing := pricingRequestToService([]channelModelPricingRequest{req}, true) + got := pricingToResponse(&pricing[0]) + require.Equal(t, map[string]float64{"none": 0.5, "high": 1.5, "max": 3}, got.ReasoningEffortMultipliers) + data, err := json.Marshal(got) + require.NoError(t, err) + require.Contains(t, string(data), `"reasoning_effort_multipliers":{"high":1.5,"max":3,"none":0.5}`) + require.NotContains(t, string(data), "max_reasoning_effort_multiplier") + + req = channelModelPricingRequest{} + require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{}}`), &req)) + pricing = pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Empty(t, pricing[0].ReasoningEffortMultipliers) +} + // --------------------------------------------------------------------------- // 3. SyncPricingModels handler // --------------------------------------------------------------------------- @@ -583,10 +599,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) var body struct { Data struct { - Found bool `json:"found"` - CacheWritePrice float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + Found bool `json:"found"` + CacheWritePrice float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` } `json:"data"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) @@ -594,8 +610,7 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12) require.NotNil(t, body.Data.CacheWrite1hPrice) require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12) - require.NotNil(t, body.Data.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier) + require.Empty(t, body.Data.ReasoningEffortMultipliers) } func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) { diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index ae01687bdbd2..46e871a0c321 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -66,17 +66,17 @@ type userAvailableGroup struct { // userSupportedModelPricing 用户可见的定价字段白名单。 type userSupportedModelPricing struct { - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []userPricingIntervalDTO `json:"intervals"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []userPricingIntervalDTO `json:"intervals"` } // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 @@ -332,16 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { billingMode = string(service.BillingModeToken) } return &userSupportedModelPricing{ - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + ReasoningEffortMultipliers: p.ReasoningEffortMultipliers, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, } } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 325f10133bde..41b70022e739 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -118,7 +118,8 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { cacheWriteMultiplier := 2.0 cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ - BillingMode: service.BillingModeToken, + BillingMode: service.BillingModeToken, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, Intervals: []service.PricingInterval{ { ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, @@ -128,6 +129,7 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { }, }) require.NotNil(t, pricing) + require.Equal(t, map[string]float64{"high": 1.5, "max": 3}, pricing.ReasoningEffortMultipliers) require.Len(t, pricing.Intervals, 1) rawIv, err := json.Marshal(pricing.Intervals[0]) require.NoError(t, err) diff --git a/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go b/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go new file mode 100644 index 000000000000..ff92dd2fc884 --- /dev/null +++ b/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +func TestMigration239ReasoningEffortMultipliers(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + _, err := tx.ExecContext(ctx, `ALTER TABLE channel_model_pricing DROP COLUMN reasoning_effort_multipliers`) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, `ALTER TABLE channel_account_stats_model_pricing DROP COLUMN reasoning_effort_multipliers`) + require.NoError(t, err) + + var channelID, configuredID, unsetID int64 + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channels (name) VALUES ('migration-reasoning-multipliers') RETURNING id`).Scan(&channelID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO channel_model_pricing (channel_id, models, max_reasoning_effort_multiplier) +VALUES ($1, '["custom-model"]', 2.5) RETURNING id`, channelID).Scan(&configuredID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO channel_model_pricing (channel_id, models) +VALUES ($1, '["claude-fable-5-1"]') RETURNING id`, channelID).Scan(&unsetID)) + + migrationSQL, err := dbmigrations.FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + var configured, unset string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, configuredID).Scan(&configured)) + require.JSONEq(t, `{"max":2.5}`, configured) + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, unsetID).Scan(&unset)) + require.JSONEq(t, `{}`, unset) + + // Changing or clearing the generic configuration must survive a migration replay. + for _, current := range []string{`{"high":1.5,"max":4}`, `{"low":0.75}`, `{}`} { + _, err = tx.ExecContext(ctx, `UPDATE channel_model_pricing SET reasoning_effort_multipliers = $1 WHERE id = $2`, current, configuredID) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, configuredID).Scan(&configured)) + require.JSONEq(t, current, configured) + } + + // Account statistics use the same empty-by-default shape. + var ruleID int64 + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channel_account_stats_pricing_rules (channel_id) VALUES ($1) RETURNING id`, channelID).Scan(&ruleID)) + var accountStatsDefault string + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channel_account_stats_model_pricing (rule_id) VALUES ($1) RETURNING reasoning_effort_multipliers::text`, ruleID).Scan(&accountStatsDefault)) + require.JSONEq(t, `{}`, accountStatsDefault) +} + +func TestMigration239GroupReasoningEffortMultipliers(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, model_pricing) +VALUES ('migration-group-reasoning', 'anthropic', '[ + {"models":["custom-model"],"input_price":2,"max_reasoning_effort_multiplier":2.5}, + {"models":["existing-map"],"max_reasoning_effort_multiplier":3,"reasoning_effort_multipliers":{"high":1.5,"max":4}}, + {"models":["cleared-map"],"max_reasoning_effort_multiplier":3,"reasoning_effort_multipliers":{}}, + {"models":["claude-fable-5-1"],"max_reasoning_effort_multiplier":null} +]'::jsonb) RETURNING id`).Scan(&groupID)) + + migrationSQL, err := dbmigrations.FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + for range 2 { + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + var actual string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT model_pricing::text FROM groups WHERE id = $1`, groupID).Scan(&actual)) + require.JSONEq(t, `[ + {"models":["custom-model"],"input_price":2,"reasoning_effort_multipliers":{"max":2.5}}, + {"models":["existing-map"],"reasoning_effort_multipliers":{"high":1.5,"max":4}}, + {"models":["cleared-map"],"reasoning_effort_multipliers":{}}, + {"models":["claude-fable-5-1"]} + ]`, actual) + } +} diff --git a/backend/internal/repository/channel_reasoning_pricing_integration_test.go b/backend/internal/repository/channel_reasoning_pricing_integration_test.go new file mode 100644 index 000000000000..325bdba2b4b0 --- /dev/null +++ b/backend/internal/repository/channel_reasoning_pricing_integration_test.go @@ -0,0 +1,181 @@ +//go:build integration + +package repository + +import ( + "context" + "math" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func newReasoningPricingIntegrationChannel(t *testing.T, channel *service.Channel) *channelRepository { + t.Helper() + repo := &channelRepository{db: integrationDB} + channel.Name = t.Name() + channel.Status = service.StatusActive + channel.BillingModelSource = service.BillingModelSourceRequested + require.NoError(t, repo.Create(context.Background(), channel)) + // Channel writes manage their own transactions, so clean up committed fixtures. + t.Cleanup(func() { + require.NoError(t, repo.Delete(context.Background(), channel.ID)) + }) + return repo +} + +func requireReasoningPricingIntegrationMaps(t *testing.T, pricing []service.ChannelModelPricing, expected map[string]map[string]float64) { + t.Helper() + require.Len(t, pricing, len(expected)) + for _, entry := range pricing { + require.Len(t, entry.Models, 1) + multipliers, ok := expected[entry.Models[0]] + require.True(t, ok, "unexpected model %s", entry.Models[0]) + if len(multipliers) == 0 { + require.Empty(t, entry.ReasoningEffortMultipliers, entry.Models[0]) + } else { + require.Equal(t, multipliers, entry.ReasoningEffortMultipliers, entry.Models[0]) + } + } +} + +func TestChannelReasoningPricingCreateUpdateReplace(t *testing.T) { + ctx := context.Background() + channel := &service.Channel{} + repo := newReasoningPricingIntegrationChannel(t, channel) + allLevels := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + pricing := service.ChannelModelPricing{ + ChannelID: channel.ID, Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, + ReasoningEffortMultipliers: allLevels, + } + require.NoError(t, repo.CreateModelPricing(ctx, &pricing)) + require.NotZero(t, pricing.ID) + assertStored := func(expected map[string]map[string]float64) { + t.Helper() + listed, err := repo.ListModelPricing(ctx, channel.ID) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, listed, expected) + // Cache refresh uses this batch loading path instead of ListModelPricing. + batch, err := repo.batchLoadModelPricing(ctx, []int64{channel.ID}) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, batch[channel.ID], expected) + } + assertStored(map[string]map[string]float64{"custom-reasoning-model": allLevels}) + + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 2.25} + require.NoError(t, repo.UpdateModelPricing(ctx, &pricing)) + assertStored(map[string]map[string]float64{"custom-reasoning-model": {"high": 2.25}}) + + pricing.ReasoningEffortMultipliers = nil + require.NoError(t, repo.UpdateModelPricing(ctx, &pricing)) + assertStored(map[string]map[string]float64{"custom-reasoning-model": nil}) + var stored string + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, pricing.ID).Scan(&stored)) + require.JSONEq(t, `{}`, stored) + + replacement := []service.ChannelModelPricing{ + {Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, ReasoningEffortMultipliers: map[string]float64{"low": 0.8, "max": 4}}, + {Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}, ReasoningEffortMultipliers: map[string]float64{}}, + } + require.NoError(t, repo.ReplaceModelPricing(ctx, channel.ID, replacement)) + expected := map[string]map[string]float64{"custom-reasoning-model": {"low": 0.8, "max": 4}, "claude-fable-5-1": nil} + assertStored(expected) + + // A serialization failure after deleting the old rows must roll back the replacement. + require.Error(t, repo.ReplaceModelPricing(ctx, channel.ID, []service.ChannelModelPricing{{ + Models: []string{"invalid"}, ReasoningEffortMultipliers: map[string]float64{"max": math.Inf(1)}, + }})) + assertStored(expected) +} + +func TestAccountStatsReasoningPricingRoundTrip(t *testing.T) { + ctx := context.Background() + channel := &service.Channel{ + ModelPricing: []service.ChannelModelPricing{{ + Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, + ReasoningEffortMultipliers: map[string]float64{"max": 4}, + }}, + AccountStatsPricingRules: []service.AccountStatsPricingRule{{ + Name: "independent account statistics", GroupIDs: []int64{}, AccountIDs: []int64{}, + Pricing: []service.ChannelModelPricing{ + {Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, ReasoningEffortMultipliers: map[string]float64{"none": 0.5, "high": 1.25, "max": 2.5}}, + {Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}}, + }, + }}, + } + repo := newReasoningPricingIntegrationChannel(t, channel) + assertStored := func(multipliers map[string]float64) *service.Channel { + t.Helper() + loaded, err := repo.GetByID(ctx, channel.ID) + require.NoError(t, err) + require.Len(t, loaded.AccountStatsPricingRules, 1) + expected := map[string]map[string]float64{"custom-reasoning-model": multipliers, "claude-fable-5-1": nil} + requireReasoningPricingIntegrationMaps(t, loaded.AccountStatsPricingRules[0].Pricing, expected) + batch, err := repo.batchLoadAccountStatsPricingRules(ctx, []int64{channel.ID}) + require.NoError(t, err) + require.Len(t, batch[channel.ID], 1) + requireReasoningPricingIntegrationMaps(t, batch[channel.ID][0].Pricing, expected) + // Custom statistics prices must not alter the user-facing channel prices. + requireReasoningPricingIntegrationMaps(t, loaded.ModelPricing, map[string]map[string]float64{"custom-reasoning-model": {"max": 4}}) + return loaded + } + loaded := assertStored(map[string]float64{"none": 0.5, "high": 1.25, "max": 2.5}) + for _, multipliers := range []map[string]float64{{"low": 0.75, "max": 1.5}, nil, {"high": 2}, {}} { + loaded.AccountStatsPricingRules[0].Pricing[0].ReasoningEffortMultipliers = multipliers + require.NoError(t, repo.Update(ctx, loaded)) + loaded = assertStored(multipliers) + } + var stored string + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT reasoning_effort_multipliers::text FROM channel_account_stats_model_pricing WHERE id = $1`, loaded.AccountStatsPricingRules[0].Pricing[0].ID).Scan(&stored)) + require.JSONEq(t, `{}`, stored) +} + +func TestGroupReasoningPricingRoundTripAndBilling(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newGroupRepositoryWithSQL(tx.Client(), tx) + inputPrice, outputPrice := 1e-6, 2e-6 + allLevels := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + group := &service.Group{ + Name: t.Name(), Platform: service.PlatformAnthropic, RateMultiplier: 0.8, + Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, + ModelPricing: []service.ChannelModelPricing{{ + Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: service.BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, ReasoningEffortMultipliers: allLevels, + }}, + } + require.NoError(t, repo.Create(ctx, group)) + billing := service.NewBillingService(&config.Config{}, nil) + resolver := service.NewModelPricingResolver(nil, billing) + assertStoredAndBilled := func(expected map[string]float64) *service.Group { + t.Helper() + loaded, err := repo.GetByID(ctx, group.ID) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, loaded.ModelPricing, map[string]map[string]float64{"claude-fable-5-1": expected}) + for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", ""} { + multiplier := 1.0 + if configured, ok := expected[effort]; ok { + multiplier = configured + } + cost, err := billing.CalculateTokenCostForRequest(service.TokenCostRequest{ + Ctx: ctx, Model: "claude-fable-5-1", Group: loaded, Resolver: resolver, + Tokens: service.UsageTokens{InputTokens: 1000, OutputTokens: 200}, + RateMultiplier: loaded.RateMultiplier, ReasoningEffort: effort, + }) + require.NoError(t, err) + require.InDelta(t, 0.0014*multiplier, cost.TotalCost, 1e-12, "effort=%q", effort) + require.InDelta(t, 0.0014*multiplier*0.8, cost.ActualCost, 1e-12, "effort=%q", effort) + } + return loaded + } + loaded := assertStoredAndBilled(allLevels) + for _, multipliers := range []map[string]float64{{"high": 2.25, "max": 4}, nil, {"high": 1.75}, {}} { + loaded.ModelPricing[0].ReasoningEffortMultipliers = multipliers + require.NoError(t, repo.Update(ctx, loaded)) + loaded = assertStoredAndBilled(multipliers) + } +} diff --git a/backend/internal/repository/channel_repo_account_stats_pricing.go b/backend/internal/repository/channel_repo_account_stats_pricing.go index 22922c689edc..f2a4786ce1fb 100644 --- a/backend/internal/repository/channel_repo_account_stats_pricing.go +++ b/backend/internal/repository/channel_repo_account_stats_pricing.go @@ -67,7 +67,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex rows, err := r.db.QueryContext(ctx, `SELECT id, rule_id, platform, models, billing_mode, input_price, output_price, - cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + cache_write_price, cache_write_1h_price, cache_read_price, reasoning_effort_multipliers, image_output_price, per_request_price, created_at, updated_at FROM channel_account_stats_model_pricing WHERE rule_id = ANY($1) ORDER BY rule_id, id`, pq.Array(ruleIDs), ) @@ -81,16 +81,22 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex var p service.ChannelModelPricing var ruleID int64 var modelsJSON []byte + var reasoningEffortMultipliersJSON []byte if err := rows.Scan( &p.ID, &ruleID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, + &reasoningEffortMultipliersJSON, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan account stats model pricing: %w", err) } if err := json.Unmarshal(modelsJSON, &p.Models); err != nil { p.Models = []string{} } + multipliers, err := unmarshalReasoningEffortMultipliers(reasoningEffortMultipliersJSON) + if err != nil { + return nil, err + } + p.ReasoningEffortMultipliers = multipliers pricingMap[ruleID] = append(pricingMap[ruleID], p) } if err := rows.Err(); err != nil { @@ -172,17 +178,21 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } billingMode := pricing.BillingMode if billingMode == "" { billingMode = service.BillingModeToken } platform := pricing.Platform err = tx.QueryRowContext(ctx, - `INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, reasoning_effort_multipliers, image_output_price, per_request_price) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at`, ruleID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.ImageOutputPrice, pricing.PerRequestPrice, + reasoningEffortMultipliersJSON, pricing.ImageOutputPrice, pricing.PerRequestPrice, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert account stats model pricing: %w", err) diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 8053d4453f45..e800c223dc3a 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -51,6 +51,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) if err != nil { return err @@ -61,10 +65,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, reasoning_effort_multipliers = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() WHERE id = $16`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, reasoningEffortMultipliersJSON, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { @@ -96,7 +100,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -177,10 +181,11 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 var p service.ChannelModelPricing var modelsJSON []byte var timePricingJSON []byte + var reasoningEffortMultipliersJSON []byte if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier, + &p.FastMultiplier, &p.FlexMultiplier, &reasoningEffortMultipliersJSON, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -188,6 +193,11 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := json.Unmarshal(modelsJSON, &p.Models); err != nil { p.Models = []string{} } + multipliers, err := unmarshalReasoningEffortMultipliers(reasoningEffortMultipliersJSON) + if err != nil { + return nil, nil, err + } + p.ReasoningEffortMultipliers = multipliers timePricing, err := unmarshalChannelTimePricing(timePricingJSON) if err != nil { return nil, nil, err @@ -234,6 +244,10 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) if err != nil { return err @@ -247,11 +261,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, reasoningEffortMultipliersJSON, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { @@ -268,6 +282,31 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C return nil } +func marshalReasoningEffortMultipliers(multipliers map[string]float64) (string, error) { + if len(multipliers) == 0 { + return "{}", nil + } + data, err := json.Marshal(multipliers) + if err != nil { + return "", fmt.Errorf("marshal reasoning effort multipliers: %w", err) + } + return string(data), nil +} + +func unmarshalReasoningEffortMultipliers(data []byte) (map[string]float64, error) { + multipliers := make(map[string]float64) + if len(data) == 0 { + return multipliers, nil + } + if err := json.Unmarshal(data, &multipliers); err != nil { + return nil, fmt.Errorf("unmarshal reasoning effort multipliers: %w", err) + } + if multipliers == nil { + multipliers = make(map[string]float64) + } + return multipliers, nil +} + func marshalChannelTimePricing(config *service.ChannelTimePricing) (any, error) { if config == nil || len(config.Periods) == 0 { return nil, nil diff --git a/backend/internal/repository/channel_repo_pricing_reasoning_test.go b/backend/internal/repository/channel_repo_pricing_reasoning_test.go new file mode 100644 index 000000000000..2cf412c67036 --- /dev/null +++ b/backend/internal/repository/channel_repo_pricing_reasoning_test.go @@ -0,0 +1,175 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql/driver" + "math" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type capturedReasoningMultipliersJSON struct { + value string +} + +func (c *capturedReasoningMultipliersJSON) Match(value driver.Value) bool { + data, ok := value.(string) + if ok { + c.value = data + } + return ok +} + +func reasoningPricingRow(multipliers any) *sqlmock.Rows { + return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( + int64(11), int64(7), "openai", `["custom-model"]`, service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, multipliers, nil, nil, nil, nil, + time.Time{}, time.Time{}, + ) +} + +func TestChannelReasoningEffortMultipliersRoundTrip(t *testing.T) { + tests := []struct { + name string + multipliers map[string]float64 + wantJSON string + }{ + {"custom levels", map[string]float64{"low": 0.75, "high": 1.5, "max": 4}, `{"low":0.75,"high":1.5,"max":4}`}, + {"nil clears configuration", nil, `{}`}, + {"empty clears configuration", map[string]float64{}, `{}`}, + } + for _, tt := range tests { + for _, operation := range []string{"create", "update", "replace"} { + t.Run(tt.name+"/"+operation, func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + ctx := context.Background() + pricing := &service.ChannelModelPricing{ + ID: 11, ChannelID: 7, Platform: "openai", Models: []string{"custom-model"}, + ReasoningEffortMultipliers: tt.multipliers, + } + stored := &capturedReasoningMultipliersJSON{} + if operation == "update" { + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*reasoning_effort_multipliers = \$10.*WHERE id = \$16`). + WithArgs([]byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, stored, nil, nil, nil, nil, "openai", int64(11)). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, repo.UpdateModelPricing(ctx, pricing)) + } else { + if operation == "replace" { + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM channel_model_pricing WHERE channel_id = \$1`). + WithArgs(int64(7)).WillReturnResult(sqlmock.NewResult(0, 1)) + } + mock.ExpectQuery(`INSERT INTO channel_model_pricing .*reasoning_effort_multipliers`). + WithArgs(int64(7), "openai", []byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, stored, nil, nil, nil, nil). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + if operation == "replace" { + mock.ExpectCommit() + require.NoError(t, repo.ReplaceModelPricing(ctx, 7, []service.ChannelModelPricing{*pricing})) + } else { + require.NoError(t, repo.CreateModelPricing(ctx, pricing)) + } + } + require.JSONEq(t, tt.wantJSON, stored.value) + + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_model_pricing.*channel_id = \$1`). + WithArgs(int64(7)).WillReturnRows(reasoningPricingRow(stored.value)) + expectEmptyModelPricingIntervals(mock) + loaded, err := repo.ListModelPricing(ctx, 7) + require.NoError(t, err) + require.Len(t, loaded, 1) + if len(tt.multipliers) == 0 { + require.Empty(t, loaded[0].ReasoningEffortMultipliers) + } else { + require.Equal(t, tt.multipliers, loaded[0].ReasoningEffortMultipliers) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} + +func TestChannelReasoningEffortMultipliersBatchLoad(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_model_pricing.*channel_id = ANY`). + WithArgs(pq.Array([]int64{7})).WillReturnRows(reasoningPricingRow(`{"medium":1.25,"high":2}`)) + expectEmptyModelPricingIntervals(mock) + pricing, err := repo.batchLoadModelPricing(context.Background(), []int64{7}) + require.NoError(t, err) + require.Len(t, pricing[7], 1) + require.Equal(t, map[string]float64{"medium": 1.25, "high": 2}, pricing[7][0].ReasoningEffortMultipliers) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChannelReasoningEffortMultipliersInvalidJSON(t *testing.T) { + for _, data := range []string{`{"high":`, `{"high":"2"}`, `[]`} { + t.Run(data, func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`SELECT .*reasoning_effort_multipliers`).WithArgs(int64(7)).WillReturnRows(reasoningPricingRow(data)) + _, err := repo.ListModelPricing(context.Background(), 7) + require.ErrorContains(t, err, "unmarshal reasoning effort multipliers") + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + for _, operation := range []string{"create", "update"} { + t.Run(operation+" invalid number", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + pricing := &service.ChannelModelPricing{ReasoningEffortMultipliers: map[string]float64{"max": math.NaN()}} + var err error + if operation == "create" { + err = repo.CreateModelPricing(context.Background(), pricing) + } else { + err = repo.UpdateModelPricing(context.Background(), pricing) + } + require.ErrorContains(t, err, "marshal reasoning effort multipliers") + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestAccountStatsReasoningEffortMultipliersRoundTrip(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + ctx := context.Background() + pricing := &service.ChannelModelPricing{ + Platform: "anthropic", Models: []string{"custom-model"}, + ReasoningEffortMultipliers: map[string]float64{"medium": 0.5, "high": 2}, + } + stored := &capturedReasoningMultipliersJSON{} + mock.ExpectBegin() + tx, err := repo.db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectQuery(`INSERT INTO channel_account_stats_model_pricing .*reasoning_effort_multipliers`). + WithArgs(int64(9), "anthropic", []byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, stored, nil, nil). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + require.NoError(t, createAccountStatsModelPricingTx(ctx, tx, 9, pricing)) + mock.ExpectCommit() + require.NoError(t, tx.Commit()) + require.JSONEq(t, `{"medium":0.5,"high":2}`, stored.value) + + columns := []string{ + "id", "rule_id", "platform", "models", "billing_mode", "input_price", "output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "reasoning_effort_multipliers", + "image_output_price", "per_request_price", "created_at", "updated_at", + } + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_account_stats_model_pricing`). + WithArgs(pq.Array([]int64{9})).WillReturnRows(sqlmock.NewRows(columns).AddRow( + int64(11), int64(9), "anthropic", `["custom-model"]`, service.BillingModeToken, + nil, nil, nil, nil, nil, stored.value, nil, nil, time.Time{}, time.Time{}, + )) + mock.ExpectQuery(`(?s)SELECT .*FROM channel_account_stats_pricing_intervals`). + WithArgs(pq.Array([]int64{11})).WillReturnRows(sqlmock.NewRows([]string{"id"})) + loaded, err := repo.batchLoadAccountStatsModelPricing(ctx, []int64{9}) + require.NoError(t, err) + require.Len(t, loaded[9], 1) + require.Equal(t, pricing.ReasoningEffortMultipliers, loaded[9][0].ReasoningEffortMultipliers) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index c22630abcfc5..a58cb4e5d9a5 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "reasoning_effort_multipliers", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -121,7 +121,7 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -169,7 +169,7 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index f27d5bb3674a..4a738ccf7715 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -19,7 +19,7 @@ import ( // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 // pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 -// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 +// reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, @@ -116,7 +116,7 @@ func tryCustomRules( } cost := calculateStatsCost(pricing, tokens, requestCount) if cost != nil { - *cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil) + *cost *= reasoningEffortBillingMultiplier(reasoningEffort, pricing.ReasoningEffortMultipliers) } return cost } @@ -192,7 +192,7 @@ func calculateStatsCost(pricing *ChannelModelPricing, tokens UsageTokens, reques return nil } switch pricing.BillingMode { - case BillingModePerRequest, BillingModeImage: + case BillingModePerRequest, BillingModeImage, BillingModeVideo: return calculatePerRequestStatsCost(pricing, requestCount) default: return calculateTokenStatsCost(pricing, tokens) diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index e53810b27977..622fc73f3c9a 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -478,7 +478,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } -func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { +func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ "claude-fable-5-1": {InputPricePerToken: 0.001}, }) @@ -487,7 +487,7 @@ func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") require.NotNil(t, standard) require.NotNil(t, max) - require.InDelta(t, *standard*3, *max, 1e-12) + require.Equal(t, *standard, *max) } func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 55e4f08d9bec..29161ce338c3 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "maps" "math" "strings" "sync" @@ -93,29 +94,29 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价 - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) - CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 - FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 - MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为 - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价 + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 + FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + ReasoningEffortMultipliers map[string]float64 // 最终转发的推理等级对应的计费倍率;未配置的等级按 1 倍计费 + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } func normalizeBillingServiceTier(serviceTier string) string { @@ -217,8 +218,6 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { cost.ActualCost *= multiplier } -const claudeFable51MaxReasoningEffortMultiplier = 3.0 - func isClaudeFable51Model(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} { @@ -232,25 +231,19 @@ func isClaudeFable51Model(model string) bool { return false } -func defaultMaxReasoningEffortMultiplier(model string) *float64 { - if !isClaudeFable51Model(model) { - return nil +func reasoningEffortBillingMultiplier(effort string, multipliers map[string]float64) float64 { + effort = strings.ToLower(strings.TrimSpace(effort)) + if effort != "none" { + effort = NormalizeMaxReasoningEffort(effort) } - multiplier := claudeFable51MaxReasoningEffortMultiplier - return &multiplier -} - -func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 { - if NormalizeMaxReasoningEffort(effort) != "max" { + if effort == "" { return 1 } - if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 { - return *pricing.MaxReasoningEffortMultiplier - } - if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil { - return *multiplier + multiplier := multipliers[effort] + if multiplier <= 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) { + return 1 } - return 1 + return multiplier } func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { @@ -1311,9 +1304,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing applyChannelTokenPriceOverrides(pricing, channelPricing) pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier - if channelPricing.MaxReasoningEffortMultiplier != nil { - pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier - } + pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers) if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1386,7 +1377,7 @@ type CostInput struct { RateMultiplier float64 PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 - ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率 + ReasoningEffort string // 最终转发的推理等级,按匹配定价中配置的等级倍率计费 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool @@ -1401,18 +1392,13 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if input.LongContextBillingEnabled != nil { applyLongContextBilling = *input.LongContextBillingEnabled } - breakdown, err := s.calculateCostInternalWithPolicy( - input.Model, - input.Tokens, - input.RateMultiplier, - input.ServiceTier, - nil, - applyLongContextBilling, - ) - if err == nil { - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil)) + pricing, err := s.GetModelPricing(input.Model) + if err != nil { + return nil, err } - return breakdown, err + breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling) + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) + return breakdown, nil } // 优先使用预解析结果,避免重复 Resolve 调用 @@ -1435,6 +1421,9 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, switch resolved.Mode { case BillingModePerRequest, BillingModeImage, BillingModeVideo: breakdown, err = s.calculatePerRequestCost(resolved, input) + if err == nil && resolved.channelPricing != nil { + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, resolved.channelPricing.ReasoningEffortMultipliers)) + } default: // BillingModeToken breakdown, err = s.calculateTokenCost(resolved, input) } @@ -1498,7 +1487,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing)) + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) return breakdown, nil } @@ -1790,17 +1779,13 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) - needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier { + if !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing - if needsMaxReasoningEffortMultiplier { - cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model) - } if isGPT56 && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 0a68e07ad73d..59f20e076276 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1807,8 +1807,7 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) { require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) - require.NotNil(t, pricing.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier) + require.Empty(t, pricing.ReasoningEffortMultipliers) } func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 23fae53557e1..fe4390b22793 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -217,7 +217,7 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) require.Equal(t, want, got) } -func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) { +func TestCalculateTokenCostForRequest_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { bs := NewBillingService(&config.Config{}, nil) tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ @@ -228,18 +228,15 @@ func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *t Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max", }) require.NoError(t, err) - require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12) - require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12) - require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12) - require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12) + require.Equal(t, standard, max) } -func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) { +func TestCalculateTokenCostForRequest_ChannelConfiguresReasoningEffortMultiplier(t *testing.T) { configured := 1.5 bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{ Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6), - MaxReasoningEffortMultiplier: &configured, + ReasoningEffortMultipliers: map[string]float64{"max": configured}, }}, nil) group := &Group{ID: 100, Platform: PlatformAnthropic} gid := group.ID diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index f9fea3bcc9f1..43dfbc2407b5 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "maps" "sort" "strings" "time" @@ -87,26 +88,26 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // ChannelTimePricing 渠道模型定价的分时倍率配置。 @@ -208,9 +209,10 @@ func (p *ChannelModelPricing) GetTierByLabel(label string) *PricingInterval { return nil } -// Clone 返回 ChannelModelPricing 的拷贝(切片独立,指针字段共享,调用方只读安全) +// Clone 返回 ChannelModelPricing 的拷贝(切片和映射独立,价格指针字段共享,调用方只读安全) func (p ChannelModelPricing) Clone() ChannelModelPricing { cp := p + cp.ReasoningEffortMultipliers = maps.Clone(p.ReasoningEffortMultipliers) if p.Models != nil { cp.Models = make([]string, len(p.Models)) copy(cp.Models, p.Models) diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 46eb15d6bb91..262a384821b4 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "maps" "sort" "strings" ) @@ -117,7 +118,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。 +// 当 pricingService 为 nil(测试场景),跳过价格回落。 // 可用渠道与模型广场共用。 func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { for i := range models { @@ -126,7 +127,6 @@ func fillGlobalPricingFallback(pricingService *PricingService, models []Supporte models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) } } - models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name) } } @@ -176,31 +176,31 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode if mode == BillingModeImage || mode == BillingModePerRequest { return &ChannelModelPricing{ - BillingMode: mode, - PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), + BillingMode: mode, + PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + ReasoningEffortMultipliers: reasoningEffortMultipliersFromPricing(existing), } } return &ChannelModelPricing{ - BillingMode: mode, - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), + BillingMode: mode, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + ReasoningEffortMultipliers: reasoningEffortMultipliersFromPricing(existing), } } -func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 { +func reasoningEffortMultipliersFromPricing(pricing *ChannelModelPricing) map[string]float64 { if pricing == nil { return nil } - return pricing.MaxReasoningEffortMultiplier + return maps.Clone(pricing.ReasoningEffortMultipliers) } func nonZeroPtr(v float64) *float64 { diff --git a/backend/internal/service/channel_reasoning_pricing_test.go b/backend/internal/service/channel_reasoning_pricing_test.go new file mode 100644 index 000000000000..b1b03e3ce52f --- /dev/null +++ b/backend/internal/service/channel_reasoning_pricing_test.go @@ -0,0 +1,42 @@ +//go:build unit + +package service + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidatePricingEntries_ReasoningEffortMultipliers(t *testing.T) { + for _, tc := range []struct { + name string + multipliers map[string]float64 + valid bool + }{ + {"unset", nil, true}, + {"cleared", map[string]float64{}, true}, + {"all levels", map[string]float64{"none": 0.5, "minimal": 1, "low": 1.25, "medium": 1.5, "high": 2, "xhigh": 2.5, "max": 3}, true}, + {"unknown level", map[string]float64{"ultra": 2}, false}, + {"blank level", map[string]float64{"": 2}, false}, + {"noncanonical level", map[string]float64{"MAX": 2}, false}, + {"zero", map[string]float64{"high": 0}, false}, + {"negative", map[string]float64{"high": -1}, false}, + {"nan", map[string]float64{"high": math.NaN()}, false}, + {"infinity", map[string]float64{"high": math.Inf(1)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + pricing := []ChannelModelPricing{{Models: []string{"custom-model"}, ReasoningEffortMultipliers: tc.multipliers}} + err := validatePricingEntries(pricing) + statsErr := validateAccountStatsPricingRules([]AccountStatsPricingRule{{Pricing: pricing}}) + if tc.valid { + require.NoError(t, err) + require.NoError(t, statsErr) + } else { + require.Error(t, err) + require.Error(t, statsErr) + } + }) + } +} diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 0d34f4401471..fb59d7f7bbe0 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "math" "strings" "sync/atomic" "time" @@ -687,9 +688,30 @@ func validatePricingEntries(pricing []ChannelModelPricing) error { if err := validatePricingBillingMode(pricing); err != nil { return err } + if err := validateReasoningEffortMultipliers(pricing); err != nil { + return err + } return validatePricingTimePricing(pricing) } +func validateReasoningEffortMultipliers(pricing []ChannelModelPricing) error { + for _, p := range pricing { + for effort, multiplier := range p.ReasoningEffortMultipliers { + switch effort { + case "none", "minimal", "low", "medium", "high", "xhigh", "max": + default: + return infraerrors.BadRequest("INVALID_REASONING_EFFORT_MULTIPLIER", + fmt.Sprintf("unsupported reasoning effort %q for models %v", effort, p.Models)) + } + if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 0 { + return infraerrors.BadRequest("INVALID_REASONING_EFFORT_MULTIPLIER", + fmt.Sprintf("reasoning_effort_multipliers.%s must be a finite number > 0", effort)) + } + } + } + return nil +} + func validatePricingTimePricing(pricing []ChannelModelPricing) error { for i := range pricing { config := pricing[i].TimePricing diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 42c2c46448d6..973a8cfd08f6 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -193,7 +193,8 @@ func TestChannelClone_Nil(t *testing.T) { func TestChannelModelPricingClone(t *testing.T) { original := ChannelModelPricing{ - Models: []string{"a", "b"}, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + Models: []string{"a", "b"}, Intervals: []PricingInterval{ {MinTokens: 0, TierLabel: "tier1"}, }, @@ -209,6 +210,8 @@ func TestChannelModelPricingClone(t *testing.T) { } cloned := original.Clone() + cloned.ReasoningEffortMultipliers["high"] = 2 + require.Equal(t, 1.5, original.ReasoningEffortMultipliers["high"]) // Modify clone slices — original unchanged cloned.Models[0] = "hacked" diff --git a/backend/internal/service/gateway_compat_reasoning_pricing_test.go b/backend/internal/service/gateway_compat_reasoning_pricing_test.go new file mode 100644 index 000000000000..d06c623d06ad --- /dev/null +++ b/backend/internal/service/gateway_compat_reasoning_pricing_test.go @@ -0,0 +1,115 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestGatewayAnthropicCompatReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + endpoints := []struct { + name string + path string + call func(*GatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error) + }{ + {"chat_completions", "/v1/chat/completions", func(s *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return s.ForwardAsChatCompletions(ctx, c, account, body, nil) + }}, + {"responses", "/v1/responses", func(s *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return s.ForwardAsResponses(ctx, c, account, body, nil) + }}, + } + for _, endpoint := range endpoints { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + name string + model string + effort string + thinking string + wantEffort string + multiplier float64 + }{ + {"xhigh_converts_to_max", "claude-fable-5-1", "xhigh", "", "max", 3}, + {"native_max", "claude-fable-5-1", "max", "", "max", 3}, + {"missing_effort", "kimi-k3", "", "", "", 1}, + {"disabled_thinking", "kimi-k3", "", "disabled", "", 1}, + {"unforwarded_thinking", "kimi-k3", "", "enabled", "", 1}, + } { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(endpoint.name+"/"+mode+"/"+tc.name, func(t *testing.T) { + request := map[string]any{"model": tc.model, "stream": stream} + if endpoint.name == "chat_completions" { + request["messages"] = []map[string]string{{"role": "user", "content": "hello"}} + if tc.effort != "" { + request["reasoning_effort"] = tc.effort + } + } else { + request["input"] = "hello" + if tc.effort != "" { + request["reasoning"] = map[string]string{"effort": tc.effort} + } + } + if tc.thinking != "" { + request["thinking"] = map[string]string{"type": tc.thinking} + } + body, err := json.Marshal(request) + require.NoError(t, err) + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream())), + }}} + svc := &GatewayService{ + cfg: &config.Config{}, httpUpstream: upstream, + tlsFPProfileService: &TLSFingerprintProfileService{}, + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, endpoint.path, strings.NewReader(string(body))) + account := &Account{ + ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test-key"}, + } + result, err := endpoint.call(svc, context.Background(), c, account, body) + require.NoError(t, err) + require.Len(t, upstream.requestBodies, 1) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.requestBodies[0], "output_config.effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + if tc.wantEffort == "" { + require.Nil(t, result.ReasoningEffort) + require.False(t, OpenAIBodyHasThinkingEnabled(upstream.requestBodies[0])) + } + + billing := NewBillingService(&config.Config{}, nil) + group := &Group{ID: 1, Platform: PlatformAnthropic, ModelPricing: []ChannelModelPricing{{ + Models: []string{tc.model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), + ReasoningEffortMultipliers: map[string]float64{"xhigh": 2, "max": 3, "high": 1.5}, + }}} + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: result.Model, Group: group, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, 20e-6*tc.multiplier, cost.TotalCost, 1e-12) + }) + } + } + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40da8..894e8535568b 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -119,11 +119,15 @@ func (s *GatewayService) ForwardAsChatCompletions( // 10. Build upstream request upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) + upstreamReq, forwardedBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the final Anthropic effort after conversion and account normalization. + // For example, OpenAI xhigh is forwarded as output_config.effort=max. + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel) // 11. Send request resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) @@ -173,14 +177,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) } - // 13. Extract reasoning effort from CC request body - reasoningEffort := extractCCReasoningEffortFromBody(body, mappedModel, originalModel) - // 国产模型默认 effort 补充:本路径是客户端 CC 请求 → Anthropic 上游, - // 如果上游是 passback-required 国产模型 (Kimi-anthropic / GLM-anthropic / MiniMax) - // 且客户端在 body 里传了 thinking.type=enabled,补中默认 effort。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) - - // 14. Handle normal response + // 13. Handle normal response // Read Anthropic SSE → convert to Responses events → convert to CC format var result *ForwardResult var handleErr error diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 1b97a739616d..999b544adc4e 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -85,9 +85,6 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, mappedModel, originalModel) - // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 mapping 完成之后。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) anthropicReq.Model = mappedModel logger.L().Debug("gateway forward_as_responses: model mapping applied", @@ -132,11 +129,15 @@ func (s *GatewayService) ForwardAsResponses( // 10. Build upstream request upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) + upstreamReq, forwardedBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the final Anthropic effort after conversion and account normalization. + // For example, OpenAI xhigh is forwarded as output_config.effort=max. + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel) // 11. Send request resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) diff --git a/backend/internal/service/gateway_reasoning_pricing_test.go b/backend/internal/service/gateway_reasoning_pricing_test.go new file mode 100644 index 000000000000..1aeaba6233e7 --- /dev/null +++ b/backend/internal/service/gateway_reasoning_pricing_test.go @@ -0,0 +1,70 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRecordUsage_ReasoningPricingUsesForwardedEffort(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI} { + t.Run(platform, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + groupID := int64(77) + inputPrice, outputPrice := 0.001, 0.002 + requested, forwarded := "max", "high" + model := "gpt-5.4" + if platform == PlatformAnthropic { + model = "claude-fable-5-1" + } + apiKey := &APIKey{ + ID: 10, GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: platform, Status: StatusActive, Hydrated: true, RateMultiplier: 0.5, + ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, + }}, + }, + } + account := &Account{ID: 30, Platform: platform, Type: AccountTypeAPIKey} + user := &User{ID: 20} + if platform == PlatformAnthropic { + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "reasoning_pricing", Model: model, Duration: time.Second, + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + ReasoningEffort: &forwarded, RequestedReasoningEffort: &requested, + }, + APIKey: apiKey, User: user, Account: account, + })) + } else { + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "reasoning_pricing", Model: model, Duration: time.Second, + Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50}, + ReasoningEffort: &forwarded, RequestedReasoningEffort: &requested, + }, + APIKey: apiKey, User: user, Account: account, + })) + } + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.3, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.15, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, forwarded, *usageRepo.lastLog.ReasoningEffort) + require.Equal(t, requested, *usageRepo.lastLog.RequestedReasoningEffort) + require.InDelta(t, 0.15, userRepo.lastAmount, 1e-12) + }) + } +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index b1618acf7881..17cb9b1b9094 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -224,6 +224,9 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error // token 模型取计费阶梯表(单价与档位均由真实计费函数得出), // 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。 func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) { + if groupPricing := matchGroupModelPricing(g, m.Name); groupPricing != nil { + m.Pricing = groupPricing + } if s.billingService != nil && s.resolver != nil { sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{ Model: m.Name, @@ -231,7 +234,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) + m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,20 +242,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) -} - -func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { - if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { - return pricing - } - multiplier := defaultMaxReasoningEffortMultiplier(model) - if multiplier == nil { - return pricing - } - cloned := pricing.Clone() - cloned.MaxReasoningEffortMultiplier = multiplier - return &cloned + m.Pricing = plazaImageDisplayPricing(m.Pricing, g) } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -263,7 +253,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice - out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier + out.ReasoningEffortMultipliers = reasoningEffortMultipliersFromPricing(raw) } first := sched.Tiers[0] out.InputPrice = first.Input diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index e7c5f4ffb9f8..fa2e7a8345a1 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,17 +57,14 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } -func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { - base := &ChannelModelPricing{BillingMode: BillingModeToken} - got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") - require.NotSame(t, base, got) - require.NotNil(t, got.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) - require.Nil(t, base.MaxReasoningEffortMultiplier) - - configured := 1.25 - custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} - require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +func TestListPlazaGroups_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { + ch := plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-fable-5-1") + svc := newPlazaService([]Channel{ch}, []Group{{ID: 10, Platform: "anthropic"}}, nil) + groups, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].Models, 1) + require.Empty(t, groups[0].Models[0].Pricing.ReasoningEffortMultipliers) } func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index ecd3d4520907..12c569fedd6b 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "maps" "strings" ) @@ -247,9 +248,7 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier - if chPricing.MaxReasoningEffortMultiplier != nil { - resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier - } + resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers) // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index e5ddc972f892..c92528f2d730 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -80,10 +80,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) - // 3. Rewrite model in body (no protocol conversion) upstreamBody := body if upstreamModel != originalModel { @@ -188,6 +184,10 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if customUA == "" && account.IsGrokOAuth() { customUA = defaultGrokUpstreamUserAgent() } + // Record the final provider-normalized effort, so usage and pricing match + // the outbound request (for example GLM xhigh is forwarded as max). + reasoningEffort := extractOpenAIReasoningEffortFromBody(upstreamBody, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, upstreamBody, upstreamModel) resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { return nil, err diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index a67ad5fca92e..433cb8cfaf21 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -456,6 +456,9 @@ func TestForwardAsRawChatCompletions_NormalizesGLMReasoningEffortForUpstream(t * require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) + require.Equal(t, 3.0, reasoningEffortBillingMultiplier(*result.ReasoningEffort, map[string]float64{"xhigh": 2, "max": 3})) } func TestForwardAsRawChatCompletions_SilentRefusalTriggersFailover(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 2c8aa8d4b4cb..9d2d1e4e3cb2 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -2312,9 +2312,7 @@ func normalizeOpenAIReasoningEffort(raw string) string { value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) switch value { - case "none", "minimal": - return "" - case "low", "medium", "high": + case "none", "minimal", "low", "medium", "high": return value case "xhigh", "extrahigh", "max": return "xhigh" diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 3b7e80bc782c..5142b7934dd0 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -932,10 +932,10 @@ func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) { wantValue: "xhigh", }, { - name: "minimal 归一化为空", - body: []byte(`{"reasoning":{"effort":"minimal"}}`), - model: "gpt-5-high", - wantNil: true, + name: "minimal 保留用于计费", + body: []byte(`{"reasoning":{"effort":"minimal"}}`), + model: "gpt-5-high", + wantValue: "minimal", }, { name: "缺失字段时从模型后缀推导", diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6ae03bb5a274..3e253e378fc0 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -726,17 +726,13 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( LongContextBillingEnabled: longContextBillingGate, }) } - breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( + return s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) - if err == nil { - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) - } - return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( diff --git a/backend/internal/service/reasoning_effort_billing_test.go b/backend/internal/service/reasoning_effort_billing_test.go new file mode 100644 index 000000000000..c62b4bf387e5 --- /dev/null +++ b/backend/internal/service/reasoning_effort_billing_test.go @@ -0,0 +1,245 @@ +//go:build unit + +package service + +import ( + "context" + "math" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestReasoningEffortBillingMultiplier(t *testing.T) { + multipliers := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + for _, tc := range []struct { + effort string + want float64 + }{ + {"none", 0.5}, {"minimal", 0.75}, {"low", 0.9}, {"medium", 1.25}, + {"high", 1.5}, {"xhigh", 2}, {"max", 3}, {" Extra-High ", 2}, + {" MAX ", 3}, {"", 1}, {"unknown", 1}, + } { + t.Run(tc.effort, func(t *testing.T) { + require.Equal(t, tc.want, reasoningEffortBillingMultiplier(tc.effort, multipliers)) + }) + } + require.Equal(t, 1.0, reasoningEffortBillingMultiplier("max", nil)) + for _, invalid := range []float64{0, -1, math.NaN(), math.Inf(1)} { + require.Equal(t, 1.0, reasoningEffortBillingMultiplier("max", map[string]float64{"max": invalid})) + } +} + +func TestReasoningEffortBillingSupportsAllModelsAndLevels(t *testing.T) { + for _, model := range []string{"claude-fable-5-1", "gpt-5.4", "gemini-2.5-pro", "custom-model"} { + t.Run(model, func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), + ReasoningEffortMultipliers: map[string]float64{"none": 0.5, "high": 1.5, "max": 3}, + }}, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI} + for effort, multiplier := range map[string]float64{"none": 0.5, "high": 1.5, "max": 3, "low": 1, "": 1} { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: model, Group: group, Resolver: resolver, + Tokens: UsageTokens{InputTokens: 1000, OutputTokens: 200}, RateMultiplier: 0.8, + ReasoningEffort: effort, + }) + require.NoError(t, err) + require.InDelta(t, 0.0014*multiplier, cost.TotalCost, 1e-12, effort) + require.InDelta(t, cost.TotalCost*0.8, cost.ActualCost, 1e-12, effort) + } + }) + } +} + +func TestReasoningEffortBillingStacksWithContextTierServiceTierAndTimePricing(t *testing.T) { + for _, interval := range []bool{false, true} { + name := "catalog long context" + if interval { + name = "channel interval" + } + t.Run(name, func(t *testing.T) { + resolved := &ResolvedPricing{ + Mode: BillingModeToken, Source: PricingSourceChannel, + BasePricing: &ModelPricing{ + InputPricePerToken: 1e-6, ImageInputPricePerToken: 2e-6, + OutputPricePerToken: 3e-6, ImageOutputPricePerToken: 4e-6, + CacheCreationPricePerToken: 5e-6, CacheReadPricePerToken: 6e-6, + FastMultiplier: testPtrFloat64(2.5), + ReasoningEffortMultipliers: map[string]float64{"high": 1.7}, + LongContextInputThreshold: 100, LongContextInputMultiplier: 2, LongContextOutputMultiplier: 3, + }, + longContextPricingEnabled: true, + channelPricing: &ChannelModelPricing{TimePricing: &ChannelTimePricing{ + Timezone: "UTC", Periods: []ChannelTimePricingPeriod{{StartTime: "08:00", EndTime: "16:00", Multiplier: 1.2}}, + }}, + } + if interval { + resolved.Intervals = []PricingInterval{{MinTokens: 100, InputMultiplier: testPtrFloat64(2), OutputMultiplier: testPtrFloat64(3)}} + } + input := CostInput{ + Model: "custom-model", RateMultiplier: 0.8, ServiceTier: "fast", + Tokens: UsageTokens{InputTokens: 200, ImageInputTokens: 20, OutputTokens: 10, ImageOutputTokens: 2, CacheCreationTokens: 5, CacheReadTokens: 3}, + Resolver: &ModelPricingResolver{}, Resolved: resolved, + PricingAt: time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC), + } + bs := &BillingService{} + standard, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + input.ReasoningEffort = "high" + adjusted, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + for _, costs := range [][2]float64{ + {standard.InputCost, adjusted.InputCost}, {standard.ImageInputCost, adjusted.ImageInputCost}, + {standard.OutputCost, adjusted.OutputCost}, {standard.ImageOutputCost, adjusted.ImageOutputCost}, + {standard.CacheCreationCost, adjusted.CacheCreationCost}, {standard.CacheReadCost, adjusted.CacheReadCost}, + {standard.TotalCost, adjusted.TotalCost}, {standard.ActualCost, adjusted.ActualCost}, + } { + require.Positive(t, costs[0]) + require.InDelta(t, costs[0]*1.7, costs[1], 1e-12) + } + require.Equal(t, standard.LongContextBillingApplied, adjusted.LongContextBillingApplied) + }) + } +} + +func TestReasoningEffortBillingGroupOverrideAndUnitBilling(t *testing.T) { + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo} { + t.Run(string(mode), func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"custom-model"}, BillingMode: mode, + InputPrice: testPtrFloat64(1e-6), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 2, "max": 3}, + }}, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI, ModelPricing: []ChannelModelPricing{{ + Models: []string{"custom-model"}, BillingMode: mode, + InputPrice: testPtrFloat64(1e-6), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 1.25}, + }}} + for effort, multiplier := range map[string]float64{"high": 1.25, "max": 1} { + input := CostInput{ + Ctx: context.Background(), Model: "custom-model", Group: group, GroupID: &group.ID, + Tokens: UsageTokens{InputTokens: 1000}, RequestCount: 2, RateMultiplier: 0.8, + ReasoningEffort: effort, Resolver: resolver, + } + cost, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + base := 0.2 + if mode == BillingModeToken { + base = 0.001 + } + require.InDelta(t, base*multiplier, cost.TotalCost, 1e-12) + require.InDelta(t, cost.TotalCost*0.8, cost.ActualCost, 1e-12) + } + }) + } +} + +func TestAccountStatsCustomRulesUseOwnReasoningEffortMultipliers(t *testing.T) { + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo} { + t.Run(string(mode), func(t *testing.T) { + channel := &Channel{AccountStatsPricingRules: []AccountStatsPricingRule{{ + AccountIDs: []int64{1}, Pricing: []ChannelModelPricing{{ + Models: []string{"claude-fable-5-1"}, BillingMode: mode, + InputPrice: testPtrFloat64(0.001), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + }}, + }}} + for effort, multiplier := range map[string]float64{"high": 1.5, "max": 1} { + cost := tryCustomRules(channel, 1, 100, PlatformAnthropic, "claude-fable-5-1", UsageTokens{InputTokens: 100}, 2, effort) + require.NotNil(t, cost) + base := 0.2 + if mode == BillingModeToken { + base = 0.1 + } + require.InDelta(t, base*multiplier, *cost, 1e-12) + } + }) + } +} + +func TestDisplayPricingPreservesConfiguredReasoningEffortMultipliers(t *testing.T) { + raw := &ChannelModelPricing{ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}} + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage} { + raw.BillingMode = mode + display := synthesizePricingFromLiteLLM(&LiteLLMModelPricing{InputCostPerToken: 1e-6, OutputCostPerImage: 0.1}, raw) + require.Equal(t, raw.ReasoningEffortMultipliers, display.ReasoningEffortMultipliers) + display.ReasoningEffortMultipliers["high"] = 9 + require.Equal(t, 1.5, raw.ReasoningEffortMultipliers["high"]) + } + display := plazaPricingFromSchedule(raw, &ContextPricingSchedule{Tiers: []ContextPricingTier{{Input: testPtrFloat64(1e-6)}}}) + require.Equal(t, raw.ReasoningEffortMultipliers, display.ReasoningEffortMultipliers) + display.ReasoningEffortMultipliers["max"] = 9 + require.Equal(t, 3.0, raw.ReasoningEffortMultipliers["max"]) +} + +func TestPlazaUsesGroupReasoningEffortMultipliers(t *testing.T) { + m := &PlazaModel{Name: "custom-model", Platform: PlatformOpenAI, Pricing: &ChannelModelPricing{ + ReasoningEffortMultipliers: map[string]float64{"max": 3}, + }} + g := &Group{ModelPricing: []ChannelModelPricing{{ + Models: []string{"custom-model"}, BillingMode: BillingModeToken, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + }}} + (&ModelPlazaService{}).fillDisplayPricing(context.Background(), m, g) + require.Equal(t, map[string]float64{"high": 1.5}, m.Pricing.ReasoningEffortMultipliers) + m.Pricing.ReasoningEffortMultipliers["high"] = 9 + require.Equal(t, 1.5, g.ModelPricing[0].ReasoningEffortMultipliers["high"]) +} + +func TestReasoningEffortBillingNoResolverPreservesCatalogPolicies(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 100, CacheCreationTokens: 20, CacheReadTokens: 10} + for _, applyLongContext := range []bool{false, true} { + for _, serviceTier := range []string{"", "priority", "flex"} { + for _, rateMultiplier := range []float64{-1, 0, 0.8} { + want, err := bs.calculateCostInternalWithPolicy("gpt-5.4", tokens, rateMultiplier, serviceTier, nil, applyLongContext) + require.NoError(t, err) + got, err := bs.CalculateCostUnified(CostInput{ + Model: "gpt-5.4", Tokens: tokens, RateMultiplier: rateMultiplier, + ServiceTier: serviceTier, LongContextBillingEnabled: &applyLongContext, ReasoningEffort: "max", + }) + require.NoError(t, err) + require.Equal(t, want, got) + } + } + } +} + +func TestReasoningEffortBillingPreservesForwardedNoneAndMinimal(t *testing.T) { + for _, effort := range []string{"none", "minimal"} { + t.Run(effort, func(t *testing.T) { + for _, body := range [][]byte{ + []byte(`{"model":"gpt-5.4","reasoning":{"effort":"` + effort + `"}}`), + []byte(`{"model":"gpt-5.4","reasoning_effort":"` + effort + `"}`), + } { + got := extractOpenAIReasoningEffortFromBody(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + require.Equal(t, 0.5, reasoningEffortBillingMultiplier(*got, map[string]float64{effort: 0.5})) + require.Equal(t, got, extractCCReasoningEffortFromBody(body, "gpt-5.4")) + } + for _, body := range []map[string]any{ + {"reasoning": map[string]any{"effort": effort}}, + {"reasoning_effort": effort}, + } { + got := extractOpenAIReasoningEffort(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + } + body := []byte(`{"model":"gpt-5.4","reasoning":{"effort":"` + effort + `"}}`) + got := ExtractResponsesReasoningEffortFromBody(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + }) + } + + // A compatibility endpoint that strips none must not bill the removed request preference. + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}} + filtered, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, []byte(`{"model":"custom-model","reasoning":{"effort":"none"}}`)) + require.NoError(t, err) + require.Nil(t, extractOpenAIReasoningEffortFromBody(filtered, "custom-model")) +} diff --git a/backend/migrations/239_channel_reasoning_effort_multipliers.sql b/backend/migrations/239_channel_reasoning_effort_multipliers.sql new file mode 100644 index 000000000000..6fd3f43ed101 --- /dev/null +++ b/backend/migrations/239_channel_reasoning_effort_multipliers.sql @@ -0,0 +1,56 @@ +-- Migrate only explicitly configured legacy max pricing. There is no model-specific default. +-- Run the backfill only when introducing the column: replaying this migration must +-- preserve current settings, including maps that have deliberately been cleared. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'channel_model_pricing'::regclass + AND attname = 'reasoning_effort_multipliers' + AND NOT attisdropped + ) THEN + ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb; + + UPDATE channel_model_pricing + SET reasoning_effort_multipliers = jsonb_build_object('max', max_reasoning_effort_multiplier) + WHERE max_reasoning_effort_multiplier IS NOT NULL; + END IF; +END $$; + +ALTER TABLE channel_account_stats_model_pricing + ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- Group pricing stores the same entries as JSON. An existing generic map wins, +-- even when empty. Remove the consumed legacy key so replay cannot revive it. +UPDATE groups AS g +SET model_pricing = ( + SELECT jsonb_agg( + CASE + WHEN jsonb_typeof(entry) = 'object' AND entry ? 'max_reasoning_effort_multiplier' THEN + (entry - 'max_reasoning_effort_multiplier') || + CASE + WHEN NOT (entry ? 'reasoning_effort_multipliers') + AND jsonb_typeof(entry->'max_reasoning_effort_multiplier') = 'number' THEN + jsonb_build_object('reasoning_effort_multipliers', + jsonb_build_object('max', entry->'max_reasoning_effort_multiplier')) + ELSE '{}'::jsonb + END + ELSE entry + END ORDER BY ordinal + ) + FROM jsonb_array_elements(g.model_pricing) WITH ORDINALITY AS pricing(entry, ordinal) +) +WHERE jsonb_typeof(g.model_pricing) = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(g.model_pricing) = 'array' THEN g.model_pricing ELSE '[]'::jsonb END + ) AS pricing(entry) + WHERE jsonb_typeof(entry) = 'object' AND entry ? 'max_reasoning_effort_multiplier' + ); + +COMMENT ON COLUMN channel_model_pricing.reasoning_effort_multipliers IS + 'Custom billing multipliers by reasoning effort; omitted efforts use 1x'; +COMMENT ON COLUMN channel_account_stats_model_pricing.reasoning_effort_multipliers IS + 'Custom account statistics billing multipliers by reasoning effort; omitted efforts use 1x'; diff --git a/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go b/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go new file mode 100644 index 000000000000..ed3da3ba2195 --- /dev/null +++ b/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go @@ -0,0 +1,25 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelReasoningEffortMultipliersMigration(t *testing.T) { + content, err := FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "ALTER TABLE channel_model_pricing ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "ALTER TABLE channel_account_stats_model_pricing ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "WHERE max_reasoning_effort_multiplier IS NOT NULL") + require.Contains(t, sql, "jsonb_build_object('max', max_reasoning_effort_multiplier)") + require.Contains(t, sql, "attrelid = 'channel_model_pricing'::regclass AND attname = 'reasoning_effort_multipliers' AND NOT attisdropped") + require.Less(t, strings.Index(sql, "UPDATE channel_model_pricing"), strings.Index(sql, "END IF;")) + require.Contains(t, sql, "UPDATE groups AS g SET model_pricing") + require.Contains(t, sql, "NOT (entry ? 'reasoning_effort_multipliers')") + require.Contains(t, sql, "entry - 'max_reasoning_effort_multiplier'") + require.Contains(t, sql, "END ORDER BY ordinal") + require.NotContains(t, strings.ToLower(sql), "fable") +} diff --git a/docs/screenshots/reasoning-effort-pricing.png b/docs/screenshots/reasoning-effort-pricing.png new file mode 100644 index 0000000000000000000000000000000000000000..df7b1ef7a2567dd730c57f267760ebb7a574e1ba GIT binary patch literal 191361 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 z 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 z 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π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
a3{$T>!!W?LMun$4?K9UdOV6 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%0ANx7-p6pj2#cwk_G7Ts83e@%jY;sZm@c_m6 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

1cBKF|1PONO_r5XlB^at3Htv4 D{|Ow- literal 0 HcmV?d00001 diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index d956bb92c7b4..a04660ca978d 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,7 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -180,7 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 5c3f83542e83..e229bbcba3e7 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -45,7 +45,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index ba6fe3cf2fb4..f2448d6d08a3 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -144,7 +144,7 @@

-
+
-
- - -
@@ -261,6 +256,43 @@ />
+ +
+
+ + +
+

{{ t('admin.channels.form.reasoningEffortMultipliersHint') }}

+
+ +
+ +
@@ -275,7 +307,8 @@ import IntervalRow from './IntervalRow.vue' import ModelTagInput from './ModelTagInput.vue' import TimePricingSection from './TimePricingSection.vue' import type { PricingFormEntry, IntervalFormEntry } from './types' -import { perTokenToMTok, getPlatformTagClass } from './types' +import { perTokenToMTok, getPlatformTagClass, isValidPositiveMultiplier, validateReasoningEffortMultipliers } from './types' +import { REASONING_EFFORT_LEVELS, type ReasoningEffortLevel } from '@/constants/channel' import type { BillingMode } from '@/api/admin/channels' import channelsAPI from '@/api/admin/channels' @@ -313,12 +346,20 @@ const billingModeLabel = computed(() => { return opt ? opt.label : props.entry.billing_mode }) -const maxReasoningEffortMultiplierPlaceholder = computed(() => - props.entry.models.some(model => /fable(?:-5-1|-5\.1|5\.1|51)(?!\d)/i.test(model)) - ? t('admin.channels.form.fable51DefaultMaxReasoningMultiplier') - : t('admin.channels.form.multiplierPlaceholder') +const reasoningEffortMultiplierError = computed(() => + validateReasoningEffortMultipliers(props.entry.reasoning_effort_multipliers, t) ) +function updateReasoningEffortMultiplier(effort: ReasoningEffortLevel, value: string) { + const multipliers = { ...props.entry.reasoning_effort_multipliers } + if (value === '') delete multipliers[effort] + else multipliers[effort] = value + emit('update', { + ...props.entry, + reasoning_effort_multipliers: Object.keys(multipliers).length ? multipliers : null, + }) +} + function emitField(field: keyof PricingFormEntry, value: string) { emit('update', { ...props.entry, [field]: value === '' ? null : value }) } @@ -394,7 +435,7 @@ async function onModelsUpdate(newModels: string[]) { cache_read_price: perTokenToMTok(result.cache_read_price ?? null), image_input_price: perTokenToMTok(result.image_input_price ?? null), image_output_price: perTokenToMTok(result.image_output_price ?? null), - max_reasoning_effort_multiplier: result.max_reasoning_effort_multiplier ?? null, + reasoning_effort_multipliers: props.entry.reasoning_effort_multipliers ?? result.reasoning_effort_multipliers ?? null, }) } } catch { diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts index 947ac41c1f26..deee992b317e 100644 --- a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts @@ -1,7 +1,12 @@ -import { shallowMount } from '@vue/test-utils' +import { flushPromises, shallowMount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import PricingEntryCard from '../PricingEntryCard.vue' import type { PricingFormEntry } from '../types' +import channelsAPI from '@/api/admin/channels' + +vi.mock('@/api/admin/channels', () => ({ + default: { getModelDefaultPricing: vi.fn() }, +})) vi.mock('vue-i18n', async importOriginal => ({ ...await importOriginal(), @@ -18,13 +23,14 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, intervals: [], time_pricing: { timezone: 'Asia/Shanghai', + weekdays_only: false, periods: [{ start_time: '09:00', end_time: '12:00', multiplier: '2.00' }], }, } @@ -67,14 +73,14 @@ describe('PricingEntryCard time pricing visibility', () => { ...entry, billing_mode: 'image', intervals: [], - time_pricing: { timezone: 'Asia/Shanghai', periods: [] }, + time_pricing: { timezone: 'Asia/Shanghai', weekdays_only: false, periods: [] }, }) expect(entry.time_pricing.periods).toHaveLength(1) }) }) describe('PricingEntryCard request multipliers', () => { - it('shows Fast, Flex, and Max effort controls only when explicitly enabled', () => { + it('shows Fast and Flex controls only when explicitly enabled', () => { const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } }) expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier') @@ -83,6 +89,73 @@ describe('PricingEntryCard request multipliers', () => { }) expect(shown.text()).toContain('admin.channels.form.fastMultiplier') expect(shown.text()).toContain('admin.channels.form.flexMultiplier') - expect(shown.text()).toContain('admin.channels.form.maxReasoningEffortMultiplier') + expect(shown.text()).toContain('admin.channels.form.reasoningEffortMultipliers') + }) + + it.each(['token', 'per_request', 'image', 'video'] as const)('supports every reasoning level for %s pricing and account statistics', billingMode => { + const wrapper = shallowMount(PricingEntryCard, { props: { entry: createEntry(billingMode) } }) + expect(wrapper.findAll('[data-reasoning-effort]').map(input => input.attributes('data-reasoning-effort'))) + .toEqual(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + }) + + it('edits levels independently, then clears individual and all overrides', async () => { + const entry = { ...createEntry(), reasoning_effort_multipliers: { high: 1.5, max: 3 } } + const wrapper = shallowMount(PricingEntryCard, { props: { entry } }) + const update = () => wrapper.emitted('update')!.at(-1)![0] as PricingFormEntry + + await wrapper.get('[data-reasoning-effort="high"]').setValue('0.75') + expect(update().reasoning_effort_multipliers).toEqual({ high: '0.75', max: 3 }) + expect(entry.reasoning_effort_multipliers.high).toBe(1.5) + await wrapper.setProps({ entry: update() }) + + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + expect(update().reasoning_effort_multipliers).toEqual({ high: '0.75' }) + await wrapper.setProps({ entry: update() }) + + await wrapper.get('[data-testid="reasoning-effort-multipliers"] button').trigger('click') + expect(update().reasoning_effort_multipliers).toBeNull() + await wrapper.setProps({ entry: update() }) + expect(wrapper.get('[data-reasoning-effort="high"]').element.value).toBe('') + }) + + it('clearing the last configured level restores default billing', async () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { max: 3 } } }, + }) + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + expect((wrapper.emitted('update')![0][0] as PricingFormEntry).reasoning_effort_multipliers).toBeNull() + }) + + it('marks invalid multipliers and shows a validation error', () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { high: 0, max: 2 } } }, + }) + expect(wrapper.get('[data-reasoning-effort="high"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('[data-reasoning-effort="max"]').attributes('aria-invalid')).toBe('false') + expect(wrapper.get('[role="alert"]').text()).toContain('reasoningEffortMultiplierPositive') + }) + + it('does not assign a model-specific multiplier to Fable', () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), models: ['claude-fable-5-1'] } }, + }) + expect(wrapper.get('[data-reasoning-effort="max"]').element.value).toBe('') + expect(wrapper.get('[data-reasoning-effort="max"]').attributes('placeholder')) + .toBe('admin.channels.form.reasoningEffortMultiplierDefault') + }) + + it('keeps custom effort multipliers when auto-filling model token prices', async () => { + vi.mocked(channelsAPI.getModelDefaultPricing).mockResolvedValue({ + found: true, input_price: 3e-6, output_price: 15e-6, + }) + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { high: 0.5 } } }, + }) + wrapper.findComponent({ name: 'ModelTagInput' }).vm.$emit('update:models', ['example-model']) + await flushPromises() + expect(wrapper.emitted('update')!.at(-1)![0]).toMatchObject({ + models: ['example-model'], input_price: 3, output_price: 15, + reasoning_effort_multipliers: { high: 0.5 }, + }) }) }) diff --git a/frontend/src/components/admin/channel/__tests__/types.spec.ts b/frontend/src/components/admin/channel/__tests__/types.spec.ts index 58b5724239d0..3a5efd052679 100644 --- a/frontend/src/components/admin/channel/__tests__/types.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/types.spec.ts @@ -4,15 +4,43 @@ import { apiTimePricingToForm, createDefaultTimePricingForm, formIntervalsToAPI, + formReasoningEffortMultipliersToAPI, formTimePricingToAPI, isValidPositiveMultiplier, validateIntervals, + validateReasoningEffortMultipliers, validateTimePricing, type IntervalFormEntry, type TimePricingFormEntry, type TimePricingPeriodFormEntry, } from '../types' +describe('reasoning effort multipliers', () => { + it('serializes independent overrides without altering their values', () => { + expect(formReasoningEffortMultipliersToAPI({ none: '0.5', high: 1, max: '3', low: '' })) + .toEqual({ none: 0.5, high: 1, max: 3 }) + }) + + it.each([null, undefined, {}, { max: '' }])('clears empty overrides with null: %j', value => { + expect(formReasoningEffortMultipliersToAPI(value)).toBeNull() + expect(validateReasoningEffortMultipliers(value, t)).toBeNull() + }) + + it('accepts supported levels with positive finite multipliers, including discounts', () => { + expect(validateReasoningEffortMultipliers({ + none: 0.01, minimal: 0.5, low: 1, medium: '1.2', high: 2, xhigh: 2.5, max: 3, + }, t)).toBeNull() + }) + + it.each([0, -1, Infinity, NaN, 'invalid', 'Infinity'])('rejects invalid multiplier %s', multiplier => { + expect(validateReasoningEffortMultipliers({ high: multiplier }, t)).toContain('reasoningEffortMultiplierPositive') + }) + + it('rejects unsupported effort keys', () => { + expect(validateReasoningEffortMultipliers({ unknown: 2 }, t)).toContain('reasoningEffortLevelInvalid') + }) +}) + describe('interval multiplier conversion', () => { it('preserves component multipliers without MTok conversion', () => { const form = apiIntervalsToForm([{ diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 0575e1b8c6b9..bd818ea4e575 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -1,4 +1,5 @@ import type { BillingMode, ChannelTimePricing, PricingInterval } from '@/api/admin/channels' +import { REASONING_EFFORT_LEVELS } from '@/constants/channel' type TranslateFn = (key: string, params?: Record) => string @@ -29,7 +30,7 @@ export interface PricingFormEntry { cache_read_price: number | string | null fast_multiplier?: number | string | null flex_multiplier?: number | string | null - max_reasoning_effort_multiplier?: number | string | null + reasoning_effort_multipliers?: Record | null image_input_price: number | string | null image_output_price: number | string | null per_request_price: number | string | null @@ -182,6 +183,30 @@ export function isValidPositiveMultiplier(val: number | string | null | undefine return Number.isFinite(multiplier) && multiplier > 0 } +export function formReasoningEffortMultipliersToAPI( + value: PricingFormEntry['reasoning_effort_multipliers'], +): Record | null { + const entries = Object.entries(value || {}) + .filter(([, multiplier]) => multiplier !== '') + .map(([effort, multiplier]) => [effort, Number(multiplier)]) + return entries.length ? Object.fromEntries(entries) : null +} + +export function validateReasoningEffortMultipliers( + value: PricingFormEntry['reasoning_effort_multipliers'], + t: TranslateFn, +): string | null { + for (const [effort, multiplier] of Object.entries(value || {})) { + if (!REASONING_EFFORT_LEVELS.some(level => level === effort)) { + return t('admin.channels.form.reasoningEffortLevelInvalid', { effort }) + } + if (multiplier !== '' && !isValidPositiveMultiplier(multiplier)) { + return t('admin.channels.form.reasoningEffortMultiplierPositive', { effort }) + } + } + return null +} + /** 前端显示值($/MTok) → 后端存储值(per-token) */ export function mTokToPerToken(val: number | string | null | undefined): number | null { const num = toNullableNumber(val) diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 125154f2e9c8..71f5ec024dee 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -100,11 +100,13 @@ {{ t('modelPlaza.table.marginalBadge') }} - {{ t('modelPlaza.table.maxReasoningMultiplierBadge', { multiplier: m.pricing.max_reasoning_effort_multiplier }) }} + {{ t('modelPlaza.table.reasoningMultiplierBadge', { effort, multiplier }) }} @@ -311,11 +313,22 @@ import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/u import { BILLING_MODE_TOKEN, BILLING_MODE_IMAGE, + REASONING_EFFORT_LEVELS, type BillingMode } from '@/constants/channel' import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza' import type { UserPricingInterval } from '@/api/channels' +function reasoningEffortMultipliers(model: PlazaModel): [string, number][] { + const multipliers = model.pricing?.reasoning_effort_multipliers + return REASONING_EFFORT_LEVELS.flatMap(effort => { + const multiplier = multipliers?.[effort] + return typeof multiplier === 'number' && Number.isFinite(multiplier) && multiplier > 0 + ? [[effort, multiplier] as [string, number]] + : [] + }) +} + const props = defineProps<{ models: PlazaModel[] /** 分组平台;实付分区底色随平台着色,未知平台回退品牌青。 */ diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 9813e40fa5fd..c8c6bbe77e81 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -68,13 +68,27 @@ describe('PlazaModelPricingTable', () => { expect(text).toContain('1x') }) - it('shows the Max reasoning billing multiplier', () => { + it('shows all configured reasoning multipliers in level order', () => { const model = tokenModel() - model.pricing!.max_reasoning_effort_multiplier = 3 + model.pricing!.reasoning_effort_multipliers = { max: 3, none: 0.5, high: 1.5 } const wrapper = mountTable([model], 1) - expect(wrapper.text()).toContain('modelPlaza.table.maxReasoningMultiplierBadge') - expect(wrapper.find('[title="modelPlaza.table.maxReasoningMultiplierHint"]').exists()).toBe(true) + const badges = wrapper.findAll('[data-reasoning-effort]') + expect(badges.map(badge => badge.attributes('data-reasoning-effort'))).toEqual(['none', 'high', 'max']) + expect(badges.every(badge => badge.attributes('title') === 'modelPlaza.table.reasoningMultiplierHint')).toBe(true) + expect(wrapper.text()).toContain('modelPlaza.table.reasoningMultiplierBadge') + }) + + it('does not show automatic reasoning charges for an unconfigured Fable model', () => { + const wrapper = mountTable([tokenModel({ name: 'claude-fable-5-1' })], 1) + expect(wrapper.find('[data-reasoning-effort]').exists()).toBe(false) + }) + + it('omits invalid or unsupported multipliers from display', () => { + const model = tokenModel() + model.pricing!.reasoning_effort_multipliers = { max: 0, high: Infinity, unknown: 2, low: 1 } + const wrapper = mountTable([model], 1) + expect(wrapper.findAll('[data-reasoning-effort]').map(badge => badge.attributes('data-reasoning-effort'))).toEqual(['low']) }) it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => { diff --git a/frontend/src/constants/channel.ts b/frontend/src/constants/channel.ts index 1e1b9205de88..0d548d1fbcfd 100644 --- a/frontend/src/constants/channel.ts +++ b/frontend/src/constants/channel.ts @@ -14,6 +14,10 @@ export type BillingMode = | typeof BILLING_MODE_IMAGE | typeof BILLING_MODE_VIDEO +/** Supported request reasoning effort levels, ordered from lowest to highest. */ +export const REASONING_EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const +export type ReasoningEffortLevel = typeof REASONING_EFFORT_LEVELS[number] + /** Billing-model-source values (must match service.BillingModelSource* constants in Go). */ export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const export const BILLING_MODEL_SOURCE_UPSTREAM = 'upstream' as const diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 77f5c006440a..db867cfc6f49 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -140,10 +140,15 @@ export default { pricePlaceholder: 'Default', fastMultiplier: 'Fast Multiplier', flexMultiplier: 'Flex Multiplier', - maxReasoningEffortMultiplier: 'Max Effort Multiplier', - fable51DefaultMaxReasoningMultiplier: 'Default: 3', + reasoningEffortMultipliers: 'Custom Reasoning Effort Multipliers (optional)', + reasoningEffortMultipliersHint: 'Apply the multiplier for the final forwarded reasoning effort to the entire request cost and quota usage. Unconfigured levels use 1×. These multiply with other billing multipliers.', + reasoningEffortMultiplierLabel: '{effort} reasoning effort multiplier', + reasoningEffortMultiplierDefault: 'Default: 1', + clearReasoningEffortMultipliers: 'Clear multipliers', + reasoningEffortMultiplierPositive: 'The {effort} reasoning effort multiplier must be a finite number greater than 0', + reasoningEffortLevelInvalid: 'Unsupported reasoning effort: {effort}', multiplierPlaceholder: 'Not set', - multiplierPositive: 'Fast/Flex/Max effort multipliers must be greater than 0', + multiplierPositive: 'Fast/Flex multipliers must be finite numbers greater than 0', inputMultiplier: 'Input Mult.', outputMultiplier: 'Output Mult.', cacheWriteMultiplier: 'Cache Write Mult.', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 76be95b4d2f0..0a8d8d352a97 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -673,8 +673,8 @@ export default { cacheReadShort: 'R', tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)', tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected', - maxReasoningMultiplierBadge: 'Max ×{multiplier}', - maxReasoningMultiplierHint: 'When the forwarded reasoning effort is max, billing and quota usage for the request are multiplied by {multiplier}', + reasoningMultiplierBadge: '{effort} ×{multiplier}', + reasoningMultiplierHint: 'When the forwarded reasoning effort is {effort}, billing and quota usage for the request are multiplied by {multiplier}. Unconfigured levels use 1×', marginalBadge: 'excess-only tiers', timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row', timePricingRowHintWeekdays: diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 7680091195e6..6d841f93234a 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -140,10 +140,15 @@ export default { pricePlaceholder: '默认', fastMultiplier: 'Fast 倍率', flexMultiplier: 'Flex 倍率', - maxReasoningEffortMultiplier: 'Max 推理倍率', - fable51DefaultMaxReasoningMultiplier: '默认 3', + reasoningEffortMultipliers: '自定义思考等级倍率(可选)', + reasoningEffortMultipliersHint: '按请求最终转发的思考等级,对整次请求的费用与额度消耗应用对应倍率。未配置的等级按 1 倍计费,可与其他计费倍率叠加。', + reasoningEffortMultiplierLabel: '{effort} 思考等级倍率', + reasoningEffortMultiplierDefault: '默认 1', + clearReasoningEffortMultipliers: '清空倍率', + reasoningEffortMultiplierPositive: '{effort} 思考等级倍率必须为大于 0 的有限数字', + reasoningEffortLevelInvalid: '不支持的思考等级:{effort}', multiplierPlaceholder: '未配置', - multiplierPositive: 'Fast/Flex/Max 推理倍率必须大于 0', + multiplierPositive: 'Fast/Flex 倍率必须为大于 0 的有限数字', inputMultiplier: '输入倍率', outputMultiplier: '输出倍率', cacheWriteMultiplier: '缓存写倍率', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index d74ff78d9905..7393964b0ff6 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -678,8 +678,8 @@ export default { cacheReadShort: '读', tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价', tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价', - maxReasoningMultiplierBadge: 'Max ×{multiplier}', - maxReasoningMultiplierHint: '最终转发的推理强度为 max 时,整次请求的计费与额度消耗乘以 {multiplier}', + reasoningMultiplierBadge: '{effort} ×{multiplier}', + reasoningMultiplierHint: '最终转发的思考等级为 {effort} 时,整次请求的计费与额度消耗乘以 {multiplier};未配置的等级按 1 倍计费', marginalBadge: '超出部分计价', timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费', timePricingRowHintWeekdays: diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index ace4b1451974..6e5e351d6a9a 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -634,7 +634,7 @@ import { extractApiErrorMessage } from '@/utils/apiError' import { adminAPI } from '@/api/admin' import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels' import type { PricingFormEntry } from '@/components/admin/channel/types' -import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formTimePricingToAPI, isValidPositiveMultiplier, mTokToPerToken, perTokenToMTok, validateIntervals, validateTimePricing } from '@/components/admin/channel/types' +import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formReasoningEffortMultipliersToAPI, formTimePricingToAPI, isValidPositiveMultiplier, mTokToPerToken, perTokenToMTok, validateIntervals, validateReasoningEffortMultipliers, validateTimePricing } from '@/components/admin/channel/types' import type { AdminGroup, GroupPlatform } from '@/types' import type { Column } from '@/components/common/types' import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors' @@ -865,7 +865,7 @@ function addPricingEntry(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -903,7 +903,7 @@ async function syncLatestModels(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -1089,6 +1089,7 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] { cache_write_price: mTokToPerToken(p.cache_write_price), cache_write_1h_price: mTokToPerToken(p.cache_write_1h_price), cache_read_price: mTokToPerToken(p.cache_read_price), + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI(p.reasoning_effort_multipliers), image_input_price: mTokToPerToken(p.image_input_price), image_output_price: mTokToPerToken(p.image_output_price), per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null, @@ -1134,7 +1135,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ cache_read_price: mTokToPerToken(entry.cache_read_price), fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null, flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null, - max_reasoning_effort_multiplier: entry.max_reasoning_effort_multiplier != null && entry.max_reasoning_effort_multiplier !== '' ? Number(entry.max_reasoning_effort_multiplier) : null, + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI(entry.reasoning_effort_multipliers), image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null, @@ -1237,7 +1238,7 @@ function apiToForm(channel: Channel): PlatformSection[] { cache_read_price: perTokenToMTok(p.cache_read_price), fast_multiplier: p.fast_multiplier, flex_multiplier: p.flex_multiplier, - max_reasoning_effort_multiplier: p.max_reasoning_effort_multiplier, + reasoning_effort_multipliers: p.reasoning_effort_multipliers ? { ...p.reasoning_effort_multipliers } : null, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1429,6 +1430,7 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) { cache_write_price: perTokenToMTok(p.cache_write_price), cache_write_1h_price: perTokenToMTok(p.cache_write_1h_price), cache_read_price: perTokenToMTok(p.cache_read_price), + reasoning_effort_multipliers: p.reasoning_effort_multipliers ? { ...p.reasoning_effort_multipliers } : null, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1541,12 +1543,28 @@ async function handleSubmit() { } } + // 思考等级倍率同时适用于渠道计价和独立的账号统计计价规则。 + for (const section of form.platforms.filter(s => s.enabled)) { + const entries = [ + ...section.model_pricing, + ...section.account_stats_pricing_rules.flatMap(rule => rule.pricing), + ] + for (const entry of entries) { + const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t) + if (!error) continue + const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) + const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') + appStore.showError(`${platformLabel} - ${modelLabel}: ${error}`) + activeTab.value = section.platform + return + } + } + // 校验区间合法性(范围、重叠等) for (const section of form.platforms.filter(s => s.enabled)) { for (const entry of section.model_pricing) { if (!isValidPositiveMultiplier(entry.fast_multiplier) || - !isValidPositiveMultiplier(entry.flex_multiplier) || - !isValidPositiveMultiplier(entry.max_reasoning_effort_multiplier)) { + !isValidPositiveMultiplier(entry.flex_multiplier)) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') appStore.showError(`${platformLabel} - ${modelLabel}: ${t('admin.channels.form.multiplierPositive')}`) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 85da60ed4183..471c4be44c93 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -4309,9 +4309,11 @@ import { apiIntervalsToForm, createDefaultTimePricingForm, formIntervalsToAPI, + formReasoningEffortMultipliersToAPI, mTokToPerToken, perTokenToMTok, toNullableNumber, + validateReasoningEffortMultipliers, } from "@/components/admin/channel/types"; import type { ChannelModelPricing } from "@/api/admin/channels"; import { VueDraggable } from "vue-draggable-plus"; @@ -4386,6 +4388,7 @@ const emptyGroupPricing = (): PricingFormEntry => ({ cache_write_price: null, cache_write_1h_price: null, cache_read_price: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -4407,6 +4410,9 @@ const groupPricingFromAPI = ( cache_write_price: perTokenToMTok(entry.cache_write_price), cache_write_1h_price: perTokenToMTok(entry.cache_write_1h_price), cache_read_price: perTokenToMTok(entry.cache_read_price), + reasoning_effort_multipliers: entry.reasoning_effort_multipliers + ? { ...entry.reasoning_effort_multipliers } + : null, image_input_price: perTokenToMTok(entry.image_input_price), image_output_price: perTokenToMTok(entry.image_output_price), per_request_price: entry.per_request_price, @@ -4429,6 +4435,9 @@ const groupPricingToAPI = ( cache_write_price: mTokToPerToken(entry.cache_write_price), cache_write_1h_price: mTokToPerToken(entry.cache_write_1h_price), cache_read_price: mTokToPerToken(entry.cache_read_price), + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI( + entry.reasoning_effort_multipliers, + ), image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: toNullableNumber(entry.per_request_price), @@ -5857,6 +5866,17 @@ const validateProfitControlForm = (form: ProfitControlFormState): boolean => { return true; }; +const validateGroupReasoningMultipliers = (pricing: PricingFormEntry[]): boolean => { + for (const entry of pricing) { + const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t); + if (error) { + appStore.showError(`${entry.models.join(", ") || t("admin.channels.form.unnamed")}: ${error}`); + return false; + } + } + return true; +}; + const handleCreateGroup = async () => { if (!createForm.name.trim()) { appStore.showError(t("admin.groups.nameRequired")); @@ -5872,6 +5892,7 @@ const handleCreateGroup = async () => { if (!validateProfitControlForm(createForm)) { return; } + if (!validateGroupReasoningMultipliers(createForm.model_pricing)) return; // 模型白名单:开启且没有任何条目时阻止提交,与后端 400 对齐。 if ( createModelAllowlistState.enabled && @@ -6203,6 +6224,7 @@ const handleUpdateGroup = async () => { if (!validateProfitControlForm(editForm)) { return; } + if (!validateGroupReasoningMultipliers(editForm.model_pricing)) return; // 模型白名单:开启且没有任何条目时阻止提交,与后端 400 对齐。 if ( editModelAllowlistState.enabled && diff --git a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts index e10e5b9869a9..d125725228ea 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts @@ -325,6 +325,65 @@ describe('GroupsView duplicate action', () => { wrapper.unmount() }) + it('loads, edits, and saves custom reasoning multipliers for group pricing', async () => { + const group = { + ...sourceGroup, + model_pricing: [{ + platform: 'openai', models: ['example-model'], billing_mode: 'token', + input_price: 3e-6, output_price: 15e-6, cache_write_price: null, cache_read_price: null, + image_input_price: null, image_output_price: null, per_request_price: null, + reasoning_effort_multipliers: { high: 1.5, max: 3 }, intervals: [], time_pricing: null, + }], + } + listGroups.mockResolvedValue({ items: [group], total: 1, page: 1, page_size: 20, pages: 1 }) + updateGroup.mockResolvedValue(group) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === 'common.edit')!.trigger('click') + await flushPromises() + + expect(wrapper.get('[data-reasoning-effort="high"]').element.value).toBe('1.5') + await wrapper.get('[data-reasoning-effort="high"]').setValue('0.5') + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + await wrapper.get('#edit-group-form').trigger('submit') + await flushPromises() + expect(updateGroup).toHaveBeenCalledWith(42, expect.objectContaining({ + model_pricing: [expect.objectContaining({ reasoning_effort_multipliers: { high: 0.5 } })], + })) + wrapper.unmount() + }) + + it('blocks saving an invalid group reasoning multiplier and allows clearing it', async () => { + const group = { + ...sourceGroup, + model_pricing: [{ + platform: 'openai', models: ['example-model'], billing_mode: 'token', + input_price: 3e-6, output_price: 15e-6, cache_write_price: null, cache_read_price: null, + image_input_price: null, image_output_price: null, per_request_price: null, + reasoning_effort_multipliers: { high: 1.5 }, intervals: [], time_pricing: null, + }], + } + listGroups.mockResolvedValue({ items: [group], total: 1, page: 1, page_size: 20, pages: 1 }) + updateGroup.mockResolvedValue(group) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === 'common.edit')!.trigger('click') + await flushPromises() + + await wrapper.get('[data-reasoning-effort="high"]').setValue('0') + await wrapper.get('#edit-group-form').trigger('submit') + expect(updateGroup).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalledWith(expect.stringContaining('reasoningEffortMultiplierPositive')) + + await wrapper.get('[data-testid="reasoning-effort-multipliers"] button').trigger('click') + await wrapper.get('#edit-group-form').trigger('submit') + await flushPromises() + expect(updateGroup).toHaveBeenCalledWith(42, expect.objectContaining({ + model_pricing: [expect.objectContaining({ reasoning_effort_multipliers: null })], + })) + wrapper.unmount() + }) + it('updates manifest controls immediately and submits the displayed selection', async () => { vi.useFakeTimers() vi.mocked(adminAPI.accounts.list).mockResolvedValue({ From fb58dfbd9375660b9bc9df9bd8eff988b75e418c Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:48:04 +0800 Subject: [PATCH 075/142] test(antigravity): cover the identity rewrite end-to-end through the transformer Adds a case with the real block layout (attribution block, identity sentence, task instructions) and asserts that user content mentioning Claude Code and claude-sonnet-4-6 is forwarded unchanged. --- .../pkg/antigravity/attribution_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go index b37ceba6f99d..79cc3c7ab304 100644 --- a/backend/internal/pkg/antigravity/attribution_test.go +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -137,3 +137,47 @@ func TestAttributionAndIdentityTogether(t *testing.T) { require.Equal(t, "", stripClaudeAttribution(attribution)) require.Equal(t, "You are an AI agent.", neutralizeClaudeIdentity(identity)) } + +// End-to-end through the transformer: the attribution block and the identity +// sentence arrive as separate system blocks in real Claude Code traffic. The +// same words inside user content must survive untouched, because there they are +// conversation content rather than a client identity declaration. +func TestTransformClaudeToGemini_IdentitySystemBlock(t *testing.T) { + const ( + attribution = "x-anthropic-billing-header: cc_version=2.1.278.0a9; cc_entrypoint=claude-vscode;" + identity = "You are a Claude agent, built on Anthropic's Claude Agent SDK." + task = "You are naming a coding session." + userText = "Does Claude Code support claude-sonnet-4-6?" + ) + system := []SystemBlock{ + {Type: "text", Text: attribution}, + {Type: "text", Text: identity}, + {Type: "text", Text: task}, + } + systemJSON, err := json.Marshal(system) + require.NoError(t, err) + userJSON, err := json.Marshal(userText) + require.NoError(t, err) + input := &ClaudeRequest{ + Model: "gemini-3.8-flash-high", + System: systemJSON, + Messages: []ClaudeMessage{{Role: "user", Content: userJSON}}, + } + body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{}) + require.NoError(t, err) + var got V1InternalRequest + require.NoError(t, json.Unmarshal(body, &got)) + + var texts []string + for _, part := range got.Request.SystemInstruction.Parts { + if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" { + texts = append(texts, part.Text) + } + } + require.Equal(t, []string{"You are an AI agent.", task}, texts) + for _, text := range texts { + require.NotContains(t, text, "Claude") + require.NotContains(t, text, "Anthropic") + } + require.Equal(t, userText, got.Request.Contents[0].Parts[0].Text) +} From d2416a9b68cf345a38e5d98c395cfb5d44a22572 Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Sun, 20 Sep 2026 16:54:06 +0800 Subject: [PATCH 076/142] ci: keep release tooling and cache keys stable across jobs --- .github/release-tools/.gitignore | 1 + .github/release-tools/README.md | 4 +- .github/release-tools/test_release_matrix.py | 28 +++++++++++ .github/workflows/release.yml | 50 ++++++++++++++------ 4 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 .github/release-tools/.gitignore diff --git a/.github/release-tools/.gitignore b/.github/release-tools/.gitignore new file mode 100644 index 000000000000..c18dd8d83cee --- /dev/null +++ b/.github/release-tools/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.github/release-tools/README.md b/.github/release-tools/README.md index 17946ff637ed..0f8ad75b2c8b 100644 --- a/.github/release-tools/README.md +++ b/.github/release-tools/README.md @@ -4,9 +4,9 @@ The release workflow builds the frontend once, then runs each configured Go targ Each build uses GoReleaser OSS in snapshot mode with the selected release version and one target. Archive naming, bundled files, Go flags and release templates remain in the existing GoReleaser configurations. Every archive is accompanied by its source commit, target, version and SHA256. The publishing job verifies the complete matrix before building images or publishing. It uses GoReleaser's `extra_files` support to publish existing archives and checksums, with builds disabled. No Pro license is needed. -Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. +Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Save uses the original restore key, even if a build hook changes `go.sum`. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. -All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. The workflow serializes release runs to prevent simultaneous updates to moving image tags. +All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. Helper scripts come from the workflow revision and are passed as a run-local artifact, so older application tags do not need to contain the new scripts. The workflow serializes release runs to prevent simultaneous updates to moving image tags. ## Validate without publication diff --git a/.github/release-tools/test_release_matrix.py b/.github/release-tools/test_release_matrix.py index b1a215518d15..bbfc0fc67341 100644 --- a/.github/release-tools/test_release_matrix.py +++ b/.github/release-tools/test_release_matrix.py @@ -159,5 +159,33 @@ def test_docker_commands_do_not_publish_during_dry_run(self): self.assertIn('ghcr.io/exampleowner/sub2api', log) + def test_published_full_and_simple_image_tags(self): + fake_bin = Path('bin') + fake_bin.mkdir() + docker = fake_bin / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') + docker.chmod(0o755) + for simple in (False, True): + with self.subTest(simple=simple): + log_path = Path(f'docker-{simple}.log').resolve() + env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], + 'DOCKER_LOG': str(log_path), 'RUNNER_TEMP': self.temp.name, + 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', + 'DRY_RUN': 'false', 'SIMPLE_RELEASE': str(simple).lower(), 'DOCKERHUB_USERNAME': 'fixturehub'} + subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) + log = log_path.read_text() + self.assertIn('--push', log) + self.assertEqual(log.count('buildx build'), 1 if simple else 2) + if simple: + self.assertNotIn('fixturehub', log) + self.assertNotIn('imagetools', log) + self.assertIn('ghcr.io/exampleowner/sub2api:latest', log) + else: + self.assertEqual(log.count('imagetools create'), 2) + self.assertIn('fixturehub/sub2api:9.8', log) + self.assertIn('ghcr.io/exampleowner/sub2api:9', log) + + + if __name__ == '__main__': unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71f4f5d25365..f14db676610d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,14 +45,21 @@ jobs: simple: ${{ steps.plan.outputs.simple }} dry_run: ${{ steps.plan.outputs.dry_run }} steps: - - uses: actions/checkout@v6 + - name: Checkout workflow tooling + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + - name: Preserve tooling for releases of older tags + run: cp -R .github/release-tools "$RUNNER_TEMP/release-tools" + - name: Checkout selected application source + uses: actions/checkout@v6 with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Resolve one source commit and target matrix id: plan env: @@ -61,7 +68,12 @@ jobs: args=(--ref "$REQUESTED_REF") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi - python .github/release-tools/release_matrix.py plan "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" plan "${args[@]}" + - uses: actions/upload-artifact@v7 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ + retention-days: 1 - uses: actions/upload-artifact@v7 with: name: version-file @@ -107,6 +119,10 @@ jobs: with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: name: version-file @@ -136,17 +152,17 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Select one GoReleaser target env: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} - run: python .github/release-tools/release_matrix.py config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output .goreleaser.matrix.yaml + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output "$RUNNER_TEMP/goreleaser.matrix.yaml" - name: Build and archive without publishing uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --snapshot --clean --skip=validate,publish,docker,announce --config=.goreleaser.matrix.yaml + args: release --snapshot --clean --skip=validate,publish,docker,announce --config=${{ runner.temp }}/goreleaser.matrix.yaml env: GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} @@ -158,7 +174,7 @@ jobs: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} - run: python .github/release-tools/release_matrix.py collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output - uses: actions/upload-artifact@v7 with: name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }} @@ -172,7 +188,7 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} + key: ${{ steps.go-cache.outputs.cache-primary-key }} release: needs: [prepare, build-binaries] @@ -181,6 +197,8 @@ jobs: contents: write packages: write env: + SIMPLE_RELEASE: ${{ needs.prepare.outputs.simple }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} @@ -189,6 +207,10 @@ jobs: with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: pattern: release-binary-* @@ -197,12 +219,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Verify complete matrix and prepare Docker contexts run: | args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context) if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi - python .github/release-tools/release_matrix.py contexts "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" contexts "${args[@]}" - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod @@ -229,7 +251,7 @@ jobs: - name: Build images and publish manifests env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} - run: bash .github/release-tools/release-images.sh + run: bash "$RUNNER_TEMP/release-tools/release-images.sh" - name: Get annotated tag notes id: tag_message run: | @@ -242,14 +264,14 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Prepare publication-only GoReleaser config run: | - args=(publish --output .goreleaser.publisher.yaml) + args=(publish --output "$RUNNER_TEMP/goreleaser.publisher.yaml") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi - python .github/release-tools/release_matrix.py config "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" config "${args[@]}" - name: Publish existing archives and release notes uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=.goreleaser.publisher.yaml + args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=${{ runner.temp }}/goreleaser.publisher.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }} From d1556a7a96d99ee2ebd93a624c16deb6fa387e49 Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Sun, 20 Sep 2026 17:01:27 +0800 Subject: [PATCH 077/142] ci: point release helper tests at their final directory --- .github/release-tools/README.md | 2 +- .github/workflows/backend-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/release-tools/README.md b/.github/release-tools/README.md index 0f8ad75b2c8b..6126de34fe8b 100644 --- a/.github/release-tools/README.md +++ b/.github/release-tools/README.md @@ -25,6 +25,6 @@ Helper checks: ```bash python -m pip install -r .github/release-tools/requirements-release.txt -python -m unittest discover -s .github/release -p 'test_release_matrix.py' +python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py' bash -n .github/release-tools/release-images.sh ``` diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index cbbc6ae12233..7aad1360b5f6 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -89,5 +89,5 @@ jobs: with: python-version: '3.12' - run: python -m pip install -r .github/release-tools/requirements-release.txt - - run: python -m unittest discover -s .github/release -p 'test_release_matrix.py' + - run: python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py' - run: bash -n .github/release-tools/release-images.sh From 1582347204af5d7607c9b5c095f2efb417bed259 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 20 Sep 2026 18:05:09 +0800 Subject: [PATCH 078/142] fix(payment): trim callback base URL trailing slashes --- .../payment/PaymentProviderDialog.vue | 4 +-- .../__tests__/PaymentProviderDialog.spec.ts | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue index 7743e4073a37..f8b1ba6afb4c 100644 --- a/frontend/src/components/payment/PaymentProviderDialog.vue +++ b/frontend/src/components/payment/PaymentProviderDialog.vue @@ -705,8 +705,8 @@ function handleSave() { // If base URL is empty, auto-fill with current domain const paths = PROVIDER_CALLBACK_PATHS[form.provider_key] if (paths) { - const notifyBase = notifyBaseUrl.value.trim() || defaultBaseUrl - const returnBase = returnBaseUrl.value.trim() || defaultBaseUrl + const notifyBase = (notifyBaseUrl.value.trim() || defaultBaseUrl).replace(/\/+$/, '') + const returnBase = (returnBaseUrl.value.trim() || defaultBaseUrl).replace(/\/+$/, '') notifyBaseUrl.value = notifyBase returnBaseUrl.value = returnBase if (paths.notifyUrl) filteredConfig['notifyUrl'] = notifyBase + paths.notifyUrl diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts index 18ca4cb483af..0126faab049a 100644 --- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts @@ -95,6 +95,32 @@ function mountDialog(options: { editing?: ProviderInstance | null } = {}) { }) } +describe('PaymentProviderDialog callback URLs', () => { + it.each([ + ['https://notify.example.com/', 'https://return.example.com///', 'https://notify.example.com', 'https://return.example.com'], + [' https://notify.example.com/sub/ ', ' https://return.example.com/site/ ', 'https://notify.example.com/sub', 'https://return.example.com/site'], + ['https://notify.example.com', 'https://return.example.com', 'https://notify.example.com', 'https://return.example.com'], + ['', '', window.location.origin, window.location.origin], + ])('joins callback paths to %s and %s', async (notify, returnUrl, expectedNotify, expectedReturn) => { + const provider = providerFactory({ + provider_key: 'easypay', name: 'EasyPay', + config: { pid: 'pid-1', apiBase: 'https://pay.example.com' }, + supported_types: ['alipay'], payment_mode: 'qrcode', + }) + const wrapper = mountDialog({ editing: provider }) + ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider) + await nextTick() + const bases = wrapper.findAll('input').filter(input => input.classes().includes('!rounded-r-none')) + await bases[0].setValue(notify) + await bases[1].setValue(returnUrl) + await wrapper.find('form').trigger('submit') + const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record } + expect(payload.config.notifyUrl).toBe(expectedNotify + '/api/v1/payment/webhook/easypay') + expect(payload.config.returnUrl).toBe(expectedReturn + '/payment/result') + wrapper.unmount() + }) +}) + describe('PaymentProviderDialog payment guide', () => { it('shows no payment guide for providers without a flow guide', () => { const wrapper = mountDialog() From 23a1d381877c4c509172c665a724ea04b2636b19 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 20 Sep 2026 18:05:23 +0800 Subject: [PATCH 079/142] fix(pricing): parse scientific notation in token bounds --- .../components/admin/channel/IntervalRow.vue | 4 +-- .../channel/__tests__/IntervalRow.spec.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/admin/channel/__tests__/IntervalRow.spec.ts diff --git a/frontend/src/components/admin/channel/IntervalRow.vue b/frontend/src/components/admin/channel/IntervalRow.vue index a8930993dae3..08adbea6003e 100644 --- a/frontend/src/components/admin/channel/IntervalRow.vue +++ b/frontend/src/components/admin/channel/IntervalRow.vue @@ -136,13 +136,13 @@ function emitField(field: keyof IntervalFormEntry, value: string | number | null } function toInt(val: string): number { - const n = parseInt(val, 10) + const n = Math.trunc(Number(val)) return isNaN(n) ? 0 : n } function toIntOrNull(val: string): number | null { if (val === '') return null - const n = parseInt(val, 10) + const n = Math.trunc(Number(val)) return isNaN(n) ? null : n } diff --git a/frontend/src/components/admin/channel/__tests__/IntervalRow.spec.ts b/frontend/src/components/admin/channel/__tests__/IntervalRow.spec.ts new file mode 100644 index 000000000000..079a8a2ea775 --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/IntervalRow.spec.ts @@ -0,0 +1,29 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import IntervalRow from '../IntervalRow.vue' +import type { IntervalFormEntry } from '../types' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +const interval: IntervalFormEntry = { + min_tokens: 0, max_tokens: null, tier_label: '', sort_order: 0, + input_price: null, output_price: null, cache_write_price: null, cache_read_price: null, + input_multiplier: null, output_multiplier: null, cache_write_multiplier: null, + cache_read_multiplier: null, per_request_price: null, +} + +describe('pricing interval token bounds', () => { + it.each([ + ['1e6', 1000000, 1000000], + ['2.72e5', 272000, 272000], + ['128000', 128000, 128000], + ['', 0, null], + ])('parses %s without losing the exponent', async (value, min, max) => { + const wrapper = mount(IntervalRow, { props: { interval, mode: 'token' } }) + const inputs = wrapper.findAll('input[type="number"]') + await inputs[0].setValue(value) + await inputs[1].setValue(value) + expect(wrapper.emitted('update')?.[0]?.[0]).toMatchObject({ min_tokens: min }) + expect(wrapper.emitted('update')?.[1]?.[0]).toMatchObject({ max_tokens: max }) + }) +}) From 319299519bd97bf5bdb72a96a7165d1e1d92c106 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 20 Sep 2026 18:07:39 +0800 Subject: [PATCH 080/142] fix(monitor): preserve the saved auto-refresh preference --- frontend/src/composables/useAutoRefresh.ts | 3 ++- .../src/views/user/ChannelStatusV1View.vue | 3 ++- .../ChannelStatusV1View.refresh.spec.ts | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/src/composables/useAutoRefresh.ts b/frontend/src/composables/useAutoRefresh.ts index 519d37cc50e0..b191dea4f89b 100644 --- a/frontend/src/composables/useAutoRefresh.ts +++ b/frontend/src/composables/useAutoRefresh.ts @@ -4,6 +4,7 @@ export interface UseAutoRefreshOptions { storageKey: string intervals?: readonly number[] defaultInterval?: number + defaultEnabled?: boolean onRefresh: () => Promise | void /** Skip tick when this returns true (e.g. modal open, document hidden). */ shouldPause?: () => boolean @@ -18,7 +19,7 @@ export function useAutoRefresh(options: UseAutoRefreshOptions) { shouldPause, } = options - const enabled = ref(false) + const enabled = ref(options.defaultEnabled ?? false) const intervalSeconds = ref(defaultInterval ?? intervals[intervals.length - 1]) const countdown = ref(0) const fetching = ref(false) diff --git a/frontend/src/views/user/ChannelStatusV1View.vue b/frontend/src/views/user/ChannelStatusV1View.vue index 8c8ad058ee21..041650c6c143 100644 --- a/frontend/src/views/user/ChannelStatusV1View.vue +++ b/frontend/src/views/user/ChannelStatusV1View.vue @@ -66,6 +66,7 @@ const autoRefresh = useAutoRefresh({ storageKey: 'channel-status-auto-refresh', intervals: [30, 60, 120] as const, defaultInterval: DEFAULT_INTERVAL_SECONDS, + defaultEnabled: true, onRefresh: () => reload(true), shouldPause: () => document.hidden || loading.value, }) @@ -162,7 +163,7 @@ watch( onMounted(() => { void reload(false) if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) { - autoRefresh.setEnabled(true) + autoRefresh.setEnabled(autoRefresh.enabled.value) } }) diff --git a/frontend/src/views/user/__tests__/ChannelStatusV1View.refresh.spec.ts b/frontend/src/views/user/__tests__/ChannelStatusV1View.refresh.spec.ts index 6bed4ee6a569..6b848bfa0268 100644 --- a/frontend/src/views/user/__tests__/ChannelStatusV1View.refresh.spec.ts +++ b/frontend/src/views/user/__tests__/ChannelStatusV1View.refresh.spec.ts @@ -24,6 +24,25 @@ beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); list.mockReset().mo afterEach(() => { wrapper?.unmount(); vi.useRealTimers(); localStorage.clear() }) describe('channel monitor refresh interval', () => { + it('keeps automatic refresh disabled after reopening the page', async () => { + wrapper = mountView(); await flushPromises() + await wrapper.get('.disable').trigger('click') + wrapper.unmount() + wrapper = mountView(); await flushPromises() + const calls = list.mock.calls.length + await vi.advanceTimersByTimeAsync(240000) + expect(list).toHaveBeenCalledTimes(calls) + expect(JSON.parse(localStorage.getItem('channel-status-auto-refresh')!).enabled).toBe(false) + await wrapper.get('.refresh').trigger('click'); await flushPromises() + expect(list).toHaveBeenCalledTimes(calls + 1) + }) + + it('starts automatic refresh for a first visit', async () => { + wrapper = mountView(); await flushPromises() + await vi.advanceTimersByTimeAsync(120000) + expect(list.mock.calls.length).toBeGreaterThan(1) + }) + it('refreshes at the selected interval on successive automatic refreshes', async () => { wrapper = mountView(); await flushPromises() await wrapper.get('.interval').trigger('click') From b86849e442417ee9cbb83afa1104dde9fc056b3c Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 20 Sep 2026 18:07:52 +0800 Subject: [PATCH 081/142] fix(usage): ignore superseded error detail requests --- .../components/user/UserErrorDetailModal.vue | 11 ++- .../__tests__/UserErrorDetailModal.spec.ts | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/user/__tests__/UserErrorDetailModal.spec.ts diff --git a/frontend/src/components/user/UserErrorDetailModal.vue b/frontend/src/components/user/UserErrorDetailModal.vue index 3ed62dcb43e7..1aba399372d9 100644 --- a/frontend/src/components/user/UserErrorDetailModal.vue +++ b/frontend/src/components/user/UserErrorDetailModal.vue @@ -92,10 +92,12 @@ const { t } = useI18n() const loading = ref(false) const loadError = ref(false) const detail = ref(null) +let requestVersion = 0 watch( () => [props.show, props.errorId] as const, - ([show, id]) => { + ([show, id], _, onCleanup) => { + onCleanup(() => { requestVersion++ }) if (show && id != null) { fetchDetail(id) } else if (!show) { @@ -106,16 +108,19 @@ watch( ) async function fetchDetail(id: number) { + const version = ++requestVersion loading.value = true loadError.value = false detail.value = null try { - detail.value = await getMyErrorDetail(id) + const result = await getMyErrorDetail(id) + if (version === requestVersion) detail.value = result } catch (e) { + if (version !== requestVersion) return console.error('[UserErrorDetailModal] Failed to load error detail:', e) loadError.value = true } finally { - loading.value = false + if (version === requestVersion) loading.value = false } } diff --git a/frontend/src/components/user/__tests__/UserErrorDetailModal.spec.ts b/frontend/src/components/user/__tests__/UserErrorDetailModal.spec.ts new file mode 100644 index 000000000000..1994d7f0465c --- /dev/null +++ b/frontend/src/components/user/__tests__/UserErrorDetailModal.spec.ts @@ -0,0 +1,70 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import UserErrorDetailModal from '../UserErrorDetailModal.vue' + +const { getDetail } = vi.hoisted(() => ({ getDetail: vi.fn() })) +vi.mock('@/api/usage', () => ({ getMyErrorDetail: getDetail })) +vi.mock('@/utils/format', () => ({ formatDateTime: (value: string) => value })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { getDetail.mockReset(); vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => vi.restoreAllMocks()) +function deferred() { + let resolve!: (value: unknown) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +const detail = (model: string) => ({ model, status_code: 500, category: 'upstream', created_at: '2026-09-20' }) +async function open() { + const wrapper = mount(UserErrorDetailModal, { + props: { show: false, errorId: 1 }, + global: { stubs: { BaseDialog: { props: ['show'], template: '
' } } }, + }) + await wrapper.setProps({ show: true }) + return wrapper +} + +describe('user error detail requests', () => { + it('keeps the newer detail when an earlier request finishes last', async () => { + const old = deferred(); const current = deferred() + getDetail.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const wrapper = await open() + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, errorId: 2 }) + current.resolve(detail('current-model')); await flushPromises() + old.resolve(detail('old-model')); await flushPromises() + expect(wrapper.text()).toContain('current-model') + expect(wrapper.text()).not.toContain('old-model') + }) + + it('keeps loading until the current request settles', async () => { + const old = deferred(); const current = deferred() + getDetail.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const wrapper = await open() + await wrapper.setProps({ errorId: 2 }) + old.resolve(detail('old-model')); await flushPromises() + expect(wrapper.find('.animate-spin').exists()).toBe(true) + current.resolve(detail('current-model')); await flushPromises() + expect(wrapper.find('.animate-spin').exists()).toBe(false) + expect(wrapper.text()).toContain('current-model') + }) + + it('ignores an obsolete failure, including when reopening the same record', async () => { + const old = deferred() + getDetail.mockReturnValueOnce(old.promise).mockResolvedValueOnce(detail('current-model')) + const wrapper = await open() + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true }); await flushPromises() + old.reject(new Error('obsolete')); await flushPromises() + expect(wrapper.text()).toContain('current-model') + expect(wrapper.text()).not.toContain('usage.errors.detail.loadFailed') + }) + + it('still reports a failure for the current record', async () => { + getDetail.mockRejectedValueOnce(new Error('current failure')) + const wrapper = await open(); await flushPromises() + expect(wrapper.text()).toContain('usage.errors.detail.loadFailed') + expect(wrapper.find('.animate-spin').exists()).toBe(false) + }) +}) From 7f18f3e9afdd69cb0cad3d140e5f083a563dccb1 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 20 Sep 2026 18:09:50 +0800 Subject: [PATCH 082/142] fix(users): keep API key results scoped to the selected user --- .../admin/user/UserApiKeysModal.vue | 16 ++-- .../user/__tests__/UserApiKeysModal.spec.ts | 80 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/admin/user/__tests__/UserApiKeysModal.spec.ts diff --git a/frontend/src/components/admin/user/UserApiKeysModal.vue b/frontend/src/components/admin/user/UserApiKeysModal.vue index ef24fe7eb01d..02d90356ef93 100644 --- a/frontend/src/components/admin/user/UserApiKeysModal.vue +++ b/frontend/src/components/admin/user/UserApiKeysModal.vue @@ -124,6 +124,7 @@ const appStore = useAppStore() const apiKeys = ref([]) const allGroups = ref([]) const loading = ref(false) +let requestVersion = 0 const updatingKeyIds = ref(new Set()) const groupSelectorKeyId = ref(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null) @@ -144,26 +145,29 @@ const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance } } -watch(() => props.show, (v) => { - if (v && props.user) { +watch(() => [props.show, props.user?.id] as const, ([show], _, onCleanup) => { + onCleanup(() => { requestVersion++ }) + closeGroupSelector() + if (show && props.user) { load() loadGroups() - } else { - closeGroupSelector() } }) const load = async () => { if (!props.user) return + const version = ++requestVersion + apiKeys.value = [] loading.value = true groupButtonRefs.value.clear() try { const res = await adminAPI.users.getUserApiKeys(props.user.id) - apiKeys.value = res.items || [] + if (version === requestVersion) apiKeys.value = res.items || [] } catch (error) { + if (version !== requestVersion) return console.error('Failed to load API keys:', error) } finally { - loading.value = false + if (version === requestVersion) loading.value = false } } diff --git a/frontend/src/components/admin/user/__tests__/UserApiKeysModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserApiKeysModal.spec.ts new file mode 100644 index 000000000000..da4c129cbc72 --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/UserApiKeysModal.spec.ts @@ -0,0 +1,80 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import UserApiKeysModal from '../UserApiKeysModal.vue' +import type { AdminUser } from '@/types' + +const { getKeys } = vi.hoisted(() => ({ getKeys: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { + users: { getUserApiKeys: getKeys }, groups: { getAll: vi.fn().mockResolvedValue([]) }, +} })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) })) +vi.mock('@/utils/format', () => ({ formatDateTime: (value: string) => value })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { getKeys.mockReset(); vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => vi.restoreAllMocks()) +function deferred() { + let resolve!: (value: unknown) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +const user = (id: number) => ({ id, email: `user${id}@example.com`, username: `user${id}` }) as AdminUser +const keys = (id: number, name: string) => ({ items: [{ id, name, key: 'sk-example-key-value-for-tests', status: 'active', created_at: '2026-09-20', group_id: null }] }) +async function open() { + const wrapper = mount(UserApiKeysModal, { + props: { show: false, user: user(1) }, + global: { stubs: { + BaseDialog: { props: ['show'], template: '
' }, + GroupBadge: true, GroupOptionItem: true, + } }, + }) + await wrapper.setProps({ show: true }) + return wrapper +} +async function switchUser(wrapper: Awaited>) { + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, user: user(2) }) +} + +describe('user API key loading', () => { + it('does not display the previous user keys when the next load fails', async () => { + getKeys.mockResolvedValueOnce(keys(1, 'first-user-key')).mockRejectedValueOnce(new Error('unavailable')) + const wrapper = await open(); await flushPromises() + expect(wrapper.text()).toContain('first-user-key') + await switchUser(wrapper); await flushPromises() + expect(wrapper.text()).toContain('user2@example.com') + expect(wrapper.text()).not.toContain('first-user-key') + }) + + it('does not replace current keys with a late previous response', async () => { + const old = deferred() + getKeys.mockReturnValueOnce(old.promise).mockResolvedValueOnce(keys(2, 'current-user-key')) + const wrapper = await open() + await switchUser(wrapper); await flushPromises() + old.resolve(keys(1, 'old-user-key')); await flushPromises() + expect(wrapper.text()).toContain('current-user-key') + expect(wrapper.text()).not.toContain('old-user-key') + }) + + it('keeps the current request loading when an obsolete request fails', async () => { + const old = deferred(); const current = deferred() + getKeys.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const wrapper = await open() + await switchUser(wrapper) + old.reject(new Error('obsolete')); await flushPromises() + expect(wrapper.find('.animate-spin').exists()).toBe(true) + current.resolve(keys(2, 'current-user-key')); await flushPromises() + expect(wrapper.text()).toContain('current-user-key') + expect(wrapper.find('.animate-spin').exists()).toBe(false) + }) + + it('loads keys when the selected user changes while the dialog is open', async () => { + getKeys.mockResolvedValueOnce(keys(1, 'first-user-key')).mockResolvedValueOnce(keys(2, 'second-user-key')) + const wrapper = await open(); await flushPromises() + await wrapper.setProps({ user: user(2) }); await flushPromises() + expect(getKeys).toHaveBeenLastCalledWith(2) + expect(wrapper.text()).toContain('second-user-key') + expect(wrapper.text()).not.toContain('first-user-key') + }) +}) From 763443abf276fa5cd6894b59048bce71355d7d6a Mon Sep 17 00:00:00 2001 From: xlplbo Date: Sun, 20 Sep 2026 19:27:47 +0800 Subject: [PATCH 083/142] =?UTF-8?q?fix(openai):=20=E6=94=B9=E5=86=99=20Cod?= =?UTF-8?q?ex=20turn=20metadata=20=E6=97=B6=E4=BF=9D=E7=95=99=E9=9D=9E=20A?= =?UTF-8?q?SCII=20=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_codex_account_identity.go | 4 +- .../service/openai_codex_fingerprint.go | 4 +- .../service/openai_codex_turn_metadata.go | 39 ++++++++ .../openai_codex_turn_metadata_test.go | 88 +++++++++++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/openai_codex_turn_metadata.go create mode 100644 backend/internal/service/openai_codex_turn_metadata_test.go diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go index d0e336ec6ccb..a7ce166165c6 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -154,7 +154,7 @@ func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *A if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { return false } - rebuilt, err := json.Marshal(metadata) + rebuilt, err := marshalCodexTurnMetadata(metadata) if err != nil { return false } @@ -267,7 +267,7 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { metadata := map[string]any{} if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { - if rebuilt, err := json.Marshal(metadata); err == nil { + if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) } } diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 1e19c07bdadb..8058c0c488aa 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -402,7 +402,7 @@ func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { for k, v := range fields { metadata[k] = v } - rebuilt, err := json.Marshal(metadata) + rebuilt, err := marshalCodexTurnMetadata(metadata) if err != nil { return } @@ -595,7 +595,7 @@ func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fi for k, v := range fields { metadata[k] = v } - if rebuilt, err := json.Marshal(metadata); err == nil { + if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { clientMetadata["x-codex-turn-metadata"] = string(rebuilt) } } diff --git a/backend/internal/service/openai_codex_turn_metadata.go b/backend/internal/service/openai_codex_turn_metadata.go new file mode 100644 index 000000000000..1654a26ca1db --- /dev/null +++ b/backend/internal/service/openai_codex_turn_metadata.go @@ -0,0 +1,39 @@ +package service + +import ( + "encoding/json" + "unicode/utf16" +) + +// Turn metadata is also used as an HTTP header, including when carried in WS JSON. +func marshalCodexTurnMetadata(metadata map[string]any) ([]byte, error) { + raw, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + for i, b := range raw { + if b < 0x7f { + continue + } + out := make([]byte, 0, len(raw)+16) + out = append(out, raw[:i]...) + appendEscape := func(r rune) { + const hex = "0123456789abcdef" + out = append(out, '\\', 'u', hex[r>>12&15], hex[r>>8&15], hex[r>>4&15], hex[r&15]) + } + for _, r := range string(raw[i:]) { + switch { + case r < 0x7f: + out = append(out, byte(r)) + case r <= 0xffff: + appendEscape(r) + default: + high, low := utf16.EncodeRune(r) + appendEscape(high) + appendEscape(low) + } + } + return out, nil + } + return raw, nil +} diff --git a/backend/internal/service/openai_codex_turn_metadata_test.go b/backend/internal/service/openai_codex_turn_metadata_test.go new file mode 100644 index 000000000000..a3a5b4773b9b --- /dev/null +++ b/backend/internal/service/openai_codex_turn_metadata_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCodexTurnMetadataRewritePreservesHeaderSafeJSON(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Credentials = map[string]any{"chatgpt_account_id": "test-account"} + ids := resolveCodexFingerprintIDsFromRequest(account, http.Header{}) + require.NotNil(t, ids) + + rewriters := map[string]func(*testing.T, string) string{ + "account_header": func(t *testing.T, raw string) string { + h := make(http.Header) + h.Set(openAIWSTurnMetadataHeader, raw) + applyCodexAccountIdentityHeaders(h, account, 77) + return h.Get(openAIWSTurnMetadataHeader) + }, + "account_body_map": func(t *testing.T, raw string) string { + cm := map[string]any{openAIWSTurnMetadataHeader: raw} + require.True(t, applyCodexAccountIdentityClientMetadataMap(map[string]any{"client_metadata": cm}, account, 77)) + value, ok := cm[openAIWSTurnMetadataHeader].(string) + require.True(t, ok) + return value + }, + "account_body_raw": func(t *testing.T, raw string) string { + body, err := json.Marshal(map[string]any{"client_metadata": map[string]any{openAIWSTurnMetadataHeader: raw}}) + require.NoError(t, err) + updated, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account, 77) + require.NoError(t, err) + require.True(t, changed) + return gjson.GetBytes(updated, "client_metadata."+openAIWSTurnMetadataHeader).String() + }, + "fingerprint_header": func(t *testing.T, raw string) string { + h := make(http.Header) + h.Set(openAIWSTurnMetadataHeader, raw) + applyCodexFingerprintHeaders(h, ids) + return h.Get(openAIWSTurnMetadataHeader) + }, + "fingerprint_body_map": func(t *testing.T, raw string) string { + cm := map[string]any{openAIWSTurnMetadataHeader: raw} + require.True(t, applyCodexFingerprintClientMetadata(map[string]any{"client_metadata": cm}, ids)) + value, ok := cm[openAIWSTurnMetadataHeader].(string) + require.True(t, ok) + return value + }, + "fingerprint_body_raw": func(t *testing.T, raw string) string { + body, err := json.Marshal(map[string]any{"client_metadata": map[string]any{openAIWSTurnMetadataHeader: raw}}) + require.NoError(t, err) + updated, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids) + require.NoError(t, err) + require.True(t, changed) + return gjson.GetBytes(updated, "client_metadata."+openAIWSTurnMetadataHeader).String() + }, + } + + for _, raw := range []string{ + `{"installation_id":"client-install","workspaces":{"C:\\work\\中文🚀":{}}}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\中文🚀":{"label":"café"}},"literal":"\\u4e2d","quote":"\"\\","controls":"\b\f\n\r\t\u0000\u007f","timestamp":1789302780858,"enabled":true}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\\u4e2d\u6587\ud83d\ude80":{"label":"caf\u00e9"}},"literal":"\\u4e2d","quote":"\"\\","controls":"\b\f\n\r\t\u0000\u007f","timestamp":1789302780858,"enabled":true}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\ascii":{}},"literal":"\\u4e2d"}`, + } { + var original map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &original)) + for name, rewrite := range rewriters { + t.Run(name, func(t *testing.T) { + updated := rewrite(t, raw) + var decoded map[string]any + require.NoError(t, json.Unmarshal([]byte(updated), &decoded)) + for key, value := range original { + if key != "installation_id" { + require.Equal(t, value, decoded[key], key) + } + } + require.NotEqual(t, original["installation_id"], decoded["installation_id"]) + for _, b := range []byte(updated) { + require.True(t, b >= 0x20 && b < 0x7f, "non-printable ASCII header byte: 0x%02x", b) + } + }) + } + } +} From 8d88b6666803fdf225f26086196a2a3c71a38665 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Wed, 16 Sep 2026 14:42:51 +0800 Subject: [PATCH 084/142] =?UTF-8?q?fix(openai):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=9D=9E=E9=AB=98=E7=BA=A7=E8=B0=83=E5=BA=A6=E9=80=89=E5=8F=B7?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/gateway_service.go | 2 + .../service/openai_account_scheduler.go | 16 ++++ .../service/openai_gateway_scheduling.go | 43 ++++++--- .../openai_legacy_scheduler_decision_test.go | 87 +++++++++++++++++++ 4 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/openai_legacy_scheduler_decision_test.go diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 97e35fdb1b62..0b6094fcf882 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -580,6 +580,8 @@ type AccountSelectionResult struct { Acquired bool ReleaseFunc func() WaitPlan *AccountWaitPlan // nil means no wait allowed + // stickySessionHit 标记账号来自会话粘性绑定命中,供非高级调度路径回填决策标签。 + stickySessionHit bool // profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的 // 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在 // 调度栈之外做抢槽后终检与准入后粘性绑定。 diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 28e30dfadfa1..0085182333fe 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -2221,6 +2221,20 @@ func (s *OpenAIGatewayService) loadOpenAIGroupRequiresPrivacySet(ctx context.Con return group != nil && group.RequirePrivacySet } +// applyLegacySelectionDecision 把非高级调度路径的选号结果回填到决策:记录选中账号, +// 命中会话粘性时标记 session_hash 层,否则保持 load_balance。 +func applyLegacySelectionDecision(decision *OpenAIAccountScheduleDecision, selection *AccountSelectionResult) { + if decision == nil || selection == nil || selection.Account == nil { + return + } + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + if selection.stickySessionHit { + decision.Layer = openAIAccountScheduleLayerSessionSticky + decision.StickySessionHit = true + } +} + func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( ctx context.Context, groupID *int64, @@ -2302,6 +2316,7 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( return selection, decision, nil } if accountSupportsOpenAICapabilities(selection.Account, requiredCapability, requiredImageCapability) { + applyLegacySelectionDecision(&decision, selection) return selection, decision, nil } if selection.ReleaseFunc != nil { @@ -2328,6 +2343,7 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( } if s.isOpenAIAccountTransportCompatible(selection.Account, requiredTransport) && accountSupportsOpenAICapabilities(selection.Account, requiredCapability, requiredImageCapability) { + applyLegacySelectionDecision(&decision, selection) return selection, decision, nil } if selection.ReleaseFunc != nil { diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 859e782291aa..2f9a750caad6 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -882,25 +882,32 @@ func resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel string) strin } func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) { + account, _, err := s.selectAccountForModelWithExclusionsStickyHit(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability, preferLowUpstreamRate) + return account, err +} + +// selectAccountForModelWithExclusionsStickyHit 与 selectAccountForModelWithExclusions 相同, +// 另返回账号是否来自粘性会话命中。 +func (s *OpenAIGatewayService) selectAccountForModelWithExclusionsStickyHit(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, bool, error) { platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), "model", requestedModel) - return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + return nil, false, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) } // 1. 尝试粘性会话命中 // Try sticky session hit if account := s.tryStickySessionHit(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability); account != nil { - return account, nil + return account, true, nil } // 2. 获取可调度的 OpenAI 账号 // Get schedulable OpenAI accounts accounts, err := s.listSchedulableAccounts(ctx, groupID, platform) if err != nil { - return nil, fmt.Errorf("query accounts failed: %w", err) + return nil, false, fmt.Errorf("query accounts failed: %w", err) } // 3. 按优先级 + LRU 选择最佳账号 @@ -908,12 +915,12 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C selected, compactBlocked, filterStats := s.selectBestAccount(ctx, groupID, platform, accounts, requestedModel, excludedIDs, requireCompact, requiredCapability, preferLowUpstreamRate) if selected == nil { - return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked, filterStats.summary("")) + return nil, false, noAvailableOpenAISelectionError(requestedModel, compactBlocked, filterStats.summary("")) } hydrated, err := s.hydrateSelectedAccount(ctx, selected) if err != nil { - return nil, err + return nil, false, err } // 4. 设置粘性会话绑定(利润门下推迟到 handler 终检通过后再绑定, @@ -923,7 +930,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } - return hydrated, nil + return hydrated, false, nil } // tryStickySessionHit 尝试从粘性会话获取账号。 @@ -1133,31 +1140,34 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } } if s.concurrencyService == nil || !cfg.LoadBatchEnabled { - account, err := s.selectAccountForModelWithExclusions(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability, preferLowUpstreamRate) + account, stickyHit, err := s.selectAccountForModelWithExclusionsStickyHit(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability, preferLowUpstreamRate) if err != nil { return nil, err } result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) if err == nil && result != nil && result.Acquired { - return s.newAcquiredSelectionResult(ctx, account, result.ReleaseFunc) + selection, selectErr := s.newAcquiredSelectionResult(ctx, account, result.ReleaseFunc) + return markStickySessionHit(selection, stickyHit), selectErr } if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil { waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID) if waitingCount < cfg.StickySessionMaxWaiting { - return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + selection, selectErr := s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: account.ID, MaxConcurrency: account.Concurrency, Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }) + return markStickySessionHit(selection, stickyHit), selectErr } } - return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + selection, selectErr := s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: account.ID, MaxConcurrency: account.Concurrency, Timeout: cfg.FallbackWaitTimeout, MaxWaiting: cfg.FallbackMaxWaiting, }) + return markStickySessionHit(selection, stickyHit), selectErr } accounts, err := s.listSchedulableAccounts(ctx, groupID, platform) @@ -1211,17 +1221,18 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return nil, selectErr } _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) - return selection, nil + return markStickySessionHit(selection, true), nil } waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID) if waitingCount < cfg.StickySessionMaxWaiting { - return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + selection, selectErr := s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: accountID, MaxConcurrency: account.Concurrency, Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }) + return markStickySessionHit(selection, true), selectErr } stickySpillover = true } @@ -1730,6 +1741,14 @@ func (s *OpenAIGatewayService) newAcquiredSelectionResult(ctx context.Context, a return selection, err } +// markStickySessionHit 在选号结果上记录账号是否来自会话粘性命中。 +func markStickySessionHit(selection *AccountSelectionResult, hit bool) *AccountSelectionResult { + if selection != nil && hit { + selection.stickySessionHit = true + } + return selection +} + func (s *OpenAIGatewayService) schedulingConfig() config.GatewaySchedulingConfig { if s.cfg != nil { return s.cfg.Gateway.Scheduling diff --git a/backend/internal/service/openai_legacy_scheduler_decision_test.go b/backend/internal/service/openai_legacy_scheduler_decision_test.go new file mode 100644 index 000000000000..9e30512a612d --- /dev/null +++ b/backend/internal/service/openai_legacy_scheduler_decision_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newLegacySchedulerDecisionTestAccounts(groupID int64, secondWSEnabled bool) []Account { + return []Account{ + { + ID: 38101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": true}, + }, + { + ID: 38102, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": secondWSEnabled}, + }, + } +} + +func newLegacySchedulerDecisionTestService(accounts []Account, loadBatch bool, concurrency schedulerTestConcurrencyCache) *OpenAIGatewayService { + cfg := newSchedulerTestOpenAIWSV2Config() + cfg.Gateway.Scheduling.LoadBatchEnabled = loadBatch + return &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrency), + } +} + +func releaseLegacySchedulerDecisionSelection(selection *AccountSelectionResult) { + if selection != nil && selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestLegacySchedulerDecision_StickySessionLayer(t *testing.T) { + ctx := context.Background() + groupID := int64(38100) + for _, loadBatch := range []bool{true, false} { + name := "load_batch" + if !loadBatch { + name = "priority_lru" + } + t.Run(name, func(t *testing.T) { + svc := newLegacySchedulerDecisionTestService(newLegacySchedulerDecisionTestAccounts(groupID, true), loadBatch, schedulerTestConcurrencyCache{}) + sessionHash := "legacy-sticky-session" + require.NoError(t, svc.setStickySessionAccountID(ctx, &groupID, sessionHash, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, "", sessionHash, "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38102), selection.Account.ID, "sticky binding must win over priority order") + require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer) + require.True(t, decision.StickySessionHit) + require.False(t, decision.StickyPreviousHit) + require.Equal(t, int64(38102), decision.SelectedAccountID) + require.Equal(t, AccountTypeAPIKey, decision.SelectedAccountType) + + selection, decision, err = svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, "", "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickySessionHit) + require.Equal(t, int64(38101), decision.SelectedAccountID) + }) + } +} From 0d0200059223d66c746d32e0e4980ca822cf8762 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Sun, 20 Sep 2026 19:28:13 +0800 Subject: [PATCH 085/142] =?UTF-8?q?fix(openai):=20=E9=9D=9E=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=E8=B0=83=E5=BA=A6=E6=8C=89=20previous=5Fresponse=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=88=B0=E6=8C=81=E6=9C=89=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E7=9A=84=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_account_scheduler.go | 63 +++++++ .../openai_legacy_scheduler_decision_test.go | 178 ++++++++++++++++++ 2 files changed, 241 insertions(+) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 0085182333fe..c136f8c3fb32 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -2235,6 +2235,60 @@ func applyLegacySelectionDecision(decision *OpenAIAccountScheduleDecision, selec } } +// selectLegacyAccountByPreviousResponse 在非高级调度路径按 previous_response_id 命中持有该响应的账号, +// 先按请求模型做渠道限制检查,再复用高级调度器 previous_response 层的账号兼容校验 +// (分组、隐私、运行期封禁、代理隔离、上游模型渠道限制、传输与能力);未命中返回 (nil, false, nil)。 +func (s *OpenAIGatewayService) selectLegacyAccountByPreviousResponse( + ctx context.Context, + groupID *int64, + previousResponseID string, + sessionHash string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredTransport OpenAIUpstreamTransport, + requiredCapability OpenAIEndpointCapability, + requiredImageCapability OpenAIImagesCapability, + requireCompact bool, + platform string, +) (*AccountSelectionResult, bool, error) { + if strings.TrimSpace(previousResponseID) == "" || platform != PlatformOpenAI { + return nil, false, nil + } + if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { + return nil, false, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + } + selection, err := s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + if err != nil { + return nil, false, err + } + if selection == nil || selection.Account == nil { + return nil, false, nil + } + account := selection.Account + scheduler := &defaultOpenAIAccountScheduler{service: s, stats: newOpenAIAccountRuntimeStats()} + compatible, _ := scheduler.isAccountRequestCompatibleReason(ctx, account, OpenAIAccountScheduleRequest{ + GroupID: groupID, + Platform: platform, + RequestedModel: requestedModel, + RequiredTransport: requiredTransport, + RequiredCapability: requiredCapability, + RequiredImageCapability: requiredImageCapability, + RequireCompact: requireCompact, + ExcludedIDs: excludedIDs, + RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID), + }) + if !s.openAIAccountMatchesSchedulingGroup(account, groupID) || !compatible || !scheduler.isAccountTransportCompatible(account, requiredTransport) { + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + return nil, false, nil + } + if sessionHash != "" { + _ = s.bindOpenAIStickySessionDuringSelection(ctx, groupID, sessionHash, account.ID) + } + return selection, true, nil +} + func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( ctx context.Context, groupID *int64, @@ -2271,6 +2325,15 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( scheduler := s.getOpenAIAccountScheduler(ctx) if scheduler == nil { decision.Layer = openAIAccountScheduleLayerLoadBalance + if selection, hit, err := s.selectLegacyAccountByPreviousResponse(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, requiredImageCapability, requireCompact, platform); err != nil { + return nil, decision, err + } else if hit { + decision.Layer = openAIAccountScheduleLayerPreviousResponse + decision.StickyPreviousHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, nil + } if guardianParentAccountID > 0 { if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { return nil, decision, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) diff --git a/backend/internal/service/openai_legacy_scheduler_decision_test.go b/backend/internal/service/openai_legacy_scheduler_decision_test.go index 9e30512a612d..2e1ac52176a3 100644 --- a/backend/internal/service/openai_legacy_scheduler_decision_test.go +++ b/backend/internal/service/openai_legacy_scheduler_decision_test.go @@ -1,3 +1,5 @@ +//go:build unit + package service import ( @@ -43,6 +45,7 @@ func releaseLegacySchedulerDecisionSelection(selection *AccountSelectionResult) } func TestLegacySchedulerDecision_StickySessionLayer(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() ctx := context.Background() groupID := int64(38100) for _, loadBatch := range []bool{true, false} { @@ -85,3 +88,178 @@ func TestLegacySchedulerDecision_StickySessionLayer(t *testing.T) { }) } } + +func TestLegacySchedulerDecision_PreviousResponseRouting(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + ctx := context.Background() + groupID := int64(38110) + responseID := "resp_legacy_route" + + t.Run("routes to the account owning the response", func(t *testing.T) { + svc := newLegacySchedulerDecisionTestService(newLegacySchedulerDecisionTestAccounts(groupID, true), true, schedulerTestConcurrencyCache{}) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "legacy-prev-session", "gpt-5.1", nil, + OpenAIUpstreamTransportResponsesWebsocketV2Ingress, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38102), selection.Account.ID, "previous_response_id must route to the owning account") + require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + require.True(t, decision.StickyPreviousHit) + require.False(t, decision.StickySessionHit) + require.Equal(t, int64(38102), decision.SelectedAccountID) + }) + + t.Run("excluded owner falls back to load balance", func(t *testing.T) { + svc := newLegacySchedulerDecisionTestService(newLegacySchedulerDecisionTestAccounts(groupID, true), true, schedulerTestConcurrencyCache{}) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", map[int64]struct{}{38102: {}}, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + }) + + t.Run("transport-incompatible owner releases its slot and falls back", func(t *testing.T) { + released := make([]int64, 0) + svc := newLegacySchedulerDecisionTestService(newLegacySchedulerDecisionTestAccounts(groupID, false), true, schedulerTestConcurrencyCache{releasedIDs: &released}) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", nil, + OpenAIUpstreamTransportResponsesWebsocketV2Ingress, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + require.Contains(t, released, int64(38102), "the owning account's slot must be released after the transport check fails") + }) + + t.Run("channel-restricted request model fails before routing", func(t *testing.T) { + released := make([]int64, 0) + svc := newLegacySchedulerDecisionTestService(newLegacySchedulerDecisionTestAccounts(groupID, true), true, schedulerTestConcurrencyCache{releasedIDs: &released}) + svc.channelService = newTestChannelService(makeStandardRepo(Channel{ + ID: 38111, + Status: StatusActive, + GroupIDs: []int64{groupID}, + RestrictModels: true, + BillingModelSource: BillingModelSourceChannelMapped, + ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-4o"}}}, + ModelMapping: map[string]map[string]string{PlatformOpenAI: {"gpt-5.1": "o3-mini"}}, + }, map[int64]string{groupID: PlatformOpenAI})) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "channel pricing restriction") + require.Nil(t, selection) + require.Empty(t, released, "the owning account's slot must not be touched for a channel-restricted model") + }) + + t.Run("channel-restricted upstream model of the owner releases its slot and falls back", func(t *testing.T) { + released := make([]int64, 0) + accounts := newLegacySchedulerDecisionTestAccounts(groupID, true) + accounts[1].Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.1": "o3-mini"}} + svc := newLegacySchedulerDecisionTestService(accounts, true, schedulerTestConcurrencyCache{releasedIDs: &released}) + svc.channelService = newTestChannelService(makeStandardRepo(Channel{ + ID: 38112, + Status: StatusActive, + GroupIDs: []int64{groupID}, + RestrictModels: true, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.1"}}}, + }, map[int64]string{groupID: PlatformOpenAI})) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + require.Contains(t, released, int64(38102), "the owning account's slot must be released after the upstream model restriction check fails") + }) + + t.Run("quarantined owner proxy releases its slot and falls back", func(t *testing.T) { + released := make([]int64, 0) + healthyProxy, quarantinedProxy := int64(38113), int64(38114) + accounts := newLegacySchedulerDecisionTestAccounts(groupID, true) + accounts[0].ProxyID = &healthyProxy + accounts[1].ProxyID = &quarantinedProxy + svc := newLegacySchedulerDecisionTestService(accounts, false, schedulerTestConcurrencyCache{releasedIDs: &released}) + svc.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) + tripped, _ := svc.openaiProxyStreamCircuit.recordFailure(quarantinedProxy, time.Now()) + require.True(t, tripped) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + require.Contains(t, released, int64(38102), "the owning account's slot must be released after the proxy quarantine check fails") + }) + + t.Run("all proxies quarantined fails open to the owner", func(t *testing.T) { + proxy := int64(38115) + accounts := newLegacySchedulerDecisionTestAccounts(groupID, true) + accounts[0].ProxyID = &proxy + accounts[1].ProxyID = &proxy + svc := newLegacySchedulerDecisionTestService(accounts, false, schedulerTestConcurrencyCache{}) + svc.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) + tripped, _ := svc.openaiProxyStreamCircuit.recordFailure(proxy, time.Now()) + require.True(t, tripped) + require.NoError(t, svc.getOpenAIWSStateStore().BindResponseAccount(ctx, groupID, responseID, 38102, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, responseID, "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, true, true, + ) + require.NoError(t, err, "quarantine must fail open instead of returning no available accounts") + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + releaseLegacySchedulerDecisionSelection(selection) + require.Equal(t, int64(38102), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + require.True(t, decision.StickyPreviousHit) + }) +} From 7c700729c23187d31ed320f6b19c790e2f194826 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 20 Sep 2026 20:05:09 +0800 Subject: [PATCH 086/142] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5a7dd4e8c954..b4627c71fbde 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,7 @@ frontend/coverage/ aicodex output/ .codegraph/ +issues/ # Vitest / Vite cache at repo root .vite/ From 992c4f4b89a8e903d6bcd40710f46e5a1e01f297 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 08:16:18 +0800 Subject: [PATCH 087/142] test(frontend): run platform quota modal spec in CI --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index fec155a8dc85..1c5737f63c8b 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/keys.bulkUpdate.spec.ts \ src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ + src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts \ src/views/user/__tests__/KeysView.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ From de98bdff1e964573c0bf3c32793e2bafb23688d7 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 10:23:10 +0800 Subject: [PATCH 088/142] fix(images): preserve insufficient balance failures --- .../handler/openai_gateway_handler.go | 15 ++- .../handler/openai_images_balance_test.go | 38 +++++++ backend/internal/service/openai_images.go | 16 +++ .../internal/service/openai_images_balance.go | 93 +++++++++++++++ .../service/openai_images_balance_test.go | 107 ++++++++++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/openai_images_balance_test.go create mode 100644 backend/internal/service/openai_images_balance.go create mode 100644 backend/internal/service/openai_images_balance_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 600d2c7372ec..bf92e3a82ea5 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -3340,6 +3340,7 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, streamStarted) return } + copyFailoverRetryAfter(c, failoverErr.ResponseHeaders) if failoverErr.IsOpenAIRequestBodyTooLarge() { service.SetOpsUpstreamError(c, http.StatusRequestEntityTooLarge, service.OpenAIRequestBodyTooLargeClientMessage, "") h.handleStreamingAwareError( @@ -3359,7 +3360,19 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", message, streamStarted) return } - copyFailoverRetryAfter(c, failoverErr.ResponseHeaders) + if failoverErr.Reason == service.OpenAIImagesInsufficientBalanceReason { + status := failoverErr.ClientStatusCode + if status <= 0 { + status = http.StatusPaymentRequired + } + message := strings.TrimSpace(failoverErr.ClientMessage) + if message == "" { + message = service.OpenAIImagesInsufficientBalanceMessage + } + service.SetOpsUpstreamError(c, failoverErr.StatusCode, message, "") + h.handleStreamingAwareErrorWithCode(c, status, "upstream_error", service.OpenAIImagesInsufficientBalanceCode, message, streamStarted, false) + return + } if failoverErr.IsCredentialFailure() { status, message := credentialFailoverClientResponse(failoverErr) h.handleStreamingAwareError(c, status, "upstream_error", message, streamStarted) diff --git a/backend/internal/handler/openai_images_balance_test.go b/backend/internal/handler/openai_images_balance_test.go new file mode 100644 index 000000000000..dc6014f4693b --- /dev/null +++ b/backend/internal/handler/openai_images_balance_test.go @@ -0,0 +1,38 @@ +//go:build unit + +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIImagesBalanceExhaustionPreservesMachineReadableCause(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, streamStarted := range []bool{false, true} { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", nil) + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + Reason: service.OpenAIImagesInsufficientBalanceReason, + ClientStatusCode: http.StatusPaymentRequired, + ClientMessage: service.OpenAIImagesInsufficientBalanceMessage, + ResponseHeaders: http.Header{"Retry-After": []string{"23"}, "X-Request-Id": []string{"req-images-balance"}}, + }, streamStarted) + require.Equal(t, "23", recorder.Header().Get("Retry-After")) + + if streamStarted { + require.Contains(t, recorder.Body.String(), `"code":"insufficient_balance"`) + } else { + require.Equal(t, http.StatusPaymentRequired, recorder.Code) + require.Equal(t, service.OpenAIImagesInsufficientBalanceCode, gjson.GetBytes(recorder.Body.Bytes(), "error.code").String()) + } + } +} diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 0fe06a334b1c..8ef483e537d0 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -661,6 +661,22 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + if isOpenAIImagesInsufficientBalance(respBody) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + ProxyID: opsUpstreamProxyID(account), + ProxyName: opsUpstreamProxyName(account), + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "failover", + Message: OpenAIImagesInsufficientBalanceMessage, + }) + s.coolOpenAIImagesInsufficientBalance(upstreamCtx, account) + return nil, newOpenAIImagesInsufficientBalanceFailoverError(resp.StatusCode, resp.Header, respBody) + } if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), diff --git a/backend/internal/service/openai_images_balance.go b/backend/internal/service/openai_images_balance.go new file mode 100644 index 000000000000..92d4ccf052a6 --- /dev/null +++ b/backend/internal/service/openai_images_balance.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" +) + +const ( + OpenAIImagesInsufficientBalanceReason GatewayFailureReason = "openai_images_insufficient_balance" + OpenAIImagesInsufficientBalanceCode = "insufficient_balance" + OpenAIImagesInsufficientBalanceMessage = "Upstream image account has insufficient balance" + openAIImagesBalanceCooldown = 5 * time.Minute + openAIImagesBalanceRateLimitReason = "openai_images_insufficient_balance" +) + +// isOpenAIImagesInsufficientBalance only accepts explicit values in structured +// error fields. This avoids treating echoed prompts or other arbitrary text as +// an account balance signal. +func isOpenAIImagesInsufficientBalance(body []byte) bool { + var value any + if len(body) == 0 || json.Unmarshal(body, &value) != nil { + return false + } + return hasOpenAIImagesInsufficientBalance(value, 0) +} + +func hasOpenAIImagesInsufficientBalance(value any, depth int) bool { + if depth > 6 { + return false + } + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if text, ok := child.(string); ok { + normalizedValue := strings.ToLower(strings.Trim(strings.TrimSpace(text), ".")) + switch normalizedKey { + case "errorkey", "error_key", "code": + if normalizedValue == OpenAIImagesInsufficientBalanceCode { + return true + } + } + } + if isOpenAIImagesErrorContainer(normalizedKey) && hasOpenAIImagesInsufficientBalance(child, depth+1) { + return true + } + } + case []any: + for _, child := range typed { + if hasOpenAIImagesInsufficientBalance(child, depth+1) { + return true + } + } + } + return false +} + +func isOpenAIImagesErrorContainer(key string) bool { + switch key { + case "error", "errors", "cause", "detail", "details", "inner", "innererror", "inner_error", "response": + return true + default: + return false + } +} + +func (s *OpenAIGatewayService) coolOpenAIImagesInsufficientBalance(ctx context.Context, account *Account) { + if s == nil || s.accountRepo == nil || account == nil { + return + } + now := time.Now() + resetAt := now.Add(openAIImagesBalanceCooldown) + setAccountModelRateLimitSnapshot(account, openAIImageGenerationRateLimitKey, resetAt, openAIImagesBalanceRateLimitReason, now) + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImagesBalanceRateLimitReason); err != nil { + slog.Warn("openai_images_balance_cooldown_failed", "account_id", account.ID, "error", err) + } +} + +func newOpenAIImagesInsufficientBalanceFailoverError(statusCode int, headers http.Header, body []byte) *UpstreamFailoverError { + return &UpstreamFailoverError{ + StatusCode: statusCode, + ResponseHeaders: headers.Clone(), + ResponseBody: body, + Reason: OpenAIImagesInsufficientBalanceReason, + ClientStatusCode: 402, + ClientMessage: OpenAIImagesInsufficientBalanceMessage, + NextAccountAction: NextAccountRetry, + } +} diff --git a/backend/internal/service/openai_images_balance_test.go b/backend/internal/service/openai_images_balance_test.go new file mode 100644 index 000000000000..d96fa0ebbff8 --- /dev/null +++ b/backend/internal/service/openai_images_balance_test.go @@ -0,0 +1,107 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type openAIImagesBalanceRepo struct { + AccountRepository + scope string + reason string + until time.Time +} + +func (r *openAIImagesBalanceRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, resetAt time.Time, reason ...string) error { + r.scope = scope + r.until = resetAt + if len(reason) > 0 { + r.reason = reason[0] + } + return nil +} + +type openAIImagesBalanceUpstream struct{ HTTPUpstream } + +func (openAIImagesBalanceUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{ + "X-Request-Id": []string{"req-images-balance"}, + "Retry-After": []string{"23"}, + }, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"message":"upstream request failed","cause":{"errorKey":"insufficient_balance","errorMessage":"Insufficient credit balance"}}}`, + )), + }, nil +} + +func TestForwardOpenAIImagesAPIKey_ClassifiesWrappedBalanceBeforeTransientFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + endpoint string + stream bool + }{ + {name: "non-stream generation", endpoint: openAIImagesGenerationsEndpoint}, + {name: "stream edit", endpoint: openAIImagesEditsEndpoint, stream: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &openAIImagesBalanceRepo{} + svc := &OpenAIGatewayService{accountRepo: repo, httpUpstream: openAIImagesBalanceUpstream{}, cfg: &config.Config{}} + account := &Account{ + ID: 7, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, + Credentials: map[string]any{"api_key": "test-key", "base_url": "https://images.example.test"}, + } + body := []byte(`{"model":"gpt-image-1","prompt":"cat"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/"+tc.endpoint, bytes.NewReader(body)) + parsed := &OpenAIImagesRequest{Model: "gpt-image-1", Endpoint: tc.endpoint, ContentType: "application/json", Stream: tc.stream, N: 1} + + _, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, "req-images-balance", failoverErr.ResponseHeaders.Get("X-Request-Id")) + require.Equal(t, "23", failoverErr.ResponseHeaders.Get("Retry-After")) + require.Equal(t, OpenAIImagesInsufficientBalanceReason, failoverErr.Reason) + require.True(t, failoverErr.ShouldRetryNextAccount()) + require.Equal(t, OpenAIImagesInsufficientBalanceCode, string(failoverErr.Reason)[len("openai_images_"):]) + require.Equal(t, openAIImageGenerationRateLimitKey, repo.scope) + require.Equal(t, openAIImagesBalanceRateLimitReason, repo.reason) + require.WithinDuration(t, time.Now().Add(openAIImagesBalanceCooldown), repo.until, 2*time.Second) + require.True(t, account.isRateLimitActiveForKey(openAIImageGenerationRateLimitKey)) + require.False(t, account.isModelRateLimitedWithContext(context.Background(), "gpt-5.5"), "text requests must remain schedulable") + }) + } +} + +func TestOpenAIImagesInsufficientBalanceDetectionRejectsUnstructuredAndEchoedText(t *testing.T) { + require.True(t, isOpenAIImagesInsufficientBalance([]byte(`{"errorKey":"insufficient_balance"}`))) + require.True(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"cause":{"errorKey":"insufficient_balance"}}}`))) + require.True(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"details":[{"code":"insufficient_balance"}]}}`))) + require.True(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"inner_error":{"error_key":" INSUFFICIENT_BALANCE. "}}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"message":"Insufficient credit balance"}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"detail":"Insufficient credit"}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"message":"Insufficient credit balance"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"details":[{"detail":"Insufficient credit"}]}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"errorMessage":"Insufficient credit balance"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"message":"{\"errorKey\":\"insufficient_balance\"}"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"error":{"detail":"upstream returned 400 {\"errorKey\":\"insufficient_balance\"}"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`gateway says insufficient_balance`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"request":{"prompt":"show the words insufficient_balance"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"request":{"errorKey":"insufficient_balance"}}`))) + require.False(t, isOpenAIImagesInsufficientBalance([]byte(`{"message":"the prompt mentioned insufficient credit balance yesterday"}`))) +} From 60a64568bf5f8fd505877dcb8b1414523899fd08 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 11:06:25 +0800 Subject: [PATCH 089/142] ci: run simple mode compose environment test --- .github/workflows/backend-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 2bac7259a9e4..8361b273b5fc 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -25,6 +25,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Check Docker Compose simple mode environment + run: /bin/sh deploy/tests/docker-compose-simple-mode-env-test.sh - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod From 386f321d3cfc052443a5b68eb3dc022d66665c51 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 12:41:01 +0800 Subject: [PATCH 090/142] fix(usage): export missing reasoning effort cleanly --- frontend/src/views/user/UsageView.vue | 1 + .../views/user/__tests__/UsageView.spec.ts | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 033064d10948..c1882480696c 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -626,6 +626,7 @@ const escapeCSVValue = (value: unknown): string => { if (value == null) return '' const str = String(value) const escaped = str.replace(/"/g, '""') + if (str === '-') return str if (/^[=+\-@\t\r]/.test(str)) return `"\'${escaped}"` if (/[,"\n\r]/.test(str)) return `"${escaped}"` return str diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts index 737d88aa7059..1ded62c5b7c2 100644 --- a/frontend/src/views/user/__tests__/UsageView.spec.ts +++ b/frontend/src/views/user/__tests__/UsageView.spec.ts @@ -399,7 +399,7 @@ describe('user UsageView', () => { expect(csvContent.startsWith('\uFEFF')).toBe(true) expect(csvContent.slice(1)).toBe([ 'Time,API Key Name,Model,Reasoning Effort,Inbound Endpoint,IP Address,Type,Billing Mode,Input Tokens,Output Tokens,Cache Read Tokens,Cache Creation Tokens,Rate Multiplier,Billed Cost,Original Cost,First Token (ms),Duration (ms)', - '2026-03-08T00:00:00Z,demo-key,gpt-5.4,"\'-",,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345', + '2026-03-08T00:00:00Z,demo-key,gpt-5.4,-,,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345', ].join('\n')) expect(csvContent).toContain('IP Address') expect(csvContent).toContain('203.0.113.10') @@ -415,6 +415,39 @@ describe('user UsageView', () => { clickSpy.mockRestore() }) + it('keeps formula-injection protection for dangerous exported values', async () => { + query.mockResolvedValue({ + items: [{ ...usageLog, api_key: { name: '-1+1' } }], + total: 1, + pages: 1, + }) + const wrapper = mountUsageView() + await flushPromises() + + let csvContent = '' + const OriginalBlob = globalThis.Blob + vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => { + csvContent = parts.map((part) => String(part)).join('') + return new OriginalBlob(parts, options) + })) + const originalCreateObjectURL = window.URL.createObjectURL + const originalRevokeObjectURL = window.URL.revokeObjectURL + window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL + window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await (wrapper.vm as any).exportToCSV() + + expect(csvContent).toContain(',"\'-1+1",gpt-5.4,-,') + expect(showSuccess).toHaveBeenCalled() + + window.URL.createObjectURL = originalCreateObjectURL + window.URL.revokeObjectURL = originalRevokeObjectURL + vi.unstubAllGlobals() + clickSpy.mockRestore() + wrapper.unmount() + }) + it('keeps the initial filters, sort, and filename while exporting multiple pages', async () => { const pageResponse = { items: [usageLog], total: 101, pages: 2 } query.mockResolvedValue(pageResponse) From d0ed0eacacfa5d6ed495960b6314e99797c9e4c5 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 21 Sep 2026 10:11:01 +0800 Subject: [PATCH 091/142] =?UTF-8?q?fix(gemini):=20=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E5=B1=82=E9=94=99=E8=AF=AF=E8=BD=AC=20failover,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=90=8C=E8=B4=A6=E5=8F=B7=E9=80=80=E9=81=BF=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 Do 返回非 HTTP 错误(代理/DNS/TCP/TLS/响应头超时)时,Gemini 三条转发路径 (ForwardNative / Forward / ForwardAsChatCompletions)原先在同一账号上重试 5 次, 退避 1+2+4+8≈15s(time.Sleep 不感知 ctx),耗尽后直接向客户端写 502 且不换号: 单账号链路故障期间 TTFT 被整段拖高,同组健康账号闲置。 对齐 Anthropic/Bedrock 侧 handleUpstreamTransportError 的既有语义: - 传输层错误单次尝试后即返回 *UpstreamFailoverError(502),由 handler failover 循环换号,耗尽后才按端点格式渲染错误;service 不再写响应 - 持久性故障(connection refused/no route/DNS not found/代理认证失败)额外临时 摘除账号 10 分钟;瞬时故障(EOF/reset/超时)只换号不摘号 - context.Canceled 保持原样返回:不换号也不摘账号 - countTokens 传输失败时立即本地估算兜底,不换号 - 摘号实现抽为 tempUnscheduleAccountForTransportError 供两侧共用,日志事件名不变 HTTP 状态码(429/5xx)的服务内重试环未改动。 --- .../gateway_upstream_transport_error.go | 14 +- .../gemini_chat_completions_compat_service.go | 19 +-- .../service/gemini_messages_compat_service.go | 43 +---- .../gemini_upstream_transport_error.go | 52 ++++++ .../gemini_upstream_transport_error_test.go | 159 ++++++++++++++++++ 5 files changed, 230 insertions(+), 57 deletions(-) create mode 100644 backend/internal/service/gemini_upstream_transport_error.go create mode 100644 backend/internal/service/gemini_upstream_transport_error_test.go diff --git a/backend/internal/service/gateway_upstream_transport_error.go b/backend/internal/service/gateway_upstream_transport_error.go index 66763b11995a..7781d5ceddb3 100644 --- a/backend/internal/service/gateway_upstream_transport_error.go +++ b/backend/internal/service/gateway_upstream_transport_error.go @@ -81,7 +81,17 @@ func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gi // - "gateway.account_temp_unschedule_transport_failed" — DB write attempted // but returned an error (the account remains schedulable). func (s *GatewayService) tempUnscheduleTransportError(ctx context.Context, account *Account, safeErr string) { - if s == nil || account == nil || s.accountRepo == nil { + if s == nil { + return + } + tempUnscheduleAccountForTransportError(ctx, s.accountRepo, account, safeErr) +} + +// tempUnscheduleAccountForTransportError is the repo-level implementation +// shared by every forward path whose scheduler reads the persisted +// temp-unschedulable state (Anthropic/Bedrock and Gemini). +func tempUnscheduleAccountForTransportError(ctx context.Context, repo AccountRepository, account *Account, safeErr string) { + if account == nil || repo == nil { return } until := time.Now().Add(gatewayTransportErrorTempUnschedDuration) @@ -89,7 +99,7 @@ func (s *GatewayService) tempUnscheduleTransportError(ctx context.Context, accou bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAccountStateUpdateTimeout) defer cancel() - if err := s.accountRepo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil { + if err := repo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil { logger.L().With(zap.String("component", "service.gateway")).Warn( "gateway.account_temp_unschedule_transport_failed", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 21b6e9af9710..959c1c84901a 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -125,24 +125,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - ProxyID: opsUpstreamProxyID(account), - ProxyName: opsUpstreamProxyName(account), - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - if attempt < geminiMaxRetries { - logger.LegacyPrintf("service.gemini_chat_completions", "Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err) - sleepGeminiBackoff(attempt) - continue - } - setOpsUpstreamError(c, 0, safeErr, "") - return nil, s.writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries: "+safeErr) + return nil, s.handleUpstreamTransportError(ctx, c, account, err) } if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel); matched { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 306d84d8f413..f02879ca9a01 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -810,24 +810,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - ProxyID: opsUpstreamProxyID(account), - ProxyName: opsUpstreamProxyName(account), - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - if attempt < geminiMaxRetries { - logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err) - sleepGeminiBackoff(attempt) - continue - } - setOpsUpstreamError(c, 0, safeErr, "") - return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries: "+safeErr) + return nil, s.handleUpstreamTransportError(ctx, c, account, err) } // Special-case: signature/thought_signature validation errors are not transient, but may be fixed by @@ -1362,23 +1345,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - ProxyID: opsUpstreamProxyID(account), - ProxyName: opsUpstreamProxyName(account), - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - if attempt < geminiMaxRetries { - logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err) - sleepGeminiBackoff(attempt) - continue - } - if action == "countTokens" { + transportErr := s.handleUpstreamTransportError(ctx, c, account, err) + // countTokens 不因上游链路故障而失败:本地估算兜底,不换号。 + var failoverErr *UpstreamFailoverError + if action == "countTokens" && errors.As(transportErr, &failoverErr) { estimated := estimateGeminiCountTokens(body) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ @@ -1391,8 +1361,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. FirstTokenMs: nil, }, nil } - setOpsUpstreamError(c, 0, safeErr, "") - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries: "+safeErr) + return nil, transportErr } // 错误策略优先:匹配则跳过重试直接处理。 diff --git a/backend/internal/service/gemini_upstream_transport_error.go b/backend/internal/service/gemini_upstream_transport_error.go new file mode 100644 index 000000000000..ea3f7ebc6624 --- /dev/null +++ b/backend/internal/service/gemini_upstream_transport_error.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// geminiTransportFailoverBody 是传输层失败时挂在 failover 错误上的 Google 格式错误体, +// 供 handler 在换号耗尽后做透传规则匹配与错误信息提取。 +var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstream request failed","status":"INTERNAL"}}`) + +// handleUpstreamTransportError 处理 Gemini 三条转发路径(原生 / Claude 兼容 / +// Chat Completions 兼容)上的传输层失败:Do 返回非 HTTP 错误(代理 / DNS / TCP / TLS / +// 响应头超时),没有拿到任何上游状态码。 +// +// - 记录 Ops 错误事件(status 0,kind=request_error); +// - 持久性故障(代理凭据失效、端点拒绝连接、DNS/路由不可达)临时摘除账号; +// 瞬时故障(EOF / reset / 超时)账号保持可调度; +// - 客户端已断开(context.Canceled)原样返回:不换号、不摘号; +// - 其余一律返回 *UpstreamFailoverError,由 handler 的 failover 循环换号。 +// +// 本函数不写响应:响应归 handler 所有(换号,或耗尽后按端点格式渲染错误)。 +func (s *GeminiMessagesCompatService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error) error { + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + setOpsUpstreamError(c, 0, safeErr, "") + event := OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + Kind: "request_error", + Message: safeErr, + } + event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account) + appendOpsUpstreamError(c, event) + + if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) { + return err + } + + if classifyUpstreamTransportError(err).Persistent { + tempUnscheduleAccountForTransportError(ctx, s.accountRepo, account, safeErr) + } + + return &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: geminiTransportFailoverBody, + } +} diff --git a/backend/internal/service/gemini_upstream_transport_error_test.go b/backend/internal/service/gemini_upstream_transport_error_test.go new file mode 100644 index 000000000000..03e5c249837f --- /dev/null +++ b/backend/internal/service/gemini_upstream_transport_error_test.go @@ -0,0 +1,159 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Gemini 传输层失败(Do 返回非 HTTP 错误)的契约,三条转发路径一致: +// - 不在同账号上重试,单次尝试后立即返回 *UpstreamFailoverError(502) 交给 handler 换号; +// - service 不写客户端响应; +// - 瞬时故障不摘号,持久性故障临时摘号; +// - 客户端断开原样返回,不换号、不摘号; +// - countTokens 以本地估算兜底。 +// --------------------------------------------------------------------------- + +const geminiTransportTestNativeBody = `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}` + +func newGeminiTransportErrorService(upstreamErr error) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub, *transportTempUnschedRepoStub) { + httpStub := &geminiCompatHTTPUpstreamStub{err: upstreamErr} + repo := &transportTempUnschedRepoStub{} + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + httpUpstream: httpStub, + cfg: &config.Config{}, + } + return svc, httpStub, repo +} + +func requireGeminiTransportFailover(t *testing.T, err error) *UpstreamFailoverError { + t.Helper() + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "传输层错误应换号,got %T: %v", err, err) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, string(geminiTransportFailoverBody), string(failoverErr.ResponseBody)) + require.True(t, failoverErr.ShouldRetryNextAccount()) + require.False(t, failoverErr.RetryableOnSameAccount, "传输层错误不做同账号重试") + return failoverErr +} + +func TestGeminiForwardNative_TransientTransportErrorFailsOverWithoutRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, repo := newGeminiTransportErrorService(errors.New(`Post "https://upstream/v1beta/models/gemini-2.5-flash:generateContent": EOF`)) + c, rec := newGeminiNativeTestContext(t) + + start := time.Now() + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(geminiTransportTestNativeBody)) + + require.Nil(t, result) + requireGeminiTransportFailover(t, err) + require.Equal(t, 1, httpStub.calls, "不应在同账号上重试") + require.Less(t, time.Since(start), geminiRetryBaseDelay, "不应进入退避等待") + require.Zero(t, repo.calls, "瞬时故障不摘号") + require.False(t, c.Writer.Written(), "换号场景不应写客户端响应") + require.Zero(t, rec.Body.Len()) + + raw, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok, "应记录 ops 上游错误事件") + events := raw.([]*OpsUpstreamErrorEvent) + require.Len(t, events, 1) + require.Equal(t, "request_error", events[0].Kind) + require.Equal(t, int64(700), events[0].AccountID) + require.Zero(t, events[0].UpstreamStatusCode) +} + +func TestGeminiForwardNative_PersistentTransportErrorEvictsAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, repo := newGeminiTransportErrorService(errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`)) + c, _ := newGeminiNativeTestContext(t) + account := geminiPoolModeAPIKeyAccount() + + before := time.Now() + _, err := svc.ForwardNative(context.Background(), c, account, + "gemini-2.5-flash", "streamGenerateContent", true, []byte(geminiTransportTestNativeBody)) + + requireGeminiTransportFailover(t, err) + require.Equal(t, 1, httpStub.calls) + require.Equal(t, 1, repo.calls, "持久性故障应临时摘号") + require.Equal(t, account.ID, repo.lastID) + require.WithinDuration(t, before.Add(gatewayTransportErrorTempUnschedDuration), repo.lastUntil, 5*time.Second) + require.False(t, c.Writer.Written()) +} + +func TestGeminiForwardNative_ClientCanceledReturnsOriginalError(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, repo := newGeminiTransportErrorService(context.Canceled) + c, _ := newGeminiNativeTestContext(t) + + _, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(geminiTransportTestNativeBody)) + + require.ErrorIs(t, err, context.Canceled) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "客户端断开不应换号") + require.Equal(t, 1, httpStub.calls) + require.Zero(t, repo.calls, "客户端断开不摘号") + require.False(t, c.Writer.Written()) +} + +func TestGeminiForwardNative_CountTokensTransportErrorFallsBackToEstimate(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, _ := newGeminiTransportErrorService(errors.New("EOF")) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "countTokens", false, []byte(geminiTransportTestNativeBody)) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, httpStub.calls) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"totalTokens"`) +} + +func TestGeminiForward_TransportErrorFailsOverWithoutRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, repo := newGeminiTransportErrorService(errors.New("read tcp 10.0.0.1:1234->1.2.3.4:443: read: connection reset by peer")) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + + result, err := svc.Forward(context.Background(), c, geminiPoolModeAPIKeyAccount(), body) + + require.Nil(t, result) + requireGeminiTransportFailover(t, err) + require.Equal(t, 1, httpStub.calls, "不应在同账号上重试") + require.Zero(t, repo.calls) + require.False(t, c.Writer.Written(), "换号场景不应写客户端响应") +} + +func TestGeminiForwardAsChatCompletions_TransportErrorFailsOverWithoutRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub, repo := newGeminiTransportErrorService(errors.New("EOF")) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiPoolModeAPIKeyAccount(), body) + + require.Nil(t, result) + requireGeminiTransportFailover(t, err) + require.Equal(t, 1, httpStub.calls, "不应在同账号上重试") + require.Zero(t, repo.calls) + require.False(t, c.Writer.Written(), "换号场景不应写客户端响应") +} From e47255715d79d0905b654fd046001e200824a860 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Mon, 21 Sep 2026 19:58:02 +0800 Subject: [PATCH 092/142] fix(billing): preserve final reasoning effort across forwarding paths --- .../service/antigravity_gateway_gemini.go | 1 + .../gateway_image_reasoning_pricing_test.go | 143 +++++++++++++ .../internal/service/gateway_usage_billing.go | 23 ++- .../gemini_chat_completions_compat_service.go | 8 +- .../service/gemini_messages_compat_service.go | 1 + .../gemini_native_reasoning_pricing_test.go | 190 ++++++++++++++++++ .../service/gemini_reasoning_effort.go | 32 +++ ...teway_chat_completions_anthropic_native.go | 8 +- ...ateway_converted_reasoning_pricing_test.go | 133 ++++++++++++ .../openai_gateway_messages_chat_fallback.go | 11 +- ...enai_gateway_responses_anthropic_native.go | 8 +- .../internal/service/openai_gateway_usage.go | 43 ++-- 12 files changed, 552 insertions(+), 49 deletions(-) create mode 100644 backend/internal/service/gateway_image_reasoning_pricing_test.go create mode 100644 backend/internal/service/gemini_native_reasoning_pricing_test.go create mode 100644 backend/internal/service/gemini_reasoning_effort.go create mode 100644 backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 777c1de9f1a0..0cede4c0e8b0 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -467,6 +467,7 @@ handleSuccess: UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), Stream: stream, + ReasoningEffort: extractGeminiReasoningEffortFromBody(injectedBody), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ClientDisconnect: clientDisconnect, diff --git a/backend/internal/service/gateway_image_reasoning_pricing_test.go b/backend/internal/service/gateway_image_reasoning_pricing_test.go new file mode 100644 index 000000000000..39309153ba8a --- /dev/null +++ b/backend/internal/service/gateway_image_reasoning_pricing_test.go @@ -0,0 +1,143 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRecordUsage_ImageReasoningPricing(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI} { + for _, source := range []string{PricingSourceChannel, PricingSourceGroup} { + for _, mode := range []BillingMode{BillingModeImage, BillingModePerRequest} { + for _, independent := range []bool{false, true} { + for _, effort := range []string{"high", "low", ""} { + name := fmt.Sprintf("%s/%s/%s/independent=%t/effort=%s", platform, source, mode, independent, effort) + t.Run(name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + groupID := int64(77) + model := "gemini-3-pro-image-preview" + price := 0.25 + pricing := ChannelModelPricing{ + Models: []string{model}, BillingMode: mode, PerRequestPrice: &price, + ReasoningEffortMultipliers: map[string]float64{"high": 2}, + } + resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price) + cache := resolver.channelService.cache.Load().(*channelCache) + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &pricing + group := &Group{ + ID: groupID, Platform: platform, Status: StatusActive, Hydrated: true, + RateMultiplier: 0.5, ImageRateIndependent: independent, ImageRateMultiplier: 0.25, + } + if source == PricingSourceGroup { + group.ModelPricing = []ChannelModelPricing{pricing} + // A matching group card owns the multiplier; the channel must not stack on top. + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3} + } + apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: group} + account := &Account{ID: 30, Platform: platform, Type: AccountTypeAPIKey} + user := &User{ID: 20} + var forwardedEffort *string + if effort != "" { + forwardedEffort = &effort + } + if platform == PlatformAnthropic { + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) + svc.resolver = resolver + require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second, + ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort, + }, + APIKey: apiKey, User: user, Account: account, + })) + } else { + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.resolver = resolver + require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second, + ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort, + }, + APIKey: apiKey, User: user, Account: account, + })) + } + wantTotal := 0.5 + if effort == "high" { + wantTotal *= 2 + } + wantRate := 0.5 + if independent { + wantRate = 0.25 + } + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, wantTotal, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, wantTotal*wantRate, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, wantTotal*wantRate, userRepo.lastAmount, 1e-12) + require.Equal(t, forwardedEffort, usageRepo.lastLog.ReasoningEffort) + require.Equal(t, 2, usageRepo.lastLog.ImageCount) + require.Equal(t, string(mode), *usageRepo.lastLog.BillingMode) + }) + } + } + } + } + } +} + +func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) { + for _, source := range []string{PricingSourceChannel, PricingSourceGroup} { + for _, media := range []string{"gateway_audio", "openai_audio", "openai_video"} { + t.Run(source+"/"+media, func(t *testing.T) { + groupID := int64(77) + model, effort := "media-model", "high" + price := 0.25 + resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price) + cache := resolver.channelService.cache.Load().(*channelCache) + pricing := cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] + pricing.BillingMode = BillingModePerRequest + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 2} + wantTotal := 1.0 // Two units at $0.25, with high=2. + if media == "openai_video" { + pricing.BillingMode = BillingModeVideo + wantTotal = 5 // Two five-second videos at $0.25/second, with high=2. + } + apiKey := &APIKey{GroupID: &groupID, Group: &Group{ID: groupID, Hydrated: true}} + if source == PricingSourceGroup { + pricing.Models = []string{model} + apiKey.Group.ModelPricing = []ChannelModelPricing{*pricing} + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3} + } + var cost *CostBreakdown + if media == "gateway_audio" { + svc := &GatewayService{billingService: resolver.billingService, resolver: resolver} + cost = svc.calculateRecordUsageCost(context.Background(), &ForwardResult{ + ReasoningEffort: &effort, AudioUsage: &AudioUsage{Mode: "tts", DurationOrUnits: 2}, + }, apiKey, model, 0.5, 0.5, time.Time{}) + } else { + svc := &OpenAIGatewayService{billingService: resolver.billingService, resolver: resolver} + result := &OpenAIForwardResult{ReasoningEffort: &effort} + if media == "openai_audio" { + result.AudioUsage = &AudioUsage{Mode: "tts", DurationOrUnits: 2} + } else { + result.VideoCount, result.VideoDurationSeconds = 2, 5 + } + var err error + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, + []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{}) + require.NoError(t, err) + } + require.NotNil(t, cost) + require.InDelta(t, wantTotal, cost.TotalCost, 1e-12) + require.InDelta(t, wantTotal*0.5, cost.ActualCost, 1e-12) + }) + } + } +} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1e1a0f84779b..5a9a00aea73d 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -903,6 +903,7 @@ func (s *GatewayService) calculateRecordUsageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -1027,6 +1028,7 @@ func (s *GatewayService) calculateImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, RequestCount: result.ImageCount, SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -1044,16 +1046,17 @@ func (s *GatewayService) calculateImageCost( } gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - Tokens: tokens, - RequestCount: result.ImageCount, - SizeTier: sizeTier, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + Tokens: tokens, + RequestCount: result.ImageCount, + SizeTier: sizeTier, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err) diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 21b6e9af9710..6c5418516ae6 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -58,7 +58,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( return nil, fmt.Errorf("marshal chat completions compat request: %w", err) } - return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime, body) + return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime) } func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( @@ -70,7 +70,6 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( clientStream bool, includeUsage bool, startTime time.Time, - originalChatBody []byte, ) (*ForwardResult, error) { var req struct { Model string `json:"model"` @@ -208,10 +207,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( c.Header("x-request-id", requestID) } - reasoningEffort := extractCCReasoningEffortFromBody(originalChatBody, mappedModel) - // 国产模型默认 effort 补充(本路径上游是 Gemini,不会命中 passback-required)。 - // 保持与 OpenAI 网关路径调用模式一致,便于未来上游变异时语义一致。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, originalChatBody, mappedModel) + reasoningEffort := extractGeminiReasoningEffortFromBody(geminiReq) if resp.StatusCode >= 400 { respBody := s.readUpstreamErrorBody(resp) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 306d84d8f413..0b7c154fecc3 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1668,6 +1668,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), Stream: stream, + ReasoningEffort: extractGeminiReasoningEffortFromBody(body), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ImageCount: imageCount, diff --git a/backend/internal/service/gemini_native_reasoning_pricing_test.go b/backend/internal/service/gemini_native_reasoning_pricing_test.go new file mode 100644 index 000000000000..718867ec3031 --- /dev/null +++ b/backend/internal/service/gemini_native_reasoning_pricing_test.go @@ -0,0 +1,190 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestGeminiNativeReasoningPricingUsesExplicitForwardedLevel(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, provider := range []string{"api_key", "oauth_ai_studio", "oauth_code_assist", "antigravity"} { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + name string + thinking map[string]any + wantEffort string + multiplier float64 + }{ + {"high", map[string]any{"thinkingLevel": "high"}, "high", 2}, + {"uppercase_high", map[string]any{"thinkingLevel": "HIGH"}, "high", 2}, + {"snake_case_high", map[string]any{"thinking_level": "high"}, "high", 2}, + {"minimal", map[string]any{"thinkingLevel": "minimal"}, "minimal", 1.1}, + {"low", map[string]any{"thinkingLevel": "low"}, "low", 1.2}, + {"medium", map[string]any{"thinkingLevel": "medium"}, "medium", 1.5}, + {"missing_level", nil, "", 1}, + {"budget_only", map[string]any{"thinkingBudget": 24576}, "", 1}, + {"unspecified_level", map[string]any{"thinkingLevel": "THINKING_LEVEL_UNSPECIFIED"}, "", 1}, + } { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(provider+"/"+mode+"/"+tc.name, func(t *testing.T) { + const model = "gemini-3.1-pro-high" + request := map[string]any{ + "contents": []any{map[string]any{"role": "user", "parts": []any{map[string]string{"text": "hello"}}}}, + } + if tc.name == "snake_case_high" { + request["generation_config"] = map[string]any{"thinking_config": tc.thinking} + } else if tc.thinking != nil { + request["generationConfig"] = map[string]any{"thinkingConfig": tc.thinking} + } + body, err := json.Marshal(request) + require.NoError(t, err) + action := "generateContent" + if stream { + action = "streamGenerateContent" + } + path := "/v1beta/models/" + model + ":" + action + c, recorder := newAntigravityCompatContext(http.MethodPost, path, body) + responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}` + wrapped := provider == "oauth_code_assist" || provider == "antigravity" + if wrapped { + responseBody = `{"response":` + responseBody + `}` + } + contentType := "application/json" + if stream || wrapped { + contentType = "text/event-stream" + responseBody = "data: " + responseBody + "\n\n" + } + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }}} + account := geminiSignalTestAccount() + var result *ForwardResult + if provider == "antigravity" { + account = newAntigravityCompatAccount(AccountTypeOAuth) + svc := newAntigravityCompatService(config.GatewayConfig{}, upstream) + result, err = svc.ForwardGemini(context.Background(), c, account, model, action, stream, body, false) + } else { + if provider != "api_key" { + account.Type = AccountTypeOAuth + account.Credentials["access_token"] = "test-token" + } + if provider == "oauth_code_assist" { + account.Credentials["project_id"] = "test-project" + } + svc := &GeminiMessagesCompatService{ + httpUpstream: upstream, cfg: &config.Config{}, tokenProvider: &GeminiTokenProvider{}, + } + result, err = svc.ForwardNative(context.Background(), c, account, model, action, stream, body) + } + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, upstream.requestBodies, 1) + forwardedPath := "generationConfig.thinkingConfig.thinkingLevel" + requestedLevel, _ := tc.thinking["thinkingLevel"].(string) + if tc.name == "snake_case_high" { + forwardedPath = "generation_config.thinking_config.thinking_level" + requestedLevel, _ = tc.thinking["thinking_level"].(string) + } + if wrapped { + forwardedPath = "request." + forwardedPath + } + require.Equal(t, requestedLevel, gjson.GetBytes(upstream.requestBodies[0], forwardedPath).String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + if tc.wantEffort == "" { + require.Nil(t, result.ReasoningEffort) + } + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + billing := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + billing.resolver = NewModelPricingResolver(nil, billing.billingService) + groupID := int64(77) + apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: &Group{ + ID: groupID, Platform: account.Platform, Status: StatusActive, Hydrated: true, RateMultiplier: 0.5, + ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"minimal": 1.1, "low": 1.2, "medium": 1.5, "high": 2}, + }}, + }} + require.NoError(t, billing.RecordUsage(context.Background(), &RecordUsageInput{ + Result: result, APIKey: apiKey, User: &User{ID: 20}, Account: account, + })) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, tc.wantEffort, optionalStringValue(usageRepo.lastLog.ReasoningEffort)) + require.InDelta(t, tc.multiplier, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, tc.multiplier*0.5, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, tc.multiplier*0.5, userRepo.lastAmount, 1e-12) + }) + } + } + } +} + +func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(mode, func(t *testing.T) { + const model = "gemini-3.1-pro" + body, err := json.Marshal(map[string]any{ + "model": model, "stream": stream, "reasoning_effort": "high", + "messages": []map[string]string{{"role": "user", "content": "hello"}}, + }) + require.NoError(t, err) + responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}` + contentType := "application/json" + if stream { + contentType = "text/event-stream" + responseBody = "data: " + responseBody + "\n\n" + } + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }}} + svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}} + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiSignalTestAccount(), body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, upstream.requestBodies, 1) + require.False(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig").Exists()) + require.Nil(t, result.ReasoningEffort) + + billing := NewBillingService(&config.Config{}, nil) + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: model, + Group: &Group{ID: 77, Platform: PlatformGemini, ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 2}, + }}}, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, 1, cost.TotalCost, 1e-12) + }) + } +} diff --git a/backend/internal/service/gemini_reasoning_effort.go b/backend/internal/service/gemini_reasoning_effort.go new file mode 100644 index 000000000000..8ab96bce7437 --- /dev/null +++ b/backend/internal/service/gemini_reasoning_effort.go @@ -0,0 +1,32 @@ +package service + +import ( + "strings" + + "github.com/tidwall/gjson" +) + +// extractGeminiReasoningEffortFromBody reads only an explicit level from the +// forwarded Gemini request. A budget or an omitted level does not define a +// billing level, even when the upstream model enables thinking by default. +func extractGeminiReasoningEffortFromBody(body []byte) *string { + config := gjson.GetBytes(body, "generationConfig") + if !config.Exists() { + config = gjson.GetBytes(body, "generation_config") + } + thinking := config.Get("thinkingConfig") + if !thinking.Exists() { + thinking = config.Get("thinking_config") + } + level := thinking.Get("thinkingLevel") + if !level.Exists() { + level = thinking.Get("thinking_level") + } + effort := strings.ToLower(strings.TrimSpace(level.String())) + switch effort { + case "minimal", "low", "medium", "high": + return &effort + default: + return nil + } +} 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 bc865af81c31..bc029bf48082 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -23,6 +23,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -111,7 +112,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) + upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) @@ -132,8 +133,9 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) } - reasoningEffort := extractCCReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + // Bill the Anthropic effort actually sent upstream (for example xhigh → max). + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) if clientStream { return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) diff --git a/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go b/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go new file mode 100644 index 000000000000..4a001d4f0d80 --- /dev/null +++ b/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go @@ -0,0 +1,133 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAINativeAnthropicReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, endpoint := range []string{"responses", "chat/completions"} { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + effort string + wantEffort string + wantCost float64 + }{ + {"xhigh", "max", 3}, + {"high", "high", 2}, + {"", "", 1}, + } { + t.Run(fmt.Sprintf("%s/stream=%t/effort=%s", endpoint, stream, tc.effort), func(t *testing.T) { + request := map[string]any{"model": "k3", "stream": stream} + if endpoint == "responses" { + request["input"] = "hello" + if tc.effort != "" { + request["reasoning"] = map[string]string{"effort": tc.effort} + } + } else { + request["messages"] = []map[string]string{{"role": "user", "content": "hello"}} + if tc.effort != "" { + request["reasoning_effort"] = tc.effort + } + } + body, err := json.Marshal(request) + require.NoError(t, err) + resp := nativeAnthropicStreamResponse() + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body = io.NopCloser(strings.NewReader(strings.ReplaceAll(string(responseBody), `"input_tokens":93`, `"input_tokens":1000000`))) + upstream := &httpUpstreamRecorder{resp: resp} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + c := adaptiveProtocolTestContext("/v1/"+endpoint, body) + account := nativeAnthropicTestAccount() + account.Credentials["base_url"] = "http://anthropic.example" + var result *OpenAIForwardResult + if endpoint == "responses" { + result, err = svc.Forward(context.Background(), c, account, body) + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.NoError(t, err) + require.Equal(t, "/v1/messages", upstream.lastReq.URL.Path) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + assertConvertedReasoningPrice(t, result, tc.wantCost) + }) + } + } + } +} + +func TestOpenAIMessagesGLMReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + effort string + maxPolicy string + wantEffort string + wantCost float64 + }{ + {"medium", "", "high", 2}, + {"xhigh", "", "max", 3}, + {"xhigh", "medium", "medium", 1.5}, + } { + t.Run(fmt.Sprintf("stream=%t/effort=%s/policy=%s", stream, tc.effort, tc.maxPolicy), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"glm-5.2","stream":%t,"max_tokens":32,"output_config":{"effort":%q},"messages":[{"role":"user","content":"hello"}]}`, stream, tc.effort)) + responseBody := `{"id":"chatcmpl_glm","object":"chat.completion","model":"glm-5.2","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}` + contentType := "application/json" + if stream { + contentType = "text/event-stream" + responseBody = "data: " + `{"id":"chatcmpl_glm","object":"chat.completion.chunk","model":"glm-5.2","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}` + "\n\ndata: [DONE]\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + ctx := context.Background() + if tc.maxPolicy != "" { + ctx = WithOpenAIReasoningEffortPolicy(ctx, tc.maxPolicy, nil, "") + } + result, err := svc.ForwardAsAnthropic(ctx, adaptiveProtocolTestContext("/v1/messages", body), forceChatMessagesFallbackAccount(), body, "", "") + require.NoError(t, err) + require.Equal(t, "/v1/chat/completions", upstream.lastReq.URL.Path) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + assertConvertedReasoningPrice(t, result, tc.wantCost) + }) + } + } +} + +func assertConvertedReasoningPrice(t *testing.T, result *OpenAIForwardResult, wantCost float64) { + t.Helper() + require.Equal(t, 1_000_000, result.Usage.InputTokens) + billing := NewBillingService(rawChatCompletionsTestConfig(), nil) + group := &Group{ID: 1, Platform: PlatformOpenAI, ModelPricing: []ChannelModelPricing{{ + Models: []string{result.BillingModel}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(0), + ReasoningEffortMultipliers: map[string]float64{"medium": 1.5, "high": 2, "xhigh": 2, "max": 3}, + }}} + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: result.BillingModel, Group: group, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, wantCost, cost.TotalCost, 1e-12) + require.InDelta(t, wantCost, cost.ActualCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 928e3fa9827d..b5f1fbbc177e 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -12,7 +12,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -67,9 +66,6 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true} } - convertedEffort := chatReq.ReasoningEffort - reasoningEffort := &convertedEffort - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) serviceTier := extractOpenAIServiceTierFromBody(body) chatBody, err := json.Marshal(chatReq) @@ -90,11 +86,12 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( } if changed { chatBody = policyBody - if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" { - reasoningEffort = &effectiveEffort - } } } + // Provider normalization and policy caps can both change the converted effort. + // Use the final outbound value for usage logs and billing. + reasoningEffort := extractOpenAIReasoningEffortFromBody(chatBody, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, chatBody, upstreamModel) // Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody // is intentionally skipped: Anthropic Messages bodies carry no service_tier, // so the converted Chat Completions body never contains one and the policy diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index fa2e190b9704..4295fa9544c1 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -75,9 +75,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) anthropicReq.Model = upstreamModel - reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) - // 5. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 anthropicReq.Stream = true @@ -116,11 +113,14 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) + upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the Anthropic effort actually sent upstream (for example xhigh → max). + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 3e253e378fc0..86ebf21ebd09 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -590,6 +590,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) } cfg := groupAudioPriceConfigFromAPIKey(apiKey) @@ -751,6 +752,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, RequestCount: result.ImageCount, SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -771,15 +773,16 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - RequestCount: result.ImageCount, - SizeTier: sizeTier, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + RequestCount: result.ImageCount, + SizeTier: sizeTier, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err == nil { return cost @@ -810,6 +813,7 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -835,16 +839,17 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( units = float64(videoCount * durationSeconds) } cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - RequestCount: videoCount, - UsageUnits: units, - SizeTier: resolution, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + RequestCount: videoCount, + UsageUnits: units, + SizeTier: resolution, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err == nil { cost.BillingMode = string(BillingModeVideo) From 27f4398b50ff763330a9e91d3f02c83ff95e473c Mon Sep 17 00:00:00 2001 From: lcy Date: Mon, 21 Sep 2026 20:13:44 +0800 Subject: [PATCH 093/142] =?UTF-8?q?fix(antigravity):=20=E8=A3=B8=20Gemini?= =?UTF-8?q?=20=E6=A8=A1=E5=9E=8B=E5=90=8D=E5=9C=A8=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E5=85=A5=E5=8F=A3=E8=A7=A3=E6=9E=90=E5=88=B0?= =?UTF-8?q?=E6=80=9D=E8=80=83=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7258 引入的 resolveGeminiThinkingVariant 只接到了 ForwardGemini, 因此仅覆盖 /v1beta 原生 Gemini 路径。经 /v1/chat/completions 与 /v1/messages 进来的请求直接调用 getMappedModel,裸名被原样透传; Antigravity 目录只登记 -low/-medium/-high/-tiered 变体,这些请求 仍然以 404 "Requested entity was not found." 失败。 把解析下沉到 getMappedModel(新增 getMappedModelForThinkingLevel), 一次覆盖四个调用点:/v1beta 原生、/v1/chat/completions、/v1/messages 以及后台账号连接测试。各转发入口按自身协议推导档位——原生读 generationConfig.thinkingConfig,Claude 侧读 thinking.budget_tokens, 两者阈值一致;默认入口按 high 兜底。 mapAntigravityModel 保持不变,调度侧的模型支持判定语义不受影响。 --- .../service/antigravity_gateway_claude.go | 6 +- .../service/antigravity_gateway_compat.go | 6 +- .../service/antigravity_gateway_gemini.go | 10 +- .../service/antigravity_gateway_service.go | 12 +++ .../antigravity_gemini_thinking_variant.go | 36 ++++++- ...ntigravity_gemini_thinking_variant_test.go | 94 +++++++++++++++++++ 6 files changed, 155 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 39b1c982a441..7f03536b7f0d 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -49,7 +49,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } originalModel := claudeReq.Model - mappedModel := s.getMappedModel(account, claudeReq.Model) + mappedModel := s.getMappedModelForThinkingLevel( + account, + claudeReq.Model, + geminiThinkingLevelFromClaudeThinking(claudeReq.Thinking), + ) if mappedModel == "" { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model)) diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index bf92f8b3f855..8763da57b0aa 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -212,7 +212,11 @@ func (s *AntigravityGatewayService) prepareAntigravityCompatCall( return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body") } - mappedModel := s.getMappedModel(account, request.originalModel) + mappedModel := s.getMappedModelForThinkingLevel( + account, + request.originalModel, + geminiThinkingLevelFromClaudeThinking(claudeRequest.Thinking), + ) if mappedModel == "" { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) message := fmt.Sprintf("model %s not in whitelist", request.originalModel) diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 777c1de9f1a0..3ce3e1fc241c 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -86,13 +86,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } - // 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体; + // 裸模型名(gemini-3.8-flash)按 thinkingConfig 解析到 -low/-medium/-high 变体; // 裸名有显式映射时保持原行为。 - mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body) - if !variantResolved { - mappedModel = s.getMappedModel(account, originalModel) - } else { - logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel) + mappedModel := s.getMappedModelForThinkingLevel(account, originalModel, geminiThinkingLevelFromBody(body)) + if mappedModel != "" && mappedModel != originalModel { + logger.LegacyPrintf("service.antigravity_gateway", "%s mapped Gemini model %s to %s", prefix, originalModel, mappedModel) } if mappedModel == "" { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index b835a84105fe..086466d31949 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -294,6 +294,18 @@ func mapAntigravityModel(account *Account, requestedModel string) string { // getMappedModel 获取映射后的模型名 // 完全依赖映射配置:账户映射(通配符)→ 默认映射兜底 func (s *AntigravityGatewayService) getMappedModel(account *Account, requestedModel string) string { + return s.getMappedModelForThinkingLevel(account, requestedModel, "") +} + +// getMappedModelForThinkingLevel 在常规映射之前先把裸 Gemini 模型名 +// (gemini-3.x-flash)解析到上游目录里真实存在的 -low/-medium/-high/-tiered 变体。 +// Antigravity 目录只登记带后缀的变体,裸名直接转发会被上游以 +// 404 "Requested entity was not found." 拒绝,因此所有转发入口都必须经过这一步。 +// thinkingLevel 为空时按 high 兜底;调用方可按自身协议传入推导出的档位。 +func (s *AntigravityGatewayService) getMappedModelForThinkingLevel(account *Account, requestedModel string, thinkingLevel string) string { + if mapped, ok := resolveGeminiThinkingVariantForLevel(account, requestedModel, thinkingLevel); ok { + return mapped + } return mapAntigravityModel(account, requestedModel) } diff --git a/backend/internal/service/antigravity_gemini_thinking_variant.go b/backend/internal/service/antigravity_gemini_thinking_variant.go index fbf02cc1c5c8..88c490686461 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant.go @@ -3,6 +3,8 @@ package service import ( "encoding/json" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) // Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时, @@ -83,6 +85,29 @@ func geminiThinkingLevelFromBody(body []byte) string { } } +// geminiThinkingLevelFromClaudeThinking 用 Claude Messages 协议的 thinking 配置推导档位, +// 阈值与 geminiThinkingLevelFromBody 保持一致,使同一请求无论走 Gemini 原生还是 +// Chat Completions / Messages 兼容层都落到同一个上游变体。 +func geminiThinkingLevelFromClaudeThinking(thinking *antigravity.ThinkingConfig) string { + if thinking == nil { + return "high" + } + if strings.EqualFold(strings.TrimSpace(thinking.Type), "disabled") { + return "low" + } + budget := thinking.BudgetTokens + switch { + case budget <= 0: + return "high" // 动态思考 / 未指定预算 + case budget <= geminiThinkingBudgetLowMax: + return "low" + case budget <= geminiThinkingBudgetMediumMax: + return "medium" + default: + return "high" + } +} + // accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里 // (而不是 resolveModelMapping 运行时补进来的默认透传条目)。 func accountRawModelMappingHasKey(account *Account, key string) bool { @@ -97,6 +122,13 @@ func accountRawModelMappingHasKey(account *Account, key string) bool { // resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。 // 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。 func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) { + return resolveGeminiThinkingVariantForLevel(account, requestedModel, geminiThinkingLevelFromBody(body)) +} + +// resolveGeminiThinkingVariantForLevel 是 resolveGeminiThinkingVariant 的协议无关内核: +// 调用方负责按自身协议推导 preferred 档位(Gemini 原生读 generationConfig.thinkingConfig, +// Claude/OpenAI 兼容层读 thinking.budget_tokens),此处只做映射查找与降级。 +func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel string, preferred string) (string, bool) { if account == nil { return "", false } @@ -120,7 +152,9 @@ func resolveGeminiThinkingVariant(account *Account, requestedModel string, body } } - preferred := geminiThinkingLevelFromBody(body) + if preferred == "" { + preferred = "high" + } order := []string{preferred} for _, level := range []string{"high", "medium", "low", "tiered"} { if level != preferred { diff --git a/backend/internal/service/antigravity_gemini_thinking_variant_test.go b/backend/internal/service/antigravity_gemini_thinking_variant_test.go index 3e9764249ca2..d0c17765a967 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant_test.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant_test.go @@ -3,6 +3,7 @@ package service import ( "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/stretchr/testify/require" ) @@ -116,3 +117,96 @@ func TestResolveGeminiThinkingVariant(t *testing.T) { require.Empty(t, got) }) } + +func TestGeminiThinkingLevelFromClaudeThinking(t *testing.T) { + tests := []struct { + name string + thinking *antigravity.ThinkingConfig + want string + }{ + {"nil thinking", nil, "high"}, + {"disabled", &antigravity.ThinkingConfig{Type: "disabled"}, "low"}, + {"enabled without budget", &antigravity.ThinkingConfig{Type: "enabled"}, "high"}, + {"budget 1024 -> low", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 1024}, "low"}, + {"budget 4000 -> medium", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 4000}, "medium"}, + {"budget 20000 -> high", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 20000}, "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, geminiThinkingLevelFromClaudeThinking(tt.thinking)) + }) + } +} + +// 锁定线上 404 回归:Chat Completions / Messages 兼容层收到裸 Gemini 模型名时, +// 必须和 Gemini 原生路径一样解析到带后缀的上游变体,而不是把裸名透传给上游。 +func TestResolveGeminiThinkingVariantForLevel_CompatPathResolvesBareModel(t *testing.T) { + catalog := map[string]string{ + "gemini-3.8-flash-low": "gemini-3.8-flash-low", + "gemini-3.8-flash-medium": "gemini-3.8-flash-medium", + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + } + account := newAntigravityAccountWithMapping(catalog) + + for _, tc := range []struct{ level, want string }{ + {"low", "gemini-3.8-flash-low"}, + {"medium", "gemini-3.8-flash-medium"}, + {"high", "gemini-3.8-flash-high"}, + {"", "gemini-3.8-flash-high"}, + } { + t.Run("level="+tc.level, func(t *testing.T) { + got, matched := resolveGeminiThinkingVariantForLevel(account, "gemini-3.8-flash", tc.level) + require.True(t, matched) + require.Equal(t, tc.want, got) + }) + } + + t.Run("already suffixed model is untouched", func(t *testing.T) { + got, matched := resolveGeminiThinkingVariantForLevel(account, "gemini-3.8-flash-high", "low") + require.False(t, matched) + require.Empty(t, got) + }) +} + +// 同一请求无论走 Gemini 原生还是兼容层,都必须落到同一个上游变体。 +func TestGeminiThinkingLevel_NativeAndCompatAgree(t *testing.T) { + native := geminiThinkingLevelFromBody([]byte(`{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`)) + compat := geminiThinkingLevelFromClaudeThinking(&antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 4000}) + require.Equal(t, native, compat) +} + +// 回归锁:裸名解析必须发生在 getMappedModel 这一层,从而覆盖全部 Antigravity +// 转发入口(/v1beta 原生、/v1/chat/completions、/v1/messages)以及账号测试。 +// 早前只在单个入口打补丁,导致 /v1/messages 仍把裸名透传给上游并触发 +// 404 "Requested entity was not found." +func TestGetMappedModelResolvesBareGeminiModelForAllEntrypoints(t *testing.T) { + svc := &AntigravityGatewayService{} + account := newAntigravityAccountWithMapping(map[string]string{ + "gemini-3.8-flash-low": "gemini-3.8-flash-low", + "gemini-3.8-flash-medium": "gemini-3.8-flash-medium", + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + }) + + t.Run("default entrypoint falls back to high", func(t *testing.T) { + require.Equal(t, "gemini-3.8-flash-high", svc.getMappedModel(account, "gemini-3.8-flash")) + }) + + t.Run("caller supplied level wins", func(t *testing.T) { + require.Equal(t, "gemini-3.8-flash-low", + svc.getMappedModelForThinkingLevel(account, "gemini-3.8-flash", "low")) + require.Equal(t, "gemini-3.8-flash-medium", + svc.getMappedModelForThinkingLevel(account, "gemini-3.8-flash", "medium")) + }) + + t.Run("models/ prefix is handled", func(t *testing.T) { + require.Equal(t, "gemini-3.8-flash-high", svc.getMappedModel(account, "models/gemini-3.8-flash")) + }) + + t.Run("suffixed model keeps normal mapping", func(t *testing.T) { + require.Equal(t, "gemini-3.8-flash-low", svc.getMappedModel(account, "gemini-3.8-flash-low")) + }) + + t.Run("unsupported model still reports unsupported", func(t *testing.T) { + require.Empty(t, svc.getMappedModel(account, "gemini-9.9-flash")) + }) +} From 935db68517f8beef407db3da016eaef1799fce25 Mon Sep 17 00:00:00 2001 From: nagi330 <502369068@qq.com> Date: Tue, 22 Sep 2026 10:30:01 +0800 Subject: [PATCH 094/142] feat(grok):add grok 4.7 support --- backend/internal/handler/gateway_handler.go | 2 +- backend/internal/pkg/xai/models.go | 3 +++ backend/internal/pkg/xai/models_test.go | 7 ++++++- backend/internal/service/billing_service.go | 15 +++++++++++++++ .../internal/service/billing_service_test.go | 19 +++++++++++++++++++ .../service/openai_codex_models_service.go | 1 + .../internal/service/openai_gateway_grok.go | 6 ++++-- .../openai_gateway_grok_chat_bridge.go | 2 +- backend/internal/service/opencode_go.go | 1 + .../service/upstream_response_model.go | 2 ++ .../model_prices_and_context_window.json | 11 +++++++++++ frontend/src/composables/useModelWhitelist.ts | 4 +++- 12 files changed, 67 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index c14143151537..3bd69cead738 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1375,7 +1375,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) { func grokModelSupportsConfigurableReasoning(modelID string) bool { switch strings.ToLower(strings.TrimSpace(modelID)) { - case "grok-4.6", "grok-4.6-latest", "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1": + case "grok-4.7", "grok-4.7-latest", "grok-4.6", "grok-4.6-latest", "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1": return true default: return false diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 65634598f11f..427b9bbf6de6 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -84,6 +84,7 @@ func (o ModelMappingOptions) defaultText() string { var defaultModels = []Model{ // Text + {ID: "grok-4.7", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.7"}, {ID: "grok-4.6", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.6"}, {ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, {ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, @@ -106,6 +107,8 @@ var defaultModels = []Model{ var grokTextResponsesModelAliases = map[string]string{ "grok": DefaultTextModel, "grok-latest": DefaultTextModel, + "grok-4.7": "grok-4.7", + "grok-4.7-latest": "grok-4.7", "grok-4.6": "grok-4.6", "grok-4.6-latest": "grok-4.6", "grok-4.5": "grok-4.5", diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index f9d58e5a0cb5..eee030c4fe2c 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -68,9 +68,14 @@ func TestIsGrokImagineModel(t *testing.T) { require.False(t, IsGrokImagineModel("grok-build-0.1")) } -func TestDefaultModelsIncludesGrok46(t *testing.T) { +func TestDefaultModelsIncludesGrok46And47(t *testing.T) { t.Parallel() ids := DefaultModelIDs() + require.Contains(t, ids, "grok-4.7") + require.Equal(t, "grok-4.7", ResolveGrokTextResponsesModelID("grok-4.7")) + require.Equal(t, "grok-4.7", ResolveGrokTextResponsesModelID("grok-4.7-latest")) + + // Keep the existing assertions for the previous current model as well. require.Contains(t, ids, "grok-4.6") require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok-4.6")) require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok-4.6-latest")) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 29161ce338c3..1be36ba52835 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -855,6 +855,19 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 2, } + // xAI Grok 4.7: $2 input / $0.50 cached input / $6 output below 200k; + // long-context rates are $4 / $1 / $12 (>=200k prompt tokens). + s.fallbackPrices["grok-4.7"] = &ModelPricing{ + InputPricePerToken: 2e-6, + OutputPricePerToken: 6e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, + } + // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k; // long-context rates are $2.50 / $0.40 / $5. s.fallbackPrices["grok-4.3"] = &ModelPricing{ @@ -1128,6 +1141,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { switch modelLower { case "grok", "grok-latest", "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] + case "grok-4.7", "grok-4.7-latest": + return s.fallbackPrices["grok-4.7"] case "grok-4.5", "grok-4.5-latest": return s.fallbackPrices["grok-4.5"] case "grok-3-mini": diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 59f20e076276..ed83a9730832 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1169,6 +1169,25 @@ func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { } } +func TestGetModelPricing_Grok47OfficialFallback(t *testing.T) { + svc := newTestBillingService() + + for _, model := range []string{"grok-4.7", "grok-4.7-latest"} { + model := model + t.Run(model, func(t *testing.T) { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.Equal(t, 200000, pricing.LongContextInputThreshold) + require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) + require.InDelta(t, 2.0, pricing.LongContextOutputMultiplier, 1e-12) + require.False(t, pricing.SupportsCacheBreakdown) + }) + } +} + func TestGetModelPricing_GrokOfficialFamilyCards(t *testing.T) { svc := newTestBillingService() for _, tc := range []struct { diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index e2658581e798..2c334524da33 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1267,6 +1267,7 @@ func isGrokCodexImageInputModel(model string) bool { case "grok-4.3", "grok-4.5", "grok-4.6", + "grok-4.7", "grok-build-0.1", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 5b9990b52cac..06a17a961e2e 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -744,16 +744,18 @@ func normalizeGrokReasoningEffortValue(raw, model string) (string, bool) { } // GrokSupportsXHighReasoningEffort reports whether the model advertises and -// forwards the xhigh reasoning effort (Grok 4.6 and its undated alias). +// forwards the xhigh reasoning effort (Grok 4.6/4.7 and their aliases). func GrokSupportsXHighReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) - return model == "grok-4.6" || model == "grok-4.6-latest" + return model == "grok-4.7" || model == "grok-4.7-latest" || + model == "grok-4.6" || model == "grok-4.6-latest" } func grokSupportsReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) switch model { case "grok-4.5", "grok-4.5-latest", "grok-4.6", "grok-4.6-latest", + "grok-4.7", "grok-4.7-latest", "grok-4.3", "grok-4.3-latest", "grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning", "grok-4.20-reasoning", "grok-4.20-multi-agent-0309": diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 27e49773e941..284b0dcf9fac 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -498,7 +498,7 @@ func grokChatResponsesCacheIntentBody(body []byte) ([]byte, error) { func grokChatResponsesBridgeModel(model string) bool { switch strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) { - case "grok-4.5", "grok-4.6", "grok-4.6-latest": + case "grok-4.5", "grok-4.6", "grok-4.6-latest", "grok-4.7", "grok-4.7-latest": return true default: return false diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go index a4bf6b150042..5e2dcc78a9be 100644 --- a/backend/internal/service/opencode_go.go +++ b/backend/internal/service/opencode_go.go @@ -27,6 +27,7 @@ const ( // 供 /v1/models 在尚未同步上游列表时回退,以及账号白名单预填。 func DefaultOpenCodeGoModelIDs() []string { return []string{ + "grok-4.7", "grok-4.6", "gpt-5.6-luna", "glm-5.3-flash", diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index 766657f84332..b22ec1c4a821 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -299,6 +299,8 @@ func canonicalGrokBuildRuntimeModel(model string) string { return "grok-4.5-build" case "grok-4.6", "grok-4.6-latest", "grok-4.6-build": return "grok-4.6-build" + case "grok-4.7", "grok-4.7-latest", "grok-4.7-build": + return "grok-4.7-build" default: return "" } 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 579d18db8637..bab05e418800 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -1,4 +1,15 @@ { + "grok-4.7": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "supports_prompt_caching": true + }, "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/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 91a847bf1952..80696b28f8bc 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -144,6 +144,7 @@ const metaModels = [ // xAI Grok const xaiModels = [ + 'grok-4.7', 'grok-4.6', 'grok-4.5', 'grok-4.3', @@ -326,6 +327,7 @@ const geminiPresetMappings = [ ] const grokPresetMappings = [ + { label: 'Grok 4.7', from: 'grok-4.7', to: 'grok-4.7', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, { label: 'Grok 4.6', from: 'grok-4.6', to: 'grok-4.6', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, { label: 'Grok 4.5', from: 'grok-4.5', to: 'grok-4.5', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, { label: 'Grok 4.3', from: 'grok-4.3', to: 'grok-4.3', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, @@ -454,7 +456,7 @@ export function getModelsByPlatform(platform: string): string[] { case 'moonshot': case 'kimi': return moonshotModels case 'opencode_go': return [ - 'grok-4.6', 'gpt-5.6-luna', + 'grok-4.7', 'grok-4.6', 'gpt-5.6-luna', 'glm-5.3-flash', 'glm-5.3', 'glm-5.2', 'glm-5.1', 'kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'longcat-2.0', From 7f1f85cb768545e2dfb65c11df0d1df7213faee6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 11:21:08 +0800 Subject: [PATCH 095/142] fix(gemini): stop cooling Vertex 429s until PST midnight, honor RetryInfo Vertex AI reports 429s with a standard google.rpc.RetryInfo detail (retryDelay like "39s"), but ParseGeminiRateLimitResetTime only knew the AI Studio quotaResetDelay metadata, so parsing always failed for Vertex bodies. The fallback then lumped service_account accounts in with API-key/AI-Studio ones and rate limited them until the next PST midnight, so a transient per-minute throttle could take a pay-as-you-go account (and with it the whole group) offline for up to a day. - Parse google.rpc.RetryInfo retryDelay from 429 bodies, capped at 15 minutes to guard against absurd upstream values. - When no reset time can be parsed, cool service_account (Vertex) accounts down for 60s instead of until PST midnight: pay-as-you-go quotas are short-window RPM/TPM with no daily reset. Fallback behavior for Code Assist, Google One and API-key accounts is unchanged, covered by new regression tests. --- .../service/gemini_error_policy_test.go | 71 +++++++++++++++++++ .../service/gemini_messages_compat_service.go | 42 ++++++++--- .../gemini_messages_compat_service_test.go | 34 +++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index 807ed7056b14..c868cf641727 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -606,6 +606,77 @@ func TestHandleGeminiUpstreamError_PoolMode429(t *testing.T) { } } +// Vertex(service_account)429 响应带 google.rpc.RetryInfo 时,应按 retryDelay 冷却, +// 而不是回退到 PST 午夜。 +func TestHandleGeminiUpstreamError_VertexRetryInfoUsesParsedDelay(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{accountRepo: repo} + + account := &Account{ + ID: 601, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{ + "project_id": "my-vertex-project", + "location": "global", + }, + } + body := []byte(`{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"39s"}]}}`) + + before := time.Now() + svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body) + + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, int64(601), repo.lastRateLimitID) + require.WithinDuration(t, before.Add(39*time.Second), repo.lastRateLimitReset, 2*time.Second) +} + +// Vertex(service_account)429 响应不带任何可解析的重置时间时,走短冷却兜底, +// 不再冷却到 PST 午夜(按量付费没有每日配额,长冷却会导致整组不可用)。 +func TestHandleGeminiUpstreamError_VertexFallbackUsesShortCooldown(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{accountRepo: repo} + + account := &Account{ + ID: 602, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{ + "project_id": "my-vertex-project", + }, + } + body := []byte(`{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED"}}`) + + before := time.Now() + svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body) + + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, int64(602), repo.lastRateLimitID) + require.WithinDuration(t, before.Add(geminiVertexFallbackCooldown), repo.lastRateLimitReset, 2*time.Second) + // 显式守护:不应被冷却超过 15 分钟(更不应到次日 PST 午夜) + require.True(t, repo.lastRateLimitReset.Before(before.Add(15*time.Minute))) +} + +// API Key(AI Studio)无法解析重置时间时仍冷却到 PST 午夜,行为保持不变。 +func TestHandleGeminiUpstreamError_APIKeyFallbackStillPSTMidnight(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{accountRepo: repo} + + account := &Account{ + ID: 603, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + } + body := []byte(`{"error":{"code":429,"message":"rate limit"}}`) + + svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body) + + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, int64(603), repo.lastRateLimitID) + expected := time.Unix(*nextGeminiDailyResetUnix(), 0) + require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second) +} + type geminiErrorPolicyRepo struct { mockAccountRepoForGemini setErrorCalls int diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 0b7c154fecc3..a5c39ea3d882 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -40,6 +40,16 @@ const ( geminiRetryMaxDelay = 16 * time.Second ) +const ( + // google.rpc.RetryInfo 的标准 @type(Vertex AI 429 响应携带,retryDelay 形如 "39s") + geminiRetryInfoTypeURL = "type.googleapis.com/google.rpc.RetryInfo" + // RetryInfo.retryDelay 的上限,防止异常值导致账号长时间不可用 + geminiRetryInfoMaxDelay = 15 * time.Minute + // Vertex(service_account,按量付费)429 无法解析重置时间时的兜底冷却: + // 按量付费限速是短窗口 RPM/TPM,没有每日配额,不适用 PST 午夜重置 + geminiVertexFallbackCooldown = time.Minute +) + // Gemini tool calling now requires `thoughtSignature` in parts that include `functionCall`. // Many clients don't send it; we inject a known dummy signature to satisfy the validator. // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures @@ -3140,6 +3150,10 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont } else { logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Google One OAuth, tier=%s, project=%s) rate limited, cooldown=%v", account.ID, tierID, projectID, time.Until(ra).Truncate(time.Second)) } + } else if account.IsVertexServiceAccount() { + // Vertex 按量付费:短窗限速,无每日配额,短冷却后即可恢复 + ra = time.Now().Add(geminiVertexFallbackCooldown) + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Vertex service account, project=%s) rate limited, cooldown=%v", account.ID, projectID, time.Until(ra).Truncate(time.Second)) } else { // API Key / AI Studio OAuth: PST 午夜 if ts := nextGeminiDailyResetUnix(); ts != nil { @@ -3172,19 +3186,29 @@ func ParseGeminiRateLimitResetTime(body []byte) *int64 { } } - // 遍历 error.details 查找 quotaResetDelay + // 遍历 error.details 查找 quotaResetDelay(AI Studio)或 RetryInfo.retryDelay(Vertex) var found *int64 gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool { - v := detail.Get("metadata.quotaResetDelay").String() - if v == "" { + if v := detail.Get("metadata.quotaResetDelay").String(); v != "" { + if dur, err := time.ParseDuration(v); err == nil { + // Use ceil to avoid undercounting fractional seconds (e.g. 10.1s should not become 10s), + // which can affect scheduling decisions around thresholds (like 10s). + ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds())) + found = &ts + return false + } return true } - if dur, err := time.ParseDuration(v); err == nil { - // Use ceil to avoid undercounting fractional seconds (e.g. 10.1s should not become 10s), - // which can affect scheduling decisions around thresholds (like 10s). - ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds())) - found = &ts - return false + // Vertex 标准错误格式:google.rpc.RetryInfo.retryDelay("39s" / "1.5s") + if detail.Get("@type").String() == geminiRetryInfoTypeURL { + if dur, err := time.ParseDuration(detail.Get("retryDelay").String()); err == nil && dur > 0 { + if dur > geminiRetryInfoMaxDelay { + dur = geminiRetryInfoMaxDelay + } + ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds())) + found = &ts + return false + } } return true }) diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index 3d7fda044eb4..07fafa94930d 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -1017,6 +1017,40 @@ func TestParseGeminiRateLimitResetTime(t *testing.T) { wantNil: false, approxDelta: 10, }, + { + name: "Vertex RetryInfo retryDelay", + input: `{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"39s"}]}}`, + wantNil: false, + approxDelta: 39, + }, + { + name: "Vertex RetryInfo 小数秒向上取整", + input: `{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"1.5s"}]}}`, + wantNil: false, + approxDelta: 2, + }, + { + name: "Vertex RetryInfo 超过上限被截断到 15 分钟", + input: `{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"3600s"}]}}`, + wantNil: false, + approxDelta: 900, + }, + { + name: "retryDelay 存在但 @type 不是 RetryInfo 则忽略", + input: `{"error":{"code":429,"details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","retryDelay":"39s"}]}}`, + wantNil: true, + }, + { + name: "RetryInfo retryDelay 非法值忽略", + input: `{"error":{"code":429,"details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"soon"}]}}`, + wantNil: true, + }, + { + name: "quotaResetDelay 与 RetryInfo 并存时先到者优先", + input: `{"error":{"code":429,"details":[{"metadata":{"quotaResetDelay":"120s"}},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"39s"}]}}`, + wantNil: false, + approxDelta: 120, + }, } for _, tt := range tests { From e35123b8aca050231d105f976cc459a39715057b Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Tue, 22 Sep 2026 14:50:29 +0800 Subject: [PATCH 096/142] fix(codex): include OpenCode Go in model capability resolution --- .../openai_codex_model_metadata_test.go | 73 +++++++++++++++++++ .../service/openai_codex_models_service.go | 5 +- .../openai_codex_models_service_test.go | 1 + 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index cb757a1faa68..f0d35b56cebc 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -192,6 +192,79 @@ func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) require.EqualValues(t, 1_000_000, models[0]["context_window"]) } +type openCodeGoCodexCatalogRepo struct { + AccountRepository + groupID int64 + accounts []Account + platforms []string +} + +func (r *openCodeGoCodexCatalogRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { + return nil, nil +} + +func (r *openCodeGoCodexCatalogRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, _ bool) ([]Account, error) { + r.platforms = append([]string(nil), platforms...) + if groupID == nil || *groupID != r.groupID { + return nil, nil + } + for _, platform := range platforms { + if platform == PlatformOpenCodeGo { + return append([]Account(nil), r.accounts...), nil + } + } + return nil, nil +} + +// Scenario: OpenCode Go accounts contribute synced capabilities to a Composite +// manifest only when they are selected by the catalog candidate query. +func TestBuildCodexModelsManifestForGroupMergesOpenCodeGoSyncedMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 742 + const publicModel = "oc/muse-spark-1.3-contributor" + const upstreamModel = "muse-spark-1.3-contributor" + + reasoning := true + account := Account{ + ID: 43, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + "model_mapping": map[string]any{publicModel: upstreamModel}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + upstreamModel: { + ID: upstreamModel, + Reasoning: &reasoning, + DefaultReasoningLevel: "medium", + SupportedReasoningLevels: []string{"minimal", "low", "medium", "high", "xhigh"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 1_048_576, + }, + }}) + repo := &openCodeGoCodexCatalogRepo{groupID: groupID, accounts: []Account{account}} + svc := &GatewayService{accountRepo: repo} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{publicModel}, + ) + require.NoError(t, err) + require.Contains(t, repo.platforms, PlatformOpenCodeGo) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"minimal", "low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + require.EqualValues(t, 1_048_576, models[0]["context_window"]) +} + // Scenario: an explicitly non-reasoning model remains directly selectable in Codex. func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index e2658581e798..008b41b86ecb 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -262,6 +262,7 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, + PlatformOpenCodeGo, }, false, ) @@ -1074,7 +1075,7 @@ func groupCodexModelSupportsImageInput( return false } } - if platform != PlatformOpenAI && platform != PlatformGrok && platform != PlatformDeepseek { + if platform != PlatformOpenAI && platform != PlatformGrok && platform != PlatformDeepseek && platform != PlatformOpenCodeGo { return false } @@ -1213,7 +1214,7 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) return false } switch account.Platform { - case PlatformOpenAI, PlatformDeepseek: + case PlatformOpenAI, PlatformDeepseek, PlatformOpenCodeGo: if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok { if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { // Official GPT-6 Astra metadata briefly shipped with a stale diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 888a32f55ccd..ae0d1f0396d1 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1114,6 +1114,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { require.Contains(t, repo.platforms, PlatformGrok) require.Contains(t, repo.platforms, PlatformDeepseek) require.Contains(t, repo.platforms, PlatformMiniMax) + require.Contains(t, repo.platforms, PlatformOpenCodeGo) require.NotContains(t, repo.platforms, PlatformComposite) } From b754ff6f1ba826a14700aadd2a841a28c8d46bfa Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:51:44 +0800 Subject: [PATCH 097/142] fix(proxies): invalidate billing probe on fallback restore --- backend/internal/repository/account_repo.go | 6 +- .../proxy_restore_probe_integration_test.go | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 backend/internal/repository/proxy_restore_probe_integration_test.go diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 8b8c82dd668f..84e8fc156aba 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -3887,8 +3887,12 @@ func (r *accountRepository) ResetQuotaUsedAndClearRateLimitCooldown(ctx context. // 仅当 proxy_fallback_origin_id IS NOT NULL 时执行更新; // 若影响行数为 0,则返回 ErrAccountNotInFallback(账号存在但不在 fallback 状态)。 func (r *accountRepository) RevertProxyFallback(ctx context.Context, accountID int64) error { + // Probe snapshots belong to the network identity; invalidate only on a real proxy change. res, err := r.sql.ExecContext(ctx, ` - UPDATE accounts SET proxy_id=proxy_fallback_origin_id, proxy_fallback_origin_id=NULL, updated_at=NOW() + UPDATE accounts SET + extra=CASE WHEN type='apikey' AND proxy_id IS DISTINCT FROM proxy_fallback_origin_id + THEN extra - 'upstream_billing_probe' ELSE extra END, + proxy_id=proxy_fallback_origin_id, proxy_fallback_origin_id=NULL, updated_at=NOW() WHERE id=$1 AND proxy_fallback_origin_id IS NOT NULL AND deleted_at IS NULL`, accountID) if err != nil { return err diff --git a/backend/internal/repository/proxy_restore_probe_integration_test.go b/backend/internal/repository/proxy_restore_probe_integration_test.go new file mode 100644 index 000000000000..ebbf77a37efc --- /dev/null +++ b/backend/internal/repository/proxy_restore_probe_integration_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package repository + +import ( + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (s *ProxyExpirySuite) TestRevertClearsBillingProbe() { + original := s.mkProxy("original", service.FallbackModeDirect, nil, nil) + backup := s.mkProxy("backup", service.FallbackModeNone, nil, nil) + account := s.mkAccountWithProxy(backup) + _, err := s.tx.ExecContext(s.ctx, `UPDATE accounts SET type='apikey',proxy_fallback_origin_id=$1,extra='{"upstream_billing_probe_enabled":true,"upstream_billing_probe":{"status":"ok","data":{"balance":123}},"keep_me":true}'::jsonb WHERE id=$2`, original, account) + s.Require().NoError(err) + repo := newAccountRepositoryWithSQL(s.tx.Client(), s.tx, nil) + s.Require().NoError(repo.RevertProxyFallback(s.ctx, account)) + s.Require().Equal(&original, s.accountProxyID(account)) + var raw []byte + s.Require().NoError(scanSingleRow(s.ctx, s.tx, `SELECT extra FROM accounts WHERE id=$1`, []any{account}, &raw)) + var extra map[string]any + s.Require().NoError(json.Unmarshal(raw, &extra)) + s.NotContains(extra, "upstream_billing_probe", "probe from previous network identity must be invalidated") + s.Equal(true, extra["keep_me"]) +} + +func (s *ProxyExpirySuite) TestRevertProbeInvalidationScope() { + for _, tc := range []struct { + name, kind string + same, direct, clear bool + }{ + {"changed API key", "apikey", false, false, true}, + {"direct API key", "apikey", false, true, true}, + {"same proxy", "apikey", true, false, false}, + {"oauth metadata", "oauth", false, false, false}, + } { + s.Run(tc.name, func() { + original := s.mkProxy("origin", service.FallbackModeNone, nil, nil) + current := s.mkProxy("current", service.FallbackModeNone, nil, nil) + if tc.same { + current = original + } + account := s.mkAccountWithProxy(current) + _, err := s.tx.ExecContext(s.ctx, `UPDATE accounts SET type=$1,proxy_fallback_origin_id=$2,extra='{"upstream_billing_probe_enabled":true,"upstream_billing_probe":{"status":"ok"},"keep_me":true}'::jsonb WHERE id=$3`, tc.kind, original, account) + s.Require().NoError(err) + if tc.direct { + _, err = s.tx.ExecContext(s.ctx, `UPDATE accounts SET proxy_id=NULL WHERE id=$1`, account) + s.Require().NoError(err) + } + repo := newAccountRepositoryWithSQL(s.tx.Client(), s.tx, nil) + s.Require().NoError(repo.RevertProxyFallback(s.ctx, account)) + var raw []byte + s.Require().NoError(scanSingleRow(s.ctx, s.tx, `SELECT extra FROM accounts WHERE id=$1`, []any{account}, &raw)) + var extra map[string]any + s.Require().NoError(json.Unmarshal(raw, &extra)) + if tc.clear { + s.NotContains(extra, "upstream_billing_probe") + } else { + s.Contains(extra, "upstream_billing_probe") + } + s.Equal(true, extra["upstream_billing_probe_enabled"]) + s.Equal(true, extra["keep_me"]) + s.Equal(&original, s.accountProxyID(account)) + }) + } +} From 85ed17b3139cc2f01eaa315632bf9000229980ee Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:03:05 +0800 Subject: [PATCH 098/142] fix(subscriptions): lock negative redemption updates --- .../redeem_reduction_lock_integration_test.go | 122 ++++++++++++++++++ backend/internal/service/redeem_service.go | 8 ++ ...redeem_subscription_reduction_lock_test.go | 39 ++++++ 3 files changed, 169 insertions(+) create mode 100644 backend/internal/repository/redeem_reduction_lock_integration_test.go create mode 100644 backend/internal/service/redeem_subscription_reduction_lock_test.go diff --git a/backend/internal/repository/redeem_reduction_lock_integration_test.go b/backend/internal/repository/redeem_reduction_lock_integration_test.go new file mode 100644 index 000000000000..009f52224ff5 --- /dev/null +++ b/backend/internal/repository/redeem_reduction_lock_integration_test.go @@ -0,0 +1,122 @@ +//go:build integration + +package repository + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +type lockedReductionReadRepo struct { + service.UserSubscriptionRepository + afterRead func(*service.UserSubscription) + lockReads atomic.Int32 +} + +func (r *lockedReductionReadRepo) GetByUserIDAndGroupID(ctx context.Context, u, g int64) (*service.UserSubscription, error) { + sub, err := r.UserSubscriptionRepository.GetByUserIDAndGroupID(ctx, u, g) + if err == nil && r.afterRead != nil { + r.afterRead(sub) + } + return sub, err +} +func (r *lockedReductionReadRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + r.lockReads.Add(1) + return r.UserSubscriptionRepository.GetByIDForUpdate(ctx, id) +} +func newReductionLockFixture(t *testing.T, name string) (*service.RedeemService, *lockedReductionReadRepo, *service.UserSubscription, int64, []string) { + t.Helper() + ctx := context.Background() + client := testEntClient(t) + user, err := client.User.Create().SetEmail(name + "@example.com").SetPasswordHash("test").Save(ctx) + require.NoError(t, err) + group, err := client.Group.Create().SetName(name).Save(ctx) + require.NoError(t, err) + // Redemption commits its own transaction; remove fixtures explicitly so + // repository list/count tests cannot observe this test's subscriptions. + t.Cleanup(func() { + _, err := integrationDB.ExecContext(context.Background(), `DELETE FROM redeem_codes WHERE group_id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM user_subscriptions WHERE user_id=$1`, user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM groups WHERE id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM users WHERE id=$1`, user.ID) + require.NoError(t, err) + }) + now := time.Now().UTC().Truncate(time.Microsecond) + sub, err := client.UserSubscription.Create().SetUserID(user.ID).SetGroupID(group.ID).SetStartsAt(now.Add(-time.Hour)).SetExpiresAt(now.AddDate(0, 0, 10)).SetStatus(service.SubscriptionStatusActive).SetNotes("initial").Save(ctx) + require.NoError(t, err) + repo := &lockedReductionReadRepo{UserSubscriptionRepository: NewUserSubscriptionRepository(client)} + codes := NewRedeemCodeRepository(client) + values := []string{name + "-first", name + "-second"} + for _, value := range values { + require.NoError(t, codes.Create(ctx, &service.RedeemCode{Code: value, Type: service.RedeemTypeSubscription, Status: service.StatusUnused, ValidityDays: -1, GroupID: &group.ID})) + } + subscriptions := service.NewSubscriptionService(nil, repo, nil, client, nil) + redeem := service.NewRedeemService(codes, NewUserRepository(client, integrationDB), subscriptions, nil, nil, client, nil, nil) + return redeem, repo, userSubscriptionEntityToService(sub), user.ID, values +} +func TestRedeemReductionPreservesRenewal(t *testing.T) { + redeem, repo, sub, userID, codes := newReductionLockFixture(t, "reduction-renewal") + renewed := sub.ExpiresAt.AddDate(0, 0, 10) + repo.afterRead = func(stale *service.UserSubscription) { + // Commit renewal on another connection after the deduction's initial read. + require.NoError(t, repo.UserSubscriptionRepository.ExtendExpiry(context.Background(), stale.ID, renewed)) + require.NoError(t, repo.UserSubscriptionRepository.UpdateNotes(context.Background(), stale.ID, "renewal")) + } + _, err := redeem.RedeemForAdminFulfillment(context.Background(), userID, codes[0]) + require.NoError(t, err) + got, err := repo.GetByID(context.Background(), sub.ID) + require.NoError(t, err) + require.True(t, renewed.AddDate(0, 0, -1).Equal(got.ExpiresAt)) + require.Contains(t, got.Notes, "renewal") + require.Contains(t, got.Notes, codes[0]) + require.EqualValues(t, 1, repo.lockReads.Load()) +} +func TestRedeemConcurrentReductionsBothApply(t *testing.T) { + redeem, repo, sub, userID, codes := newReductionLockFixture(t, "reduction-concurrent") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ready := make(chan struct{}, 2) + release := make(chan struct{}) + repo.afterRead = func(*service.UserSubscription) { + ready <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + } + } + results := make(chan error, 2) + for _, code := range codes { + go func(code string) { _, err := redeem.RedeemForAdminFulfillment(ctx, userID, code); results <- err }(code) + } + for range 2 { + select { + case <-ready: + case <-ctx.Done(): + t.Fatal("both deductions did not reach the read barrier") + } + } + close(release) + for range 2 { + select { + case err := <-results: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("deductions did not finish") + } + } + got, err := repo.GetByID(ctx, sub.ID) + require.NoError(t, err) + require.True(t, sub.ExpiresAt.AddDate(0, 0, -2).Equal(got.ExpiresAt)) + for _, code := range codes { + require.Contains(t, got.Notes, code) + } + require.EqualValues(t, 2, repo.lockReads.Load()) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 7b42b04d70da..30dd4f911f41 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -702,6 +702,14 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, return ErrSubscriptionNotFound } + // Redemption already owns a transaction. Lock and reread the subscription + // before computing changes: the redeem-code lock cannot serialize different + // codes (or an administrator renewal) targeting the same subscription. + sub, err = s.subscriptionService.userSubRepo.GetByIDForUpdate(ctx, sub.ID) + if err != nil { + return fmt.Errorf("lock subscription for reduction: %w", err) + } + now := time.Now() remaining := int(sub.ExpiresAt.Sub(now).Hours() / 24) if remaining < 0 { diff --git a/backend/internal/service/redeem_subscription_reduction_lock_test.go b/backend/internal/service/redeem_subscription_reduction_lock_test.go new file mode 100644 index 000000000000..44287125b032 --- /dev/null +++ b/backend/internal/service/redeem_subscription_reduction_lock_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + "errors" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestRedeemReductionUsesLockedSubscription(t *testing.T) { + now := time.Now() + stale := UserSubscription{ID: 7, UserID: 11, GroupID: 13, ExpiresAt: now.AddDate(0, 0, 10), Status: SubscriptionStatusActive, Notes: "old"} + current := stale + current.ExpiresAt = now.AddDate(0, 0, 20) + current.Notes = "renewed" + repo := &lockingRenewalRepo{stale: stale, current: current} + svc := &RedeemService{subscriptionService: NewSubscriptionService(nil, repo, nil, nil, nil)} + require.NoError(t, svc.reduceOrCancelSubscription(context.Background(), 11, 13, 1, "minus-one-day")) + require.Equal(t, current.ExpiresAt.AddDate(0, 0, -1), repo.current.ExpiresAt) + require.Contains(t, repo.current.Notes, "renewed") + require.Equal(t, 1, repo.lockReads) +} + +type failingReductionLockRepo struct { + *lockingRenewalRepo + err error +} + +func (r *failingReductionLockRepo) GetByIDForUpdate(context.Context, int64) (*UserSubscription, error) { + return nil, r.err +} +func TestRedeemReductionLockFailureDoesNotWrite(t *testing.T) { + sub := UserSubscription{ID: 7, ExpiresAt: time.Now().AddDate(0, 0, 10), Status: SubscriptionStatusActive, Notes: "unchanged"} + repo := &failingReductionLockRepo{lockingRenewalRepo: &lockingRenewalRepo{stale: sub, current: sub}, err: errors.New("lock failed")} + svc := &RedeemService{subscriptionService: NewSubscriptionService(nil, repo, nil, nil, nil)} + require.ErrorIs(t, svc.reduceOrCancelSubscription(context.Background(), 11, 13, 1, "deduct"), repo.err) + require.Equal(t, sub, repo.current) +} From 0c0df031c5e43639625aff53c2d98056b1796f26 Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:03:06 +0800 Subject: [PATCH 099/142] fix(subscriptions): preserve partial days during redemption deductions --- ...em_reduction_remainder_integration_test.go | 51 +++++++++++++++++++ backend/internal/service/redeem_service.go | 10 ++-- ...m_subscription_reduction_remainder_test.go | 44 ++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 backend/internal/repository/redeem_reduction_remainder_integration_test.go create mode 100644 backend/internal/service/redeem_subscription_reduction_remainder_test.go diff --git a/backend/internal/repository/redeem_reduction_remainder_integration_test.go b/backend/internal/repository/redeem_reduction_remainder_integration_test.go new file mode 100644 index 000000000000..0bb110807afc --- /dev/null +++ b/backend/internal/repository/redeem_reduction_remainder_integration_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package repository + +import ( + "context" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestRedeemReductionPreservesPartialDay(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + user, err := client.User.Create().SetEmail("reduction-remainder@example.com").SetPasswordHash("test").Save(ctx) + require.NoError(t, err) + group, err := client.Group.Create().SetName("reduction-remainder").Save(ctx) + require.NoError(t, err) + // Redemption commits its own transaction; remove fixtures explicitly so + // repository list/count tests cannot observe this test's subscriptions. + t.Cleanup(func() { + _, err := integrationDB.ExecContext(context.Background(), `DELETE FROM redeem_codes WHERE group_id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM user_subscriptions WHERE user_id=$1`, user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM groups WHERE id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM users WHERE id=$1`, user.ID) + require.NoError(t, err) + }) + now := time.Now().UTC().Truncate(time.Microsecond) + expiry := now.Add(36 * time.Hour) + sub, err := client.UserSubscription.Create().SetUserID(user.ID).SetGroupID(group.ID).SetStartsAt(now.Add(-time.Hour)).SetExpiresAt(expiry).SetStatus(service.SubscriptionStatusActive).Save(ctx) + require.NoError(t, err) + repo := NewUserSubscriptionRepository(client) + codes := NewRedeemCodeRepository(client) + code := &service.RedeemCode{Code: "REDUCE-ONE-DAY", Type: service.RedeemTypeSubscription, Status: service.StatusUnused, ValidityDays: -1, GroupID: &group.ID} + require.NoError(t, codes.Create(ctx, code)) + subscriptions := service.NewSubscriptionService(nil, repo, nil, client, nil) + redeem := service.NewRedeemService(codes, NewUserRepository(client, integrationDB), subscriptions, nil, nil, client, nil, nil) + _, err = redeem.RedeemForAdminFulfillment(ctx, user.ID, code.Code) + require.NoError(t, err) + got, err := repo.GetByID(ctx, sub.ID) + require.NoError(t, err) + require.Equal(t, service.SubscriptionStatusActive, got.Status) + require.True(t, expiry.AddDate(0, 0, -1).Equal(got.ExpiresAt)) + used, err := codes.GetByID(ctx, code.ID) + require.NoError(t, err) + require.Equal(t, service.StatusUsed, used.Status) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 7b42b04d70da..a6f4e93e82bf 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -703,14 +703,15 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, } now := time.Now() - remaining := int(sub.ExpiresAt.Sub(now).Hours() / 24) - if remaining < 0 { - remaining = 0 + if s.subscriptionService.now != nil { + now = s.subscriptionService.now() } + // Preserve calendar-day semantics without rounding away the remaining hours. + newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays) notes := fmt.Sprintf("通过兑换码 %s 退款扣减 %d 天", code, reduceDays) - if remaining <= reduceDays { + if !newExpiresAt.After(now) { // 剩余天数不足,直接取消订阅 if err := s.subscriptionService.userSubRepo.UpdateStatus(ctx, sub.ID, SubscriptionStatusExpired); err != nil { return fmt.Errorf("cancel subscription: %w", err) @@ -721,7 +722,6 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, } } else { // 缩短天数 - newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays) if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, newExpiresAt); err != nil { return fmt.Errorf("reduce subscription: %w", err) } diff --git a/backend/internal/service/redeem_subscription_reduction_remainder_test.go b/backend/internal/service/redeem_subscription_reduction_remainder_test.go new file mode 100644 index 000000000000..0e59f02fb6e5 --- /dev/null +++ b/backend/internal/service/redeem_subscription_reduction_remainder_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestRedeemReductionPreservesRemainingTime(t *testing.T) { + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + remaining time.Duration + days int + expired bool + }{ + {"partial day", 36 * time.Hour, 1, false}, + {"one second left", 24*time.Hour + time.Second, 1, false}, + {"exact exhaustion", 24 * time.Hour, 1, true}, + {"excess deduction", 12 * time.Hour, 1, true}, + {"already expired", -time.Hour, 1, true}, + {"multiple days", 84 * time.Hour, 2, false}, + } { + t.Run(tc.name, func(t *testing.T) { + sub := UserSubscription{ID: 7, ExpiresAt: now.Add(tc.remaining), Status: SubscriptionStatusActive, Notes: "original"} + repo := &lockingRenewalRepo{stale: sub, current: sub} + subscriptions := NewSubscriptionService(nil, repo, nil, nil, nil) + subscriptions.now = func() time.Time { return now } + svc := &RedeemService{subscriptionService: subscriptions} + require.NoError(t, svc.reduceOrCancelSubscription(context.Background(), 11, 13, tc.days, "deduct")) + want := sub.ExpiresAt.AddDate(0, 0, -tc.days) + status := SubscriptionStatusActive + if tc.expired { + want = now + status = SubscriptionStatusExpired + } + require.Equal(t, want, repo.current.ExpiresAt) + require.Equal(t, status, repo.current.Status) + require.Contains(t, repo.current.Notes, "original") + require.Contains(t, repo.current.Notes, "deduct") + }) + } +} From fa79b1ebc368bd6658c4d70d7ebc2b601671909a Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Tue, 22 Sep 2026 15:51:51 +0800 Subject: [PATCH 100/142] fix(gateway): prevent Cloudflare 1010 from disabling OpenCode and Command Code accounts --- .../internal/service/account_test_service.go | 3 + .../account_test_service_cn_adaptive.go | 3 + .../service/openai_gateway_cc_pipeline.go | 4 + .../service/openai_gateway_forward.go | 4 + ...penai_gateway_messages_anthropic_native.go | 4 + .../service/openai_gateway_passthrough.go | 4 + .../service/openai_opencode_session.go | 48 +++++ .../service/openai_opencode_session_test.go | 182 ++++++++++++++++++ backend/internal/service/ratelimit_service.go | 17 ++ .../ratelimit_service_403_html_test.go | 28 +++ 10 files changed, 297 insertions(+) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b56861ebb6c9..4da9668dd816 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2103,6 +2103,9 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+authToken) + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA,与真实转发路径一致。 + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) + // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index a68a691d27f5..4fe14c380b98 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -84,6 +84,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co // Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic // base_url 强制 Bearer,其余保持 extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) @@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+authToken) applyOpenAICodexProbeHeaders(req.Header) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) @@ -267,6 +269,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 // extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL()) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 103423e8aae9..0fe504a6f80f 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -225,6 +225,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( } applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity) } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, upstreamReq.Header) + // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7f899d2c346e..036754037fc2 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1510,6 +1510,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("content-type", "application/json") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey)) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 263eceba0434..1634a556fc20 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -208,6 +208,10 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) payloads := append([][]byte{body}, sessionBodies...) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index f421a3f6bd54..5380e9fc6df9 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -719,6 +719,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("content-type", "application/json") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body) diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go index f5aaca641f08..3e3f5faea31a 100644 --- a/backend/internal/service/openai_opencode_session.go +++ b/backend/internal/service/openai_opencode_session.go @@ -14,6 +14,14 @@ import ( const ( openCodeSessionHeader = "X-OpenCode-Session" openCodeInboundBodyContextKey = "opencode_inbound_body" + + // openCodeUpstreamUserAgent 是发往官方 OpenCode 上游的规范 User-Agent。 + // opencode.ai 前置 Cloudflare 按客户端 UA 签名做 bot 拦截:Python-urllib 等 + // 编程库 UA 会被 CF error code 1010 拒绝并返回 403。网关透传白名单放行 + // user-agent,客户端自报身份原样到达上游会命中 WAF;该 403 再被计入账号 + // 凭证类 strike,健康账号因此被自动禁用。取值沿用真实 opencode 客户端的 + // UA 格式(opencode/)。 + openCodeUpstreamUserAgent = "opencode/1.0.0" ) // rememberOpenCodeInboundBody keeps the client request body so protocol @@ -97,6 +105,46 @@ func isOfficialOpenCodeHost(targetURL string) bool { return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai") } +func isOfficialCommandCodeHost(targetURL string) bool { + parsed, err := url.Parse(targetURL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "api.commandcode.ai") +} + +// applyOpenCodeUpstreamUserAgent 将发往官方 OpenCode / Command Code 上游的出站 +// User-Agent 收敛为规范客户端身份,覆盖客户端透传值与平台默认 UA。判定规则与 +// x-opencode-session 同源:opencode 平台账号,或(任意平台账号)目标为官方主机。 +// 必须先于 Account.ApplyHeaderOverrides 调用——账号 header_overrides 中显式 +// 配置的 user-agent 仍拥有最终决定权。 +func applyOpenCodeUpstreamUserAgent(account *Account, targetURL string, headers http.Header) { + if headers == nil { + return + } + + userAgent := "" + switch { + case isOfficialCommandCodeHost(targetURL): + userAgent = CodexCanonicalUserAgent() + case account != nil && account.IsOpenCodeGo(), isOfficialOpenCodeHost(targetURL): + userAgent = openCodeUpstreamUserAgent + default: + return + } + if userAgent == "" { + return + } + + // 先删任意大小写变体再写入:透传链路与覆写直写 map 可能残留非 canonical 键。 + for key := range headers { + if strings.EqualFold(key, "User-Agent") { + delete(headers, key) + } + } + headers.Set("User-Agent", userAgent) +} + func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string { if c != nil && c.Request != nil { if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" { diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go index b4e455df7cda..53341f8e26e9 100644 --- a/backend/internal/service/openai_opencode_session_test.go +++ b/backend/internal/service/openai_opencode_session_test.go @@ -307,6 +307,188 @@ func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL stri return u.Do(req, proxyURL, accountID, concurrency) } +func TestApplyOpenCodeUpstreamUserAgent(t *testing.T) { + tests := []struct { + name string + account *Account + targetURL string + seed string + want string + }{ + { + name: "opencode go account canonicalizes passthrough UA", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/chat/completions", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "opencode go account canonicalizes on custom relay too", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://relay.example.com/v1/chat/completions", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "non-opencode account targeting official host", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/responses", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "official command code host uses canonical Codex UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: CodexCanonicalUserAgent(), + }, + { + name: "command code lookalike host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.commandcode.ai.evil.example/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "command code subdomain keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://proxy.api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "insecure command code host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "http://api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "non-official account and host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.openai.com/v1/chat/completions", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "lookalike host does not count as official", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai.evil.example/v1/chat/completions", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "missing UA is filled for opencode account", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/messages", + want: openCodeUpstreamUserAgent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + if tt.seed != "" { + headers.Set("User-Agent", tt.seed) + } + applyOpenCodeUpstreamUserAgent(tt.account, tt.targetURL, headers) + require.Equal(t, tt.want, headers.Get("User-Agent")) + }) + } +} + +func TestApplyOpenCodeUpstreamUserAgentDropsNonCanonicalKeyVariants(t *testing.T) { + headers := http.Header{"user-agent": []string{"Python-urllib/3.13"}} + applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + "https://opencode.ai/zen/go/v1/chat/completions", headers) + + count := 0 + for key, values := range headers { + if strings.EqualFold(key, "User-Agent") { + count += len(values) + require.Equal(t, []string{openCodeUpstreamUserAgent}, values) + } + } + require.Equal(t, 1, count) +} + +func TestApplyOpenCodeUpstreamUserAgentNilSafe(t *testing.T) { + applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + "https://opencode.ai/zen/go/v1/chat/completions", nil) +} + +func TestOpenCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) { + // 回归:客户端透传的编程库 UA(Python-urllib)会被 opencode.ai 前置 + // Cloudflare 以 error code 1010 拦截并计入账号 403 strike,导致健康账号 + // 被自动禁用。出站 UA 必须收敛为规范 opencode 客户端身份。 + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 1, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, openCodeUpstreamUserAgent, req.Header.Get("User-Agent")) +} + +func TestCommandCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://api.commandcode.ai/provider/v1", + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, CodexCanonicalUserAgent(), req.Header.Get("User-Agent")) +} + +func TestOpenCodeUpstreamUserAgentYieldsToExplicitAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 1, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"user-agent": "custom-relay-agent/2.0"}, + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, "custom-relay-agent/2.0", req.Header.Get("User-Agent")) +} + func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &openCodeSessionHTTPUpstream{} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 6fc119f7bab8..5c80de5a6f8b 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1039,6 +1039,15 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account ) return false } + if isCloudflareBotBlockResponse(responseBody) { + slog.Warn( + "openai_403_cloudflare_bot_block_skips_account_penalty", + "account_id", account.ID, + "platform", account.Platform, + "upstream_message", upstreamMsg, + ) + return false + } msg := buildForbiddenErrorMessage( "Access forbidden (403):", @@ -1084,6 +1093,14 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account return true } +// isCloudflareBotBlockResponse reports Cloudflare's WAF bot-signature response +// (error code 1010). The upstream never reached the account API, so this is a +// request/edge-level failure and must not consume the account 403 strike budget. +func isCloudflareBotBlockResponse(body []byte) bool { + normalized := strings.ToLower(strings.TrimSpace(string(body))) + return strings.Contains(normalized, "error code: 1010") +} + // handleAntigravity403 处理 Antigravity 平台的 403 错误 // validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复) // violation(违规封号)→ 永久 SetError(需人工处理) diff --git a/backend/internal/service/ratelimit_service_403_html_test.go b/backend/internal/service/ratelimit_service_403_html_test.go index 5c8f7825981a..8768b41fea61 100644 --- a/backend/internal/service/ratelimit_service_403_html_test.go +++ b/backend/internal/service/ratelimit_service_403_html_test.go @@ -68,6 +68,8 @@ func (h *openAI403TestHarness) requireNoAccountPenalty(t *testing.T) { const openAI403HTMLBody = "\n403 Forbidden" + "

403 Forbidden

" +const openAI403Cloudflare1010Body = "error code: 1010\n" + func TestHandleUpstreamError_OpenAIHTML403DoesNotPenalizeAccount(t *testing.T) { cases := []struct { name string @@ -102,6 +104,32 @@ func TestHandleUpstreamError_OpenAIHTML403RepeatedNeverEscalates(t *testing.T) { h.requireNoAccountPenalty(t) } +func TestHandleUpstreamError_OpenAICloudflare1010DoesNotPenalizeAccount(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformOpenCodeGo} { + t.Run(platform, func(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &countingOpenAI403CounterCache{ + openAI403CounterCacheStub: openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}, + } + blocker := &runtimeBlockRecorder{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc.SetOpenAI403CounterCache(counter) + svc.SetAccountRuntimeBlocker(blocker) + account := &Account{ID: 601, Platform: platform, Type: AccountTypeAPIKey} + + shouldDisable := svc.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, []byte(openAI403Cloudflare1010Body), + ) + + require.False(t, shouldDisable, "Cloudflare 1010 不得判定账号应下线") + require.Equal(t, 0, counter.increments, "Cloudflare 1010 不得递增账号 403 计数") + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) + require.Empty(t, blocker.accounts) + }) + } +} + // 对照不变式:真正的结构化 JSON 403 是账号级证据,处罚链路必须原样保留。 // 缺了这组断言,上面的跳过逻辑一旦写宽就会把真实的封号 403 也放过去。 func TestHandleUpstreamError_OpenAIStructured403StillPenalizes(t *testing.T) { From 7fbc59eb698ea9d75cc9be9ac0b3b0c6f11127a5 Mon Sep 17 00:00:00 2001 From: spongehah <2635879218@qq.com> Date: Tue, 22 Sep 2026 16:54:36 +0800 Subject: [PATCH 101/142] =?UTF-8?q?fix:=20=E9=80=89=E5=8F=B7=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=88=86=E7=BB=84=E6=97=B6=E4=B8=8D=E5=86=8D=E8=81=9A?= =?UTF-8?q?=E5=90=88=E8=B4=A6=E5=8F=B7=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 推理选号只需要隐私开关和分组名,改为 GetByIDLite - 避免 GetByID 附带的账号计数聚合打满主库 - 管理列表的实时账号数统计保持不变 --- .../internal/service/gateway_multiplatform_test.go | 12 ++++++------ backend/internal/service/gateway_scheduling.go | 8 ++++---- backend/internal/service/openai_account_scheduler.go | 7 ++++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 6568b3f6b434..badb2427cfa2 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -3361,8 +3361,8 @@ func TestGatewayService_GroupResolution_ReusesContextGroup(t *testing.T) { account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) require.NoError(t, err) require.NotNil(t, account) - require.Equal(t, 1, groupRepo.getByIDCalls) // +1 for require_privacy_set check - require.Equal(t, 0, groupRepo.getByIDLiteCalls) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 1, groupRepo.getByIDLiteCalls) // require_privacy_set,不走账号计数聚合 } func TestGatewayService_GroupResolution_IgnoresInvalidContextGroup(t *testing.T) { @@ -3404,8 +3404,8 @@ func TestGatewayService_GroupResolution_IgnoresInvalidContextGroup(t *testing.T) account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) require.NoError(t, err) require.NotNil(t, account) - require.Equal(t, 1, groupRepo.getByIDCalls) // +1 for require_privacy_set check - require.Equal(t, 1, groupRepo.getByIDLiteCalls) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 2, groupRepo.getByIDLiteCalls) // 分组解析 + require_privacy_set } func TestGatewayService_GroupContext_OverwritesInvalidContextGroup(t *testing.T) { @@ -3474,8 +3474,8 @@ func TestGatewayService_GroupResolution_FallbackUsesLiteOnce(t *testing.T) { account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) require.NoError(t, err) require.NotNil(t, account) - require.Equal(t, 1, groupRepo.getByIDCalls) // +1 for require_privacy_set check - require.Equal(t, 1, groupRepo.getByIDLiteCalls) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 2, groupRepo.getByIDLiteCalls) // fallback 解析 + require_privacy_set } func TestGatewayService_ResolveGatewayGroup_DetectsFallbackCycle(t *testing.T) { diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index f5dd160c79df..059d7e2b2cb5 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -1893,10 +1893,10 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, preferOAuth := platform == PlatformGemini routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, platform) - // require_privacy_set: 获取分组信息 + // require_privacy_set: 获取分组配置。GetByID 会聚合账号计数,旧选号路径不能用它。 var schedGroup *Group if groupID != nil && s.groupRepo != nil { - schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID) + schedGroup, _ = s.groupRepo.GetByIDLite(ctx, *groupID) } var accounts []Account @@ -2159,10 +2159,10 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g preferOAuth := nativePlatform == PlatformGemini routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, nativePlatform) - // require_privacy_set: 获取分组信息 + // require_privacy_set: 获取分组配置。GetByID 会聚合账号计数,旧选号路径不能用它。 var schedGroup *Group if groupID != nil && s.groupRepo != nil { - schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID) + schedGroup, _ = s.groupRepo.GetByIDLite(ctx, *groupID) } var accounts []Account diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 28e30dfadfa1..3cf56f234e61 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1432,10 +1432,10 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( accounts = modelFiltered } - // require_privacy_set: 获取分组信息 + // require_privacy_set: 获取分组配置。GetByID 会聚合账号计数,选号不能走它。 var schedGroup *Group if req.GroupID != nil && s.service.schedulerSnapshot != nil { - schedGroup, _ = s.service.schedulerSnapshot.GetGroupByID(ctx, *req.GroupID) + schedGroup, _ = s.service.schedulerSnapshot.GetGroupByIDLite(ctx, *req.GroupID) } filterStats := openAISelectionFilterStats{pool: len(accounts)} @@ -2214,7 +2214,8 @@ func (s *OpenAIGatewayService) loadOpenAIGroupRequiresPrivacySet(ctx context.Con if s == nil || groupID == nil || s.schedulerSnapshot == nil { return false } - group, err := s.schedulerSnapshot.GetGroupByID(ctx, *groupID) + // GetByID 会聚合账号计数;隐私开关只在分组行上。 + group, err := s.schedulerSnapshot.GetGroupByIDLite(ctx, *groupID) if err != nil { return true } From 66843cc95cdbc7e39902082e01693613f836d2ad Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 22 Sep 2026 19:07:04 +0800 Subject: [PATCH 102/142] fix(moderation): handle trailing Anthropic system messages --- .../internal/service/content_moderation_input.go | 12 +++++++++--- .../service/content_moderation_input_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 013c10c13de2..3886bfff8eab 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -91,13 +91,19 @@ func (collector moderationTextCollector) collectLastAnthropicUserMessage(message if len(array) == 0 { return } - last := array[len(array)-1] - if strings.ToLower(strings.TrimSpace(last.Get("role").String())) != "user" { + // Anthropic requests may append a system message after the user turn. It + // does not represent a new model/tool turn, so retain the latest user + // content while continuing to reject assistant/tool-ended loops. + lastUser := len(array) - 1 + for lastUser >= 0 && strings.ToLower(strings.TrimSpace(array[lastUser].Get("role").String())) == "system" { + lastUser-- + } + if lastUser < 0 || strings.ToLower(strings.TrimSpace(array[lastUser].Get("role").String())) != "user" { return } var candidate []string var candidateImages []string - collector.collectAnthropicUserContentValue(last.Get("content"), &candidate, &candidateImages) + collector.collectAnthropicUserContentValue(array[lastUser].Get("content"), &candidate, &candidateImages) if normalizeContentModerationText(strings.Join(candidate, "\n")) == "" && len(candidateImages) == 0 { return } diff --git a/backend/internal/service/content_moderation_input_test.go b/backend/internal/service/content_moderation_input_test.go index d51dc21ba7a1..2ba889052490 100644 --- a/backend/internal/service/content_moderation_input_test.go +++ b/backend/internal/service/content_moderation_input_test.go @@ -50,6 +50,21 @@ func TestExtractContentModerationInput_AnthropicMultiTurnExtractsLatestUser(t *t require.Equal(t, "Q2", input.Text) } +func TestExtractContentModerationInput_AnthropicTrailingSystemExtractsLatestUser(t *testing.T) { + body := []byte(`{ + "messages": [ + {"role":"user","content":"Q1"}, + {"role":"assistant","content":"A1"}, + {"role":"user","content":"Q2"}, + {"role":"system","content":"request metadata"} + ] + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body) + + require.Equal(t, "Q2", input.Text) +} + func TestExtractContentModerationInput_AnthropicStreamResendExtractsResend(t *testing.T) { body := []byte(`{ "messages": [ From de28eea11fe0e59d053fb4006beaeccd77348113 Mon Sep 17 00:00:00 2001 From: GongTengXY <1497404080@qq.com> Date: Wed, 9 Sep 2026 11:23:42 +0800 Subject: [PATCH 103/142] fix(openai): filter account test models by model mapping --- .../service/account_test_models_test.go | 31 +++++++++++++++++++ .../internal/service/account_test_service.go | 9 +++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go index 59af5edbd9a6..29cdcf94aa42 100644 --- a/backend/internal/service/account_test_models_test.go +++ b/backend/internal/service/account_test_models_test.go @@ -72,6 +72,37 @@ func TestFetchOpenAIAccountModelsAPIKeyPopulatesPickerFields(t *testing.T) { require.Equal(t, "named-model", models[2].ID) } +func TestFetchOpenAIAccountModelsAPIKeyAppliesAccountModelMapping(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[ + {"id":"upstream-target","owned_by":"provider","created":123}, + {"id":"direct-model","owned_by":"provider"}, + {"id":"unconfigured-model","owned_by":"provider"} + ]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{ + "configured-alias": "upstream-target", + "direct-model": "direct-model", + } + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + require.Len(t, models, 2) + ids := []string{models[0].ID, models[1].ID} + require.ElementsMatch(t, []string{"configured-alias", "direct-model"}, ids) + require.NotContains(t, ids, "unconfigured-model") + alias := models[0] + if alias.ID != "configured-alias" { + alias = models[1] + } + require.Equal(t, "configured-alias", alias.ID) + require.Equal(t, "configured-alias", alias.DisplayName) + require.Equal(t, "provider", alias.OwnedBy) + require.EqualValues(t, 123, alias.Created) +} + func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) { gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { return ordinaryModelsUpstreamResponse(`{"data":[]}`), nil diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b56861ebb6c9..421a9820779f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -188,10 +188,17 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou if err != nil { return nil, err } + // The shared discovery response is the raw upstream catalog. Project it + // through the account mapping before exposing it in the admin picker so + // configured aliases remain public names and unconfigured models stay out. + projectedBody, err := projectAccountModelsBody(response.Body, account, nil, false) + if err != nil { + return nil, fmt.Errorf("project OpenAI account models: %w", err) + } var payload struct { Data []openai.Model `json:"data"` } - if err := json.Unmarshal(response.Body, &payload); err != nil { + if err := json.Unmarshal(projectedBody, &payload); err != nil { return nil, fmt.Errorf("decode OpenAI account models: %w", err) } // Every entry in the picker is labelled by the same rule: the upstream display From a5d8db24451f7def70c3ea2534a9a1c7792a9ec8 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 22 Sep 2026 19:47:12 +0800 Subject: [PATCH 104/142] fix(responses): strip oversized input item IDs --- .../service/openai_responses_item_id.go | 2 +- .../service/openai_responses_item_id_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go index cb1522e4fb8a..5fc8bb77d52a 100644 --- a/backend/internal/service/openai_responses_item_id.go +++ b/backend/internal/service/openai_responses_item_id.go @@ -50,7 +50,7 @@ func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool { if !constrained { return false } - return id == "" || !strings.HasPrefix(id, prefix) + return id == "" || len(id) > 64 || !strings.HasPrefix(id, prefix) } func shouldStripOpenAIResponsesNonPairCallID(itemType string) bool { diff --git a/backend/internal/service/openai_responses_item_id_test.go b/backend/internal/service/openai_responses_item_id_test.go index b300b22a2e45..10fd5dd6e0f9 100644 --- a/backend/internal/service/openai_responses_item_id_test.go +++ b/backend/internal/service/openai_responses_item_id_test.go @@ -2,6 +2,7 @@ package service import ( "strconv" + "strings" "testing" "github.com/stretchr/testify/require" @@ -29,6 +30,9 @@ func TestOpenAIResponsesInputItemIDPrefixUsesObservedOutputContracts(t *testing. {itemType: "tool_search_call", id: "fc_123", strip: true}, {itemType: "web_search_call", id: "ws_123", strip: false}, {itemType: "web_search_call", id: "item_123", strip: true}, + {itemType: "web_search_call", id: "ws_" + strings.Repeat("x", 60), strip: false}, + {itemType: "web_search_call", id: "ws_" + strings.Repeat("x", 61), strip: false}, + {itemType: "web_search_call", id: "ws_" + strings.Repeat("x", 62), strip: true}, {itemType: "custom_tool_call_output", id: "fc_123", strip: false}, {itemType: "custom_tool_call_output", id: "ctco_123", strip: true}, // Do not impose an inferred contract on output types for which there is @@ -132,6 +136,19 @@ func TestSanitizeOpenAIResponsesInputItemIDsStripsEmptyKnownIDsOnly(t *testing.T require.True(t, gjson.GetBytes(sanitized, "input.1.id").Exists()) } +func TestSanitizeOpenAIResponsesInputItemIDsStripsOversizedWebSearchID(t *testing.T) { + validID := "ws_" + strings.Repeat("x", 61) + oversizedID := "ws_" + strings.Repeat("x", 62) + body := []byte(`{"input":[{"type":"web_search_call","id":"` + validID + `"},{"type":"web_search_call","id":"` + oversizedID + `"}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, validID, gjson.GetBytes(sanitized, "input.0.id").String()) + require.False(t, gjson.GetBytes(sanitized, "input.1.id").Exists()) +} + func TestSanitizeOpenAIResponsesInputItemIDsStripsOnlyNonPairCallIDs(t *testing.T) { body := []byte(`{"input":[ {"type":"message","call_id":"remove_message","content":"hi"}, From 1d640c40e5b560d27d0d49c7f555d41767f5b7bc Mon Sep 17 00:00:00 2001 From: lcy Date: Tue, 22 Sep 2026 20:49:24 +0800 Subject: [PATCH 105/142] =?UTF-8?q?fix(tools):=20=E6=B8=85=E6=B4=97?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20Schema=20=E9=87=8C=E9=9D=9E=E6=B3=95?= =?UTF-8?q?=E7=9A=84=20null=20required=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Schema 规定 required 必须是属性名数组,但部分客户端会发成 null, 严格校验的上游直接拒绝整个请求: xAI 400 /required: null is not of type "array" Moonshot 400 At path 'required': required must be an array 同样的模型发纯文本请求一切正常,只有带 tools 时才失败,因此表现得像模型 或渠道故障。 三处改动: 1. 清洗 required:null 时整键删除,而不是替换成 []。JSON Schema 里缺省 required 等价于无必填项,删除保留了客户端原意,置 [] 反而凭空造出一条 客户端从未声明的约束。 2. 把 input_schema 也识别为 schema 根。此前只认 OpenAI Responses 的 parameters 拼法,Anthropic Messages 的工具定义因此绕过整套清洗。 3. 在 ForwardAsAnthropic 入口调用清洗。/v1/messages 此前不做任何工具 Schema 处理;该函数会分流到原生 Anthropic 直通、Chat Completions 与 Responses 三条路径,每条都原样透传 tools,所以必须在分流之前处理。 另外把 contentSchema 补进子 schema 关键字:它承载嵌套 schema,其中的 null required 此前会漏网。 配套测试同时锁定反向保证:default / examples / const / enum 里装的是实例 数据,字面量 {"required": null} 属于客户端 payload,不能被改写。现有的 关键字白名单下降本就如此,测试用于防止它退化成无差别遍历。 --- .../service/openai_gateway_messages.go | 9 ++ .../service/openai_responses_tool_schema.go | 17 ++- .../openai_responses_tool_schema_test.go | 139 ++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index cbc5de9d288a..79de86b45b4c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -33,6 +33,15 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + // 工具 Schema 清洗必须先于所有分流:下游每条路径(原生 Anthropic 直通、 + // Chat Completions 转换、Responses 转换)都会把 tools 原样带给上游,而 + // xAI / Moonshot 等严格校验方会因 input_schema 里的 required:null 或 + // type:null 直接 400。 + if sanitized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, account.Platform); err != nil { + return nil, err + } else if changed { + body = sanitized + } rememberOpenCodeInboundBody(c, body) beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go index a53eefb982d9..f07e47dd48d8 100644 --- a/backend/internal/service/openai_responses_tool_schema.go +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -88,6 +88,7 @@ func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{ replaceNullParameterTypes: true, injectObjectUnionRootObjectType: true, + dropNullRequired: true, }) } @@ -107,6 +108,7 @@ type openAIResponsesToolSchemaOptions struct { removeLookaroundPatterns bool replaceNullParameterTypes bool injectObjectUnionRootObjectType bool + dropNullRequired bool } type openAIResponsesToolSchemaContext uint8 @@ -277,6 +279,15 @@ func (p *openAIResponsesToolSchemaParser) parseObject( deleteMember = true } } + // JSON Schema requires "required" to be an array. Clients occasionally emit + // null, which xAI and Moonshot reject outright with a 400. An absent + // "required" is the semantic equivalent of an empty one, so dropping the + // member preserves the client's intent while satisfying strict validators. + if context == openAIResponsesToolSchema && p.options.dropNullRequired && + openAIResponsesJSONStringEquals(key, "required") && + bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) { + deleteMember = true + } if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") { // Duplicate JSON keys have parser-dependent effective values. Repair a // root type only when it is unique rather than choosing a winner and @@ -455,7 +466,9 @@ func openAIResponsesToolSchemaChildContext( } case openAIResponsesToolSchemaTool: switch { - case openAIResponsesJSONStringEquals(key, "parameters"): + // Anthropic Messages carries the same JSON Schema under input_schema, so + // /v1/messages tools must reach the sanitizer too. + case openAIResponsesJSONStringMatchesAny(key, "parameters", "input_schema"): return openAIResponsesToolSchema, true case openAIResponsesJSONStringEquals(key, "function"): return openAIResponsesToolSchemaFunction, false @@ -477,7 +490,7 @@ func openAIResponsesToolSchemaChildContext( switch { case openAIResponsesJSONStringMatchesAny(key, "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else", - "propertyNames", "unevaluatedProperties", "unevaluatedItems"): + "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema"): return openAIResponsesToolSchema, false case openAIResponsesJSONStringEquals(key, "items"): return openAIResponsesToolSchemaOrArray, false diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index 938ab59ab75d..7b1714e30d08 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -652,3 +652,142 @@ func BenchmarkSanitizeOpenAIResponsesToolParameterTypes_ByteSpanPatch(b *testing _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(body) } } + +// 回归锁:客户端把 "required" 发成 null 时,xAI 返回 +// `/required: null is not of type "array"`,Moonshot 返回 +// `parameters is not a valid moonshot flavored json schema`,均为 400。 +func TestSanitizeOpenAIResponsesToolParameterTypes_DropsNullRequired(t *testing.T) { + body := []byte(`{ + "model": "grok-4.7", + "tools": [ + { + "type": "function", + "name": "read_file", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": null + } + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + // null 的 required 被整键删除:JSON Schema 里缺省 required 等价于无必填项。 + require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.required").Exists()) + // 其余定义原样保留。 + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String()) + require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.parameters.properties.path.type").String()) + require.Equal(t, "read_file", gjson.GetBytes(sanitized, "tools.0.name").String()) + require.Equal(t, "grok-4.7", gjson.GetBytes(sanitized, "model").String()) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_KeepsValidRequired(t *testing.T) { + body := []byte(`{ + "tools": [ + { + "type": "function", + "name": "read_file", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + } + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, []string{"path"}, func() []string { + out := []string{} + for _, v := range gjson.GetBytes(sanitized, "tools.0.parameters.required").Array() { + out = append(out, v.String()) + } + return out + }()) +} + +// Grok 与国产供应商必须走到同一套清理,否则裸 null required 会直达上游。 +func TestSanitizeOpenAIResponsesToolSchemasForPlatform_DropsNullRequired(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"f","parameters":{"type":"object","required":null}}]}`) + + for _, platform := range []string{PlatformGrok, PlatformOpenAI, PlatformAnthropic, PlatformKimi} { + t.Run(platform, func(t *testing.T) { + sanitized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.required").Exists()) + }) + } +} + +// 回归锁:/v1/messages 的工具 schema 在 input_schema 下,早前只认 parameters, +// 导致 Anthropic 协议的工具请求绕过清理,裸 null 直达上游触发 400。 +func TestSanitizeOpenAIResponsesToolParameterTypes_AnthropicInputSchema(t *testing.T) { + body := []byte(`{ + "model": "grok-4.7", + "tools": [ + { + "name": "read_file", + "description": "read", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": null + } + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(sanitized, "tools.0.input_schema.required").Exists()) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.input_schema.type").String()) + require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.input_schema.properties.path.type").String()) + require.Equal(t, "read_file", gjson.GetBytes(sanitized, "tools.0.name").String()) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_AnthropicInputSchemaNullType(t *testing.T) { + body := []byte(`{"tools":[{"name":"f","input_schema":{"type":null,"properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.input_schema.type").String()) +} + +// contentSchema 同样承载子 schema,其中的 null required 必须一并修掉。 +func TestSanitizeOpenAIResponsesToolParameterTypes_ContentSchema(t *testing.T) { + body := []byte(`{"tools":[{"name":"f","input_schema":{"type":"object","contentSchema":{"required":null}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(sanitized, "tools.0.input_schema.contentSchema.required").Exists()) +} + +// default / examples / const / enum 里装的是实例数据,其中字面量 {"required": null} +// 是客户端 payload,不能当成 schema 修改。 +func TestSanitizeOpenAIResponsesToolParameterTypes_PreservesInstanceData(t *testing.T) { + body := []byte(`{"tools":[{"name":"f","input_schema":{"type":"object","properties":{"cfg":{"type":"object","default":{"required":null},"examples":[{"required":null}],"const":{"required":null},"enum":[{"required":null}]}}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + base := "tools.0.input_schema.properties.cfg" + require.True(t, gjson.GetBytes(sanitized, base+".default.required").Exists()) + require.True(t, gjson.GetBytes(sanitized, base+".examples.0.required").Exists()) + require.True(t, gjson.GetBytes(sanitized, base+".const.required").Exists()) + require.True(t, gjson.GetBytes(sanitized, base+".enum.0.required").Exists()) +} From 4daea093f9dc2cf4a0992d16da5491ec162657f1 Mon Sep 17 00:00:00 2001 From: Thinker-Joe <94465898+Thinker-Joe@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:15:20 +0800 Subject: [PATCH 106/142] fix(openai): keep OAuth image aliases in the test picker Image choices were resolved from DefaultModels IDs and from mapping keys, so an alias whose target is a local image model (paint -> gpt-image-2.5-flare) never reached the picker: IsModelSupported only matches keys, and a public name is not an image model. Resolve the public name to its target before deciding, and only synthesize an entry when the target is an image model. Update the fixture of TestFetchOpenAIAccountModelsOAuthLabelsLocalImageModelsLikeUpstream: it asserted on an upstream slug the account did not configure, which conflicts with the account-scoped projection. Coverage is preserved by configuring that slug and by a new test asserting unconfigured text models stay out. --- .../service/account_test_models_test.go | 57 ++++++++++++++++++- .../internal/service/account_test_service.go | 17 +++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go index 29cdcf94aa42..3104b4e4301e 100644 --- a/backend/internal/service/account_test_models_test.go +++ b/backend/internal/service/account_test_models_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" ) @@ -117,7 +118,13 @@ func TestFetchOpenAIAccountModelsOAuthLabelsLocalImageModelsLikeUpstream(t *test newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.6-sol"}]}`) svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} account := newCodexModelsTestAccount() - account.Credentials["model_mapping"] = map[string]any{"gpt-image-2.5-flare": "gpt-image-2.5-flare"} + // This case is about label sources, so it must configure the upstream slug it + // asserts on: the picker is scoped to the account mapping, and an unconfigured + // slug is intentionally absent (see the exclusion test below). + account.Credentials["model_mapping"] = map[string]any{ + "gpt-5.6-sol": "gpt-5.6-sol", + "gpt-image-2.5-flare": "gpt-image-2.5-flare", + } models, err := svc.FetchOpenAIAccountModels(context.Background(), account) require.NoError(t, err) byID := make(map[string]string, len(models)) @@ -142,3 +149,51 @@ func TestFetchOpenAIAccountModelsOAuthRespectsImageAllowlist(t *testing.T) { require.Contains(t, ids, "gpt-image-2.5-flare") require.NotContains(t, ids, "gpt-image-2.5-sunburst") } + +func pickerModelIDs(models []openai.Model) []string { + ids := make([]string, 0, len(models)) + for _, model := range models { + ids = append(ids, model.ID) + } + return ids +} + +// The picker is scoped to the account mapping: an upstream slug the account does +// not configure must not become a testable choice. +func TestFetchOpenAIAccountModelsOAuthExcludesUnconfiguredTextModels(t *testing.T) { + newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-6-astra"}]}`) + svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} + account := newCodexModelsTestAccount() + account.Credentials["model_mapping"] = map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"} + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.6-sol"}, pickerModelIDs(models)) +} + +// An alias may point at a local image model that Codex discovery never lists, so +// the picker has to resolve the target before deciding to offer the public name. +func TestFetchOpenAIAccountModelsOAuthLocalImageAlias(t *testing.T) { + newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-6-astra"}]}`) + svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} + account := newCodexModelsTestAccount() + account.Credentials["model_mapping"] = map[string]any{"paint": "gpt-image-2.5-flare"} + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"paint"}, pickerModelIDs(models)) + require.Equal(t, "paint", models[0].DisplayName) +} + +// A public name that merely looks like an image model must not be enough to +// synthesize a picker entry when its target is a text model. +func TestFetchOpenAIAccountModelsOAuthImageLookalikeAliasIsNotSynthesized(t *testing.T) { + newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.6-sol"}]}`) + svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} + account := newCodexModelsTestAccount() + account.Credentials["model_mapping"] = map[string]any{"gpt-image-2.5-lookalike": "text-target-missing"} + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + require.Empty(t, pickerModelIDs(models)) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 421a9820779f..c2540f5b10a1 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -228,10 +228,21 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou seen[model.ID] = true } } - for model := range account.GetModelMapping() { - if IsGPTImageGenerationModel(model) && !strings.Contains(model, "*") && !seen[model] { - payload.Data = append(payload.Data, openai.Model{ID: model, Object: "model", Type: "model", OwnedBy: "openai", DisplayName: openaiCodexDisplayName(model)}) + // Image models that a configured alias points at are absent from the Codex + // manifest, so the projection alone cannot surface them. Resolve each public + // name to its target and keep the entry when that target is an image model. + // Judging by the target rather than the public name keeps a lookalike name + // (for example an alias spelled "gpt-image-*" that maps to a text model) + // from being synthesized into the picker. + for publicID := range account.GetModelMapping() { + if strings.Contains(publicID, "*") || seen[publicID] { + continue + } + if !IsGPTImageGenerationModel(account.GetMappedModel(publicID)) { + continue } + payload.Data = append(payload.Data, openai.Model{ID: publicID, Object: "model", Type: "model", OwnedBy: "openai", DisplayName: openaiCodexDisplayName(publicID)}) + seen[publicID] = true } } return payload.Data, nil From 9e4010695b880803c635db5d19ad445164b92787 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:36:42 +0800 Subject: [PATCH 107/142] fix(channel-monitor): ignore stale detail requests --- .../components/user/MonitorDetailDialog.vue | 11 +++-- .../__tests__/MonitorDetailDialog.spec.ts | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/user/__tests__/MonitorDetailDialog.spec.ts diff --git a/frontend/src/components/user/MonitorDetailDialog.vue b/frontend/src/components/user/MonitorDetailDialog.vue index 14f52bbbf327..1ba999198f99 100644 --- a/frontend/src/components/user/MonitorDetailDialog.vue +++ b/frontend/src/components/user/MonitorDetailDialog.vue @@ -87,22 +87,27 @@ const { statusLabel, statusBadgeClass, formatLatency, formatPercent, formatMonit const detail = ref(null) const loading = ref(false) +let requestVersion = 0 async function load(id: number) { + const version = ++requestVersion detail.value = null loading.value = true try { - detail.value = await fetchChannelMonitorDetail(id) + const result = await fetchChannelMonitorDetail(id) + if (version === requestVersion) detail.value = result } catch (err: unknown) { + if (version !== requestVersion) return appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError'))) } finally { - loading.value = false + if (version === requestVersion) loading.value = false } } watch( () => [props.show, props.monitorId] as const, - ([show, id]) => { + ([show, id], _, onCleanup) => { + onCleanup(() => { requestVersion++ }) if (!show) { detail.value = null return diff --git a/frontend/src/components/user/__tests__/MonitorDetailDialog.spec.ts b/frontend/src/components/user/__tests__/MonitorDetailDialog.spec.ts new file mode 100644 index 000000000000..0f5b0843e1e8 --- /dev/null +++ b/frontend/src/components/user/__tests__/MonitorDetailDialog.spec.ts @@ -0,0 +1,43 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import MonitorDetailDialog from '../MonitorDetailDialog.vue' +const mocks = vi.hoisted(() => ({ status: vi.fn(), showError: vi.fn() })) +vi.mock('@/api/channelMonitor', () => ({ status: mocks.status })) +vi.mock('@/stores/app', () => ({ useAppStore: () => mocks })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => vi.clearAllMocks()) +function deferred() { + let resolve!: (value: unknown) => void + let reject!: (value: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +const detail = (model: string) => ({ models: [{ model, latest_status: 'operational' }] }) +function open() { + return mount(MonitorDetailDialog, { props: { show: true, monitorId: 1, title: 'Monitor' }, + global: { stubs: { BaseDialog: { props: ['show'], template: '
' } } } }) +} +describe('monitor detail request ownership', () => { + it('keeps the new monitor response when the old response arrives last', async () => { + const old = deferred() + mocks.status.mockReturnValueOnce(old.promise).mockResolvedValueOnce(detail('new-model')) + const w = open(); await w.setProps({ monitorId: 2 }); await flushPromises() + old.resolve(detail('old-model')); await flushPromises() + expect(w.text()).toContain('new-model'); expect(w.text()).not.toContain('old-model') + }) + it('ignores a previous failure while the current monitor is loading', async () => { + const old = deferred(); const current = deferred() + mocks.status.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const w = open(); await w.setProps({ show: false }); await w.setProps({ show: true }) + old.reject(new Error('old failure')); await flushPromises() + expect(mocks.showError).not.toHaveBeenCalled(); expect(w.text()).toContain('common.loading') + current.resolve(detail('current')); await flushPromises(); expect(w.text()).toContain('current') + }) + it('reports a failure for the current monitor', async () => { + mocks.status.mockRejectedValueOnce(new Error('current failure')) + const w = open(); await flushPromises() + expect(mocks.showError).toHaveBeenCalledWith('current failure') + expect(w.text()).toContain('channelStatus.detailLoadError') + }) +}) From 5c38d372c128fd3e4774c27e7bacb3a9bbca736b Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:37:01 +0800 Subject: [PATCH 108/142] fix(accounts): keep temporary unschedulable status scoped to the current account --- .../account/TempUnschedStatusModal.vue | 12 +++-- .../__tests__/TempUnschedStatusModal.spec.ts | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/account/__tests__/TempUnschedStatusModal.spec.ts diff --git a/frontend/src/components/account/TempUnschedStatusModal.vue b/frontend/src/components/account/TempUnschedStatusModal.vue index bc1ed1685c85..7670dcecbe3c 100644 --- a/frontend/src/components/account/TempUnschedStatusModal.vue +++ b/frontend/src/components/account/TempUnschedStatusModal.vue @@ -175,6 +175,7 @@ const appStore = useAppStore() const loading = ref(false) const resetting = ref(false) const status = ref(null) +let requestVersion = 0 const state = computed(() => status.value?.state || null) @@ -236,14 +237,18 @@ const remainingText = computed(() => { const loadStatus = async () => { if (!props.account) return + const version = ++requestVersion + status.value = null loading.value = true try { - status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id) + const result = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id) + if (version === requestVersion) status.value = result } catch (error: any) { + if (version !== requestVersion) return appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.failedToLoad')) status.value = null } finally { - loading.value = false + if (version === requestVersion) loading.value = false } } @@ -268,7 +273,8 @@ const handleReset = async () => { watch( () => [props.show, props.account?.id], - ([visible]) => { + ([visible], _, onCleanup) => { + onCleanup(() => { requestVersion++ }) if (visible && props.account) { loadStatus() return diff --git a/frontend/src/components/account/__tests__/TempUnschedStatusModal.spec.ts b/frontend/src/components/account/__tests__/TempUnschedStatusModal.spec.ts new file mode 100644 index 000000000000..069d7c62f14b --- /dev/null +++ b/frontend/src/components/account/__tests__/TempUnschedStatusModal.spec.ts @@ -0,0 +1,50 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import TempUnschedStatusModal from '../TempUnschedStatusModal.vue' +import type { Account } from '@/types' +const mocks = vi.hoisted(() => ({ getTempUnschedulableStatus: vi.fn(), recoverState: vi.fn(), showError: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { accounts: mocks } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => mocks })) +vi.mock('@/utils/format', () => ({ formatDateTime: () => 'date' })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => vi.clearAllMocks()) +function deferred() { + let resolve!: (value: unknown) => void + let reject!: (value: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +const active = (message: string) => ({ active: true, state: { until_unix: Date.now() / 1000 + 3600, error_message: message, rule_index: -1 } }) +async function open() { + const w = mount(TempUnschedStatusModal, { props: { show: false, account: { id: 1, name: 'first' } as Account }, + global: { stubs: { BaseDialog: { props: ['show'], template: '
' } } } }) + await w.setProps({ show: true }); return w +} +describe('temporary unschedulable status requests', () => { + it('does not let a late response replace the next account status', async () => { + const old = deferred() + mocks.getTempUnschedulableStatus.mockReturnValueOnce(old.promise).mockResolvedValueOnce(active('current-error')) + const w = await open(); await w.setProps({ account: { id: 2, name: 'second' } as Account }); await flushPromises() + old.resolve(active('old-error')); await flushPromises() + expect(w.text()).toContain('current-error'); expect(w.text()).not.toContain('old-error') + }) + it('keeps recovery disabled while loading a different account', async () => { + const current = deferred() + mocks.getTempUnschedulableStatus.mockResolvedValueOnce(active('old-error')).mockReturnValueOnce(current.promise) + const w = await open(); await flushPromises() + await w.setProps({ account: { id: 2 } as Account }) + expect(w.get('button.btn-primary').attributes('disabled')).toBeDefined() + current.resolve(active('current-error')); await flushPromises() + expect(w.get('button.btn-primary').attributes('disabled')).toBeUndefined() + }) + it('ignores old errors without dismissing the current loading state', async () => { + const old = deferred(); const current = deferred() + mocks.getTempUnschedulableStatus.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const w = await open(); await w.setProps({ show: false }); await w.setProps({ show: true }) + old.reject(new Error('obsolete')); await flushPromises() + expect(mocks.showError).not.toHaveBeenCalled(); expect(w.find('.animate-spin').exists()).toBe(true) + current.resolve({ active: false }); await flushPromises() + expect(w.text()).toContain('admin.accounts.tempUnschedulable.notActive') + }) +}) From 4d667f1b0828d7bdf063206c0284fa2191202d82 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:39:54 +0800 Subject: [PATCH 109/142] fix(users): show normalized API errors in the balance dialog --- .../admin/user/UserBalanceModal.vue | 3 ++- .../user/__tests__/UserBalanceModal.spec.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/user/__tests__/UserBalanceModal.spec.ts diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue index e8474cff0219..f0070b0340f3 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -29,6 +29,7 @@ import { reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import { extractApiErrorMessage } from '@/utils/apiError' import type { AdminUser } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -80,7 +81,7 @@ const handleBalanceSubmit = async () => { appStore.showSuccess(t('common.success')); emit('success'); emit('close') } catch (e: any) { console.error('Failed to update balance:', e) - appStore.showError(e.response?.data?.detail || t('common.error')) + appStore.showError(extractApiErrorMessage(e, t('common.error'))) } finally { submitting.value = false } } diff --git a/frontend/src/components/admin/user/__tests__/UserBalanceModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserBalanceModal.spec.ts new file mode 100644 index 000000000000..068bc2424dee --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/UserBalanceModal.spec.ts @@ -0,0 +1,27 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import UserBalanceModal from '../UserBalanceModal.vue' +import type { AdminUser } from '@/types' +const mocks = vi.hoisted(() => ({ updateBalance: vi.fn(), showError: vi.fn(), showSuccess: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { users: mocks } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => mocks })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => vi.restoreAllMocks()) +describe('balance operation error feedback', () => { + it.each([ + [{ message: 'User balance changed; retry' }, 'User balance changed; retry'], + [{ response: { data: { detail: 'Legacy detail' } } }, 'Legacy detail'], + [{}, 'common.error'], + ])('shows the available API error and keeps the dialog open', async (error, message) => { + mocks.updateBalance.mockRejectedValueOnce(error) + const w = mount(UserBalanceModal, { props: { show: true, operation: 'add', user: { id: 1, email: 'a@example.com', balance: 5 } as AdminUser }, + global: { stubs: { BaseDialog: { template: '
' } } } }) + await w.get('input[type="number"]').setValue('1') + await w.get('form').trigger('submit'); await flushPromises() + expect(mocks.showError).toHaveBeenCalledWith(message) + expect(w.emitted('success')).toBeUndefined(); expect(w.emitted('close')).toBeUndefined() + expect(w.get('button[type="submit"]').attributes('disabled')).toBeUndefined() + }) +}) From 7ba6efe8058cdaba22b126fe58e2703fb837c6da Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:40:14 +0800 Subject: [PATCH 110/142] fix(subscriptions): use calendar dates for expiry labels --- .../common/SubscriptionProgressMini.vue | 8 ++++--- .../SubscriptionProgressMini.spec.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/__tests__/SubscriptionProgressMini.spec.ts diff --git a/frontend/src/components/common/SubscriptionProgressMini.vue b/frontend/src/components/common/SubscriptionProgressMini.vue index c915b1dffa4b..bc55c1641172 100644 --- a/frontend/src/components/common/SubscriptionProgressMini.vue +++ b/frontend/src/components/common/SubscriptionProgressMini.vue @@ -184,6 +184,7 @@ import Icon from '@/components/icons/Icon.vue' import { useSubscriptionStore } from '@/stores' import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags' import type { UserSubscription } from '@/types' +import { getExpirationDateRelation } from '@/utils/subscriptionQuota' const { t } = useI18n() @@ -264,10 +265,11 @@ function formatDaysRemaining(expiresAt: string): string { const now = new Date() const expires = new Date(expiresAt) const diff = expires.getTime() - now.getTime() - if (diff < 0) return t('subscriptionProgress.expired') + const relation = getExpirationDateRelation(expires, now) + if (relation === 'expired') return t('subscriptionProgress.expired') + if (relation === 'today') return t('subscriptionProgress.expiresToday') + if (relation === 'tomorrow') return t('subscriptionProgress.expiresTomorrow') const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) - if (days === 0) return t('subscriptionProgress.expiresToday') - if (days === 1) return t('subscriptionProgress.expiresTomorrow') return t('subscriptionProgress.daysRemaining', { days }) } diff --git a/frontend/src/components/common/__tests__/SubscriptionProgressMini.spec.ts b/frontend/src/components/common/__tests__/SubscriptionProgressMini.spec.ts new file mode 100644 index 000000000000..cf52174c2f60 --- /dev/null +++ b/frontend/src/components/common/__tests__/SubscriptionProgressMini.spec.ts @@ -0,0 +1,23 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SubscriptionProgressMini from '../SubscriptionProgressMini.vue' +const store = vi.hoisted(() => ({ activeSubscriptions: [] as unknown[], hasActiveSubscriptions: true, fetchActiveSubscriptions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('@/stores', () => ({ useSubscriptionStore: () => store })) +vi.mock('@/utils/featureFlags', () => ({ FeatureFlags: { subscription: 'subscription' }, isFeatureFlagEnabled: () => true })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 22, 12)) }) +afterEach(() => vi.useRealTimers()) +describe('subscription expiry calendar labels', () => { + it.each([ + [new Date(2026, 8, 22, 18), 'expiresToday'], + [new Date(2026, 8, 23, 18), 'expiresTomorrow'], + [new Date(2026, 8, 22, 12), 'expired'], + [new Date(2026, 8, 25, 12), 'daysRemaining'], + ])('labels %s as %s', async (expires, label) => { + store.activeSubscriptions = [{ id: 1, group_id: 1, expires_at: expires.toISOString(), group: { name: 'Plan' } }] + const w = mount(SubscriptionProgressMini, { global: { stubs: { Icon: true, RouterLink: true } } }) + await w.get('button').trigger('click') + expect(w.text()).toContain('subscriptionProgress.' + label) + }) +}) From 1ca54bcc2505c3e1892bc25fbec3320552cb5437 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:42:54 +0800 Subject: [PATCH 111/142] fix(profile): avoid starting TOTP cooldowns after unmount --- .../components/user/profile/TotpDisableDialog.vue | 3 +++ .../src/components/user/profile/TotpSetupModal.vue | 3 +++ .../profile/__tests__/totp-timer-cleanup.spec.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/frontend/src/components/user/profile/TotpDisableDialog.vue b/frontend/src/components/user/profile/TotpDisableDialog.vue index 97bf2a63dd9c..d46d83d6ce06 100644 --- a/frontend/src/components/user/profile/TotpDisableDialog.vue +++ b/frontend/src/components/user/profile/TotpDisableDialog.vue @@ -101,6 +101,7 @@ const methodLoading = ref(true) const verificationMethod = ref<'email' | 'password'>('password') const loading = ref(false) const sendingCode = ref(false) +let disposed = false const codeCooldown = ref(0) const cooldownTimer = ref | null>(null) const form = ref({ @@ -132,6 +133,7 @@ const handleSendCode = async () => { sendingCode.value = true try { await totpAPI.sendVerifyCode() + if (disposed) return appStore.showSuccess(t('profile.totp.codeSent')) // Start cooldown codeCooldown.value = 60 @@ -180,6 +182,7 @@ onMounted(() => { }) onUnmounted(() => { + disposed = true if (cooldownTimer.value) { clearInterval(cooldownTimer.value) cooldownTimer.value = null diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue index 0bd4d5e51cf3..05217cad8435 100644 --- a/frontend/src/components/user/profile/TotpSetupModal.vue +++ b/frontend/src/components/user/profile/TotpSetupModal.vue @@ -190,6 +190,7 @@ const methodLoading = ref(true) const verificationMethod = ref<'email' | 'password'>('password') const verifyForm = ref({ emailCode: '', password: '' }) const sendingCode = ref(false) +let disposed = false const codeCooldown = ref(0) const cooldownTimer = ref | null>(null) @@ -328,6 +329,7 @@ const handleSendCode = async () => { sendingCode.value = true try { await totpAPI.sendVerifyCode() + if (disposed) return appStore.showSuccess(t('profile.totp.codeSent')) // Start cooldown codeCooldown.value = 60 @@ -397,6 +399,7 @@ onMounted(() => { }) onUnmounted(() => { + disposed = true if (cooldownTimer.value) { clearInterval(cooldownTimer.value) cooldownTimer.value = null diff --git a/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts b/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts index 44d9fcf9b946..b4b2170097e7 100644 --- a/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts +++ b/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts @@ -79,6 +79,18 @@ describe('TOTP 弹窗定时器清理', () => { clearIntervalSpy.mockRestore() }) + it.each([TotpSetupModal, TotpDisableDialog])('does not start a cooldown after unmounting during send', async (component) => { + let resolve!: (value: unknown) => void + mocks.sendVerifyCode.mockReturnValueOnce(new Promise(res => { resolve = res })) + const wrapper = mount(component) + await flushPromises() + await wrapper.findAll('button').find(b => b.text().includes('profile.totp.sendCode'))!.trigger('click') + wrapper.unmount() + resolve({ success: true }); await flushPromises() + expect(setIntervalSpy).not.toHaveBeenCalled() + expect(mocks.showSuccess).not.toHaveBeenCalled() + }) + it('TotpSetupModal 卸载时清理倒计时定时器', async () => { const wrapper = mount(TotpSetupModal) await flushPromises() From a1eaad60331d109d9cf3f5f83bf34ea8260981c4 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:45:48 +0800 Subject: [PATCH 112/142] fix(channels): preserve model tags during IME composition --- .../admin/channel/ModelTagInput.vue | 15 ++++++++--- .../__tests__/ModelTagInput.keyboard.spec.ts | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/admin/channel/ModelTagInput.vue b/frontend/src/components/admin/channel/ModelTagInput.vue index a268ae340af5..b8c7e8758e3a 100644 --- a/frontend/src/components/admin/channel/ModelTagInput.vue +++ b/frontend/src/components/admin/channel/ModelTagInput.vue @@ -23,9 +23,9 @@ type="text" class="flex-1 min-w-[120px] border-none bg-transparent text-sm outline-none placeholder:text-gray-400 dark:text-white" :placeholder="models.length === 0 ? placeholder : ''" - @keydown.enter.prevent="addModel" + @keydown.enter="handleEnter" @keydown.tab="handleTab" - @keydown.delete="handleBackspace" + @keydown.backspace="handleBackspace" @paste="handlePaste" @blur="addModel" /> @@ -66,8 +66,14 @@ function addModel() { inputValue.value = '' } +function handleEnter(event: KeyboardEvent) { + if (event.isComposing) return + event.preventDefault() + addModel() +} + function handleTab(event: KeyboardEvent) { - if (!inputValue.value.trim()) return + if (event.isComposing || !inputValue.value.trim()) return event.preventDefault() addModel() } @@ -78,7 +84,8 @@ function removeModel(idx: number) { emit('update:models', newModels) } -function handleBackspace() { +function handleBackspace(event: KeyboardEvent) { + if (event.isComposing) return if (inputValue.value === '' && props.models.length > 0) { removeModel(props.models.length - 1) } diff --git a/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts index 44e4cc67b7ea..ccf09d8880e0 100644 --- a/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts @@ -13,6 +13,33 @@ function pressTab(input: Element, shiftKey = false) { } describe('model tag keyboard navigation', () => { + it.each(['Enter', 'Tab', 'Backspace'])('leaves composing %s to the input method', async (key) => { + const wrapper = mount(ModelTagInput, { props: { models: ['existing'] } }) + const input = wrapper.get('input') + if (key !== 'Backspace') await input.setValue('prefix') + const event = new KeyboardEvent('keydown', { key, isComposing: true, bubbles: true, cancelable: true }) + input.element.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(wrapper.emitted('update:models')).toBeUndefined() + }) + + it('keeps tags on Delete and removes the last tag only on Backspace', async () => { + const wrapper = mount(ModelTagInput, { props: { models: ['existing'] } }) + await wrapper.get('input').trigger('keydown', { key: 'Delete' }) + expect(wrapper.emitted('update:models')).toBeUndefined() + await wrapper.get('input').trigger('keydown', { key: 'Backspace' }) + expect(wrapper.emitted('update:models')).toEqual([[[]]]) + }) + + it('commits ordinary Enter input and prevents form submission', async () => { + const wrapper = mount(ModelTagInput, { props: { models: [] } }) + const input = wrapper.get('input'); await input.setValue('new-model') + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + input.element.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(wrapper.emitted('update:models')).toEqual([[['new-model']]]) + }) + it.each([false, true])('allows leaving an empty input with Tab (shift: %s)', (shift) => { const wrapper = mount(ModelTagInput, { props: { models: ['gpt-4o'] } }) expect(pressTab(wrapper.get('input').element, shift).defaultPrevented).toBe(false) From 5fc4a06af62d1c4af628bd4ef713502a273f29fe Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:46:06 +0800 Subject: [PATCH 113/142] fix(date-picker): refresh relative dates after midnight --- .../src/components/common/DateRangePicker.vue | 27 ++++++--------- .../DateRangePicker.midnight.spec.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/common/__tests__/DateRangePicker.midnight.spec.ts diff --git a/frontend/src/components/common/DateRangePicker.vue b/frontend/src/components/common/DateRangePicker.vue index 56861979bfc4..1c741997250a 100644 --- a/frontend/src/components/common/DateRangePicker.vue +++ b/frontend/src/components/common/DateRangePicker.vue @@ -43,7 +43,7 @@ @@ -57,7 +57,7 @@ type="date" v-model="localEndDate" :min="localStartDate" - :max="tomorrow" + :max="tomorrow()" class="date-picker-input" @change="onDateChange" /> @@ -108,22 +108,15 @@ const localStartDate = ref(props.startDate) const localEndDate = ref(props.endDate) const activePreset = ref('last24Hours') -const today = computed(() => { - // Use local timezone to avoid UTC timezone issues - const now = new Date() - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -}) +const today = () => formatDateToString(new Date()) // Tomorrow's date - used for max date to handle timezone differences // When user is in a timezone behind the server, "today" on server might be "tomorrow" locally -const tomorrow = computed(() => { +const tomorrow = () => { const d = new Date() d.setDate(d.getDate() + 1) return formatDateToString(d) -}) +} // Helper function to format date to YYYY-MM-DD using local timezone const formatDateToString = (date: Date): string => { @@ -138,7 +131,7 @@ const presets: DatePreset[] = [ labelKey: 'dates.today', value: 'today', getRange: () => { - const t = today.value + const t = today() return { start: t, end: t } } }, @@ -168,7 +161,7 @@ const presets: DatePreset[] = [ labelKey: 'dates.last7Days', value: '7days', getRange: () => { - const end = today.value + const end = today() const d = new Date() d.setDate(d.getDate() - 6) const start = formatDateToString(d) @@ -179,7 +172,7 @@ const presets: DatePreset[] = [ labelKey: 'dates.last14Days', value: '14days', getRange: () => { - const end = today.value + const end = today() const d = new Date() d.setDate(d.getDate() - 13) const start = formatDateToString(d) @@ -190,7 +183,7 @@ const presets: DatePreset[] = [ labelKey: 'dates.last30Days', value: '30days', getRange: () => { - const end = today.value + const end = today() const d = new Date() d.setDate(d.getDate() - 29) const start = formatDateToString(d) @@ -203,7 +196,7 @@ const presets: DatePreset[] = [ getRange: () => { const now = new Date() const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1)) - return { start, end: today.value } + return { start, end: today() } } }, { diff --git a/frontend/src/components/common/__tests__/DateRangePicker.midnight.spec.ts b/frontend/src/components/common/__tests__/DateRangePicker.midnight.spec.ts new file mode 100644 index 000000000000..79e240456464 --- /dev/null +++ b/frontend/src/components/common/__tests__/DateRangePicker.midnight.spec.ts @@ -0,0 +1,33 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import DateRangePicker from '../DateRangePicker.vue' +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref('en') }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 30, 23, 59)) }) +afterEach(() => vi.useRealTimers()) +async function reopenNextDay() { + const w = mount(DateRangePicker, { props: { startDate: '2026-09-30', endDate: '2026-09-30' }, global: { stubs: { Icon: true } } }) + await w.get('.date-picker-trigger').trigger('click') + expect(w.findAll('input[type="date"]')[1].attributes('max')).toBe('2026-10-01') + await w.get('.date-picker-trigger').trigger('click') + vi.setSystemTime(new Date(2026, 9, 1, 0, 1)) + await w.get('.date-picker-trigger').trigger('click') + return w +} +describe('date presets after midnight', () => { + it.each([ + ['dates.today', '2026-10-01'], + ['dates.last7Days', '2026-09-25'], + ['dates.thisMonth', '2026-10-01'], + ])('refreshes %s when the page stays mounted overnight', async (label, startDate) => { + const w = await reopenNextDay() + await w.findAll('.date-picker-preset').find(b => b.text() === label)!.trigger('click') + await w.get('.date-picker-apply').trigger('click') + expect(w.emitted('change')?.[0]?.[0]).toMatchObject({ startDate, endDate: '2026-10-01' }) + }) + it('updates the maximum selectable date when reopened', async () => { + const w = await reopenNextDay() + expect(w.findAll('input[type="date"]')[1].attributes('max')).toBe('2026-10-02') + }) +}) From 1f5cfe89731a13e2cf8b67f930579711141dd935 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:49:11 +0800 Subject: [PATCH 114/142] fix(users): discard stale user attribute responses --- .../src/components/user/UserAttributeForm.vue | 10 +++-- .../__tests__/UserAttributeForm.race.spec.ts | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/user/__tests__/UserAttributeForm.race.spec.ts diff --git a/frontend/src/components/user/UserAttributeForm.vue b/frontend/src/components/user/UserAttributeForm.vue index 74f3cff3c137..7ae5a8348257 100644 --- a/frontend/src/components/user/UserAttributeForm.vue +++ b/frontend/src/components/user/UserAttributeForm.vue @@ -112,6 +112,7 @@ const emit = defineEmits() const loading = ref(false) const attributes = ref([]) const localValues = ref({}) +let requestVersion = 0 const loadAttributes = async () => { loading.value = true @@ -126,9 +127,11 @@ const loadAttributes = async () => { const loadUserValues = async () => { if (!props.userId) return + const version = ++requestVersion try { const values = await adminAPI.userAttributes.getUserAttributeValues(props.userId) + if (version !== requestVersion) return const valuesMap: UserAttributeValuesMap = {} values.forEach(v => { valuesMap[v.attribute_id] = v.value @@ -184,12 +187,11 @@ watch(() => props.modelValue, (newVal) => { } }, { immediate: true }) -watch(() => props.userId, (newUserId) => { +watch(() => props.userId, (newUserId, _, onCleanup) => { + onCleanup(() => { requestVersion++ }) + localValues.value = {} if (newUserId) { loadUserValues() - } else { - // Reset for new user - localValues.value = {} } }, { immediate: true }) diff --git a/frontend/src/components/user/__tests__/UserAttributeForm.race.spec.ts b/frontend/src/components/user/__tests__/UserAttributeForm.race.spec.ts new file mode 100644 index 000000000000..60f947a67faa --- /dev/null +++ b/frontend/src/components/user/__tests__/UserAttributeForm.race.spec.ts @@ -0,0 +1,42 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import UserAttributeForm from '../UserAttributeForm.vue' +const mocks = vi.hoisted(() => ({ getUserAttributeValues: vi.fn(), listEnabledDefinitions: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { userAttributes: mocks } })) +enableAutoUnmount(afterEach) +beforeEach(() => { + vi.clearAllMocks() + mocks.listEnabledDefinitions.mockResolvedValue([{ id: 1, name: 'Team', type: 'text' }]) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => vi.restoreAllMocks()) +function deferred() { + let resolve!: (value: unknown) => void + const promise = new Promise(res => { resolve = res }) + return { promise, resolve } +} +function open() { + return mount(UserAttributeForm, { props: { userId: 1, modelValue: {} }, global: { stubs: { Select: true } } }) +} +describe('user attribute request ownership', () => { + it('does not emit previous user values after a newer user has loaded', async () => { + const old = deferred() + mocks.getUserAttributeValues.mockReturnValueOnce(old.promise).mockResolvedValueOnce([{ attribute_id: 1, value: 'current' }]) + const w = open(); await w.setProps({ userId: 2 }); await flushPromises() + old.resolve([{ attribute_id: 1, value: 'old' }]); await flushPromises() + expect(w.get('input').element.value).toBe('current') + expect(w.emitted('update:modelValue')).toEqual([[{ 1: 'current' }]]) + }) + it('does not restore values after resetting to a new user', async () => { + const old = deferred(); mocks.getUserAttributeValues.mockReturnValueOnce(old.promise) + const w = open(); await flushPromises(); await w.setProps({ userId: undefined }) + old.resolve([{ attribute_id: 1, value: 'old' }]); await flushPromises() + expect(w.get('input').element.value).toBe('') + expect(w.emitted('update:modelValue')).toBeUndefined() + }) + it('clears the previous values before a new user request fails', async () => { + mocks.getUserAttributeValues.mockResolvedValueOnce([{ attribute_id: 1, value: 'old' }]).mockRejectedValueOnce(new Error('unavailable')) + const w = open(); await flushPromises(); await w.setProps({ userId: 2 }); await flushPromises() + expect(w.get('input').element.value).toBe('') + }) +}) From 3eb4bae9ab4849a9554e17ad717823f1c63a0a8d Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:49:27 +0800 Subject: [PATCH 115/142] fix(profile): recognize DingTalk profile sources --- .../components/user/profile/ProfileInfoCard.vue | 1 + .../user/profile/__tests__/ProfileInfoCard.spec.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index fc5691a86fd8..6783f6d8abbe 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -281,6 +281,7 @@ function normalizeProvider(value: string): UserAuthProvider | null { if ( normalized === 'email' || normalized === 'linuxdo' || + normalized === 'dingtalk' || normalized === 'wechat' || normalized === 'github' || normalized === 'google' diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index 51653c6a4a28..e8ee13628238 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -35,6 +35,7 @@ vi.mock('vue-i18n', async (importOriginal) => { if (key === 'profile.user') return 'User' if (key === 'profile.authBindings.providers.email') return 'Email' if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo' + if (key === 'profile.authBindings.providers.dingtalk') return 'DingTalk' if (key === 'profile.authBindings.providers.wechat') return 'WeChat' if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC' if (key === 'profile.authBindings.source.avatar') { @@ -70,6 +71,19 @@ function createUser(overrides: Partial = {}): User { } describe('ProfileInfoCard', () => { + it.each([ + { avatar_source: 'dingtalk', username_source: 'dingtalk' }, + { profile_sources: { avatar: { provider: 'dingtalk' }, username: { provider: 'dingtalk' } } }, + ])('shows DingTalk as the synchronized profile source', (sources) => { + const wrapper = mount(ProfileInfoCard, { + props: { user: createUser(sources), dingtalkEnabled: true }, + global: { stubs: { Icon: true } }, + }) + expect(wrapper.text()).toContain('Avatar synced from DingTalk') + expect(wrapper.text()).toContain('Username synced from DingTalk') + wrapper.unmount() + }) + it('renders basic account information inside the new overview shell', () => { const wrapper = mount(ProfileInfoCard, { props: { From 8f8358cc671ede707c24fcd62fa14b7e3bf1a8b3 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 23 Sep 2026 00:44:29 +0800 Subject: [PATCH 116/142] =?UTF-8?q?fix(backup):=20=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=9C=88=E5=BA=A6=E5=BD=92=E6=A1=A3=E5=90=8E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=94=B9=E5=86=99=E6=97=A7=E5=BD=92=E6=A1=A3=E7=9A=84=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E4=BB=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/backup_retention_test.go | 40 ++++++++++++++++++- backend/internal/service/backup_service.go | 8 ++-- frontend/src/views/admin/BackupView.vue | 18 ++++++++- .../views/admin/__tests__/BackupView.spec.ts | 29 ++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/backup_retention_test.go b/backend/internal/service/backup_retention_test.go index 41b45bf80f5a..1b207f9f9b44 100644 --- a/backend/internal/service/backup_retention_test.go +++ b/backend/internal/service/backup_retention_test.go @@ -145,7 +145,7 @@ func TestBackupRetention_CleanupSeparatePoolsAndPermanentProtection(t *testing.T store.objects[id] = []byte(id) require.NoError(t, svc.saveRecord(ctx, r)) } - cfg := &BackupScheduleConfig{RetainDays: 1, RetainCount: 1, MonthlyArchive: &BackupMonthlyArchiveConfig{RetainCount: 1}} + cfg := &BackupScheduleConfig{RetainDays: 1, RetainCount: 1, MonthlyArchive: &BackupMonthlyArchiveConfig{Enabled: true, RetainCount: 1}} require.NoError(t, svc.cleanupOldBackups(ctx, cfg)) for _, id := range []string{"running", "failed", "ordinary-new", "archive-new", "permanent", "restoring"} { _, err := svc.GetBackupRecord(ctx, id) @@ -167,6 +167,44 @@ func TestBackupRetention_CleanupSeparatePoolsAndPermanentProtection(t *testing.T require.NoError(t, err) } +func TestBackupRetention_DisabledRuleKeepsPersistedArchivePolicy(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + ctx := context.Background() + for id, started := range map[string]string{"newest": "2026-09-16T04:00:00Z", "middle": "2026-08-16T04:00:00Z", "oldest": "2026-07-16T04:00:00Z"} { + record := archiveRecord(id, started) + record.S3Key = id + record.MonthlyArchive = &BackupMonthlyArchive{RetainCount: 2} + store.objects[id] = []byte(id) + require.NoError(t, svc.saveRecord(ctx, record)) + } + // The settings form hides the archive count once the rule is disabled, so a + // saved payload may still carry a smaller limit. It must not reach old archives. + cfg := &BackupScheduleConfig{RetainDays: 14, RetainCount: 10, MonthlyArchive: &BackupMonthlyArchiveConfig{Enabled: false, Days: []int{1}, RetainCount: 1}} + require.NoError(t, svc.cleanupOldBackups(ctx, cfg)) + for _, id := range []string{"newest", "middle"} { + record, err := svc.GetBackupRecord(ctx, id) + require.NoError(t, err, id) + require.Equal(t, 2, record.MonthlyArchive.RetainCount, id) + require.Contains(t, store.objects, id) + } + // The persisted policy keeps applying while the rule is off. + _, err := svc.GetBackupRecord(ctx, "oldest") + require.ErrorIs(t, err, ErrBackupNotFound) + require.NotContains(t, store.objects, "oldest") + // Re-enabling the rule applies the new finite limit to the pool again. + cfg.MonthlyArchive.Enabled = true + require.NoError(t, svc.cleanupOldBackups(ctx, cfg)) + newest, err := svc.GetBackupRecord(ctx, "newest") + require.NoError(t, err) + require.Equal(t, 1, newest.MonthlyArchive.RetainCount) + _, err = svc.GetBackupRecord(ctx, "middle") + require.ErrorIs(t, err, ErrBackupNotFound) + require.NotContains(t, store.objects, "middle") +} + func TestBackupRetention_Over100ArchivesRemainDiscoverable(t *testing.T) { repo := newMockSettingRepo() svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 6e1d10ea744e..39f1c36b9901 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -1494,9 +1494,11 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS if r.MonthlyArchive != nil { if r.MonthlyArchive.RetainCount > 0 { archiveCount++ - // A new finite limit applies to the finite archive pool. Permanent - // archives remain protected even after the configuration changes. - if cfg := schedule.MonthlyArchive; cfg != nil && cfg.RetainCount > 0 && cfg.RetainCount != r.MonthlyArchive.RetainCount { + // A new finite limit applies to the finite archive pool only while the + // rule is enabled; a disabled rule leaves every archive on its persisted + // policy. Permanent archives remain protected even after the + // configuration changes. + if cfg := schedule.MonthlyArchive; cfg != nil && cfg.Enabled && cfg.RetainCount > 0 && cfg.RetainCount != r.MonthlyArchive.RetainCount { r.MonthlyArchive.RetainCount = cfg.RetainCount metadataChanged = true } diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 3c2d92058d11..b9f7600a1902 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -488,8 +488,19 @@ const scheduleForm = ref({ }) const savingSchedule = ref(false) const archiveForm = ref({ enabled: false, days: [1], include_month_end: false, retain_count: 0 }) +// Disabling hides the archive parameters, so a save while disabled keeps the +// persisted parameters and only turns the rule off. +const savedArchive = ref(cloneArchive(archiveForm.value)) +const archivePayload = computed(() => + archiveForm.value.enabled ? cloneArchive(archiveForm.value) : { ...cloneArchive(savedArchive.value), enabled: false }, +) +function cloneArchive(config: BackupMonthlyArchiveConfig): BackupMonthlyArchiveConfig { + return { ...config, days: [...config.days] } +} const scheduleValidationError = computed(() => { - if (![scheduleForm.value.retain_days, scheduleForm.value.retain_count, archiveForm.value.retain_count].every(value => Number.isSafeInteger(value) && value >= 0)) { + const counts = [scheduleForm.value.retain_days, scheduleForm.value.retain_count] + if (archiveForm.value.enabled) counts.push(archiveForm.value.retain_count) + if (!counts.every(value => Number.isSafeInteger(value) && value >= 0)) { return t('admin.backup.archive.invalidRetention') } if (archiveForm.value.enabled && !archiveForm.value.days.length && !archiveForm.value.include_month_end) { @@ -753,6 +764,7 @@ async function loadSchedule() { include_month_end: cfg.monthly_archive?.include_month_end ?? false, retain_count: cfg.monthly_archive?.retain_count ?? 0, } + savedArchive.value = cloneArchive(archiveForm.value) } catch (error) { appStore.showError((error as { message?: string })?.message || t('errors.networkError')) } @@ -762,7 +774,9 @@ async function saveSchedule() { if (scheduleValidationError.value) return savingSchedule.value = true try { - await adminAPI.backup.updateSchedule({ ...scheduleForm.value, monthly_archive: { ...archiveForm.value, days: [...archiveForm.value.days] } }) + const archive = archivePayload.value + await adminAPI.backup.updateSchedule({ ...scheduleForm.value, monthly_archive: archive }) + savedArchive.value = cloneArchive(archive) appStore.showSuccess(t('admin.backup.schedule.saved')) } catch (error) { appStore.showError((error as { message?: string })?.message || t('errors.networkError')) diff --git a/frontend/src/views/admin/__tests__/BackupView.spec.ts b/frontend/src/views/admin/__tests__/BackupView.spec.ts index 62ba71713b98..8c1e1563bc02 100644 --- a/frontend/src/views/admin/__tests__/BackupView.spec.ts +++ b/frontend/src/views/admin/__tests__/BackupView.spec.ts @@ -219,6 +219,35 @@ describe('admin BackupView', () => { expect(updateSchedule).not.toHaveBeenCalled() }) + it('关闭月度归档后不提交隐藏的归档参数,重新启用时保留编辑值', async () => { + getSchedule.mockResolvedValue({ enabled: true, cron_expr: '0 4 * * *', retain_days: 14, retain_count: 10, + monthly_archive: { enabled: true, days: [1, 15], include_month_end: false, retain_count: 12 } }) + const wrapper = mountBackupView() + await flushPromises() + await wrapper.get('[data-testid="archive-count"]').setValue(1) + await wrapper.get('[data-testid="archive-enabled"]').setValue(false) + expect(wrapper.find('[data-testid="archive-count"]').exists()).toBe(false) + const save = wrapper.get('[data-testid="backup-schedule"] .btn-primary') + await save.trigger('click') + await flushPromises() + expect(updateSchedule).toHaveBeenLastCalledWith(expect.objectContaining({ monthly_archive: { enabled: false, days: [1, 15], include_month_end: false, retain_count: 12 } })) + await wrapper.get('[data-testid="archive-enabled"]').setValue(true) + expect((wrapper.get('[data-testid="archive-count"]').element as HTMLInputElement).value).toBe('1') + // A hidden invalid count neither blocks saving nor reaches the request. + await wrapper.get('[data-testid="archive-count"]').setValue('') + expect(save.attributes('disabled')).toBeDefined() + await wrapper.get('[data-testid="archive-enabled"]').setValue(false) + expect(save.attributes('disabled')).toBeUndefined() + await save.trigger('click') + await flushPromises() + expect(updateSchedule).toHaveBeenLastCalledWith(expect.objectContaining({ monthly_archive: { enabled: false, days: [1, 15], include_month_end: false, retain_count: 12 } })) + await wrapper.get('[data-testid="archive-enabled"]').setValue(true) + await wrapper.get('[data-testid="archive-count"]').setValue(1) + await save.trigger('click') + await flushPromises() + expect(updateSchedule).toHaveBeenLastCalledWith(expect.objectContaining({ monthly_archive: { enabled: true, days: [1, 15], include_month_end: false, retain_count: 1 } })) + }) + it('加载归档设置并支持键盘关闭日期选择器', async () => { getSchedule.mockResolvedValue({ enabled: true, cron_expr: '0 4 * * *', retain_days: 14, retain_count: 10, monthly_archive: { enabled: true, days: [1, 15], include_month_end: true, retain_count: 23 } }) From c19204289d04f643fcf3442dbf998d3796f33555 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 23 Sep 2026 01:24:59 +0800 Subject: [PATCH 117/142] =?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 0c2db16cd1ca..a5ed9e1cc0b3 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 2ef1be66bf49..0d4344fde57c 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_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 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +// 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 18e3ea6725f1..0a6b653ec635 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 355360055759..4e2eaaf3d41c 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 f74da3da6f8d..c68482dd7585 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 b3221a1ce87d..a9a20166784d 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 2c6423c78729..f48156e9956c 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 1e3f0b6c598c..b3d92039a5af 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 f604e811f98b..0a7d1b8895ca 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 000000000000..5a97f418214f --- /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 8cc736bce75c..570bbf1fe7a7 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 51bd13a6ac45..d469c778efe5 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 abc2b0c8d758..95396dea1d2b 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 8d3cf6797907..a9fe09d7b564 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" />