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/205] 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 14080ecde8..0682ba0e36 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 c39fa6b098..e395d6b947 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/205] 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 ecca154781..0705d897b1 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 fe6e3ea6b2..56e071041a 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 104f319dc5..021b060c73 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/205] 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 0705d897b1..429bc19a78 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 021b060c73..6ea830b7b3 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/205] 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 9595d27ed3..0aef73f4fc 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/205] 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 0000000000..d43af796f9 --- /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 293dbcceab..391ba8c6ab 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/205] 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 2a459cc286..e3197dbcad 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 dd8f390079..33f9a2c3e8 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 0000000000..e84ed8d7f0 --- /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 0000000000..6c403f218a --- /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 52420c584a..0d00dacb79 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/205] 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 0000000000..4adc7c208a --- /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/205] 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 18e1d59195..9900861715 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 0000000000..1e0b9406ba --- /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 b5268e9102..bee3e8946a 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 0fe06a334b..c71a9ea05d 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 4adc7c208a..10e50f8104 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/205] 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 1e0b9406ba..516a84bb4f 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/205] 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 516a84bb4f..e8672e7c61 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/205] 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 0ee746aa37..8988603ff1 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 0000000000..516d010c72 --- /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/205] 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 8988603ff1..9ac4211e4a 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 516d010c72..0401b670ad 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/205] =?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 f888d53adf..edeeba61d0 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 0000000000..5c643d0f4e --- /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 3f20bbe1f6..2143cb1f28 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 b0f4ebed70..cac990e520 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 5ad63d30f8..928e2624d2 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 } - return openAIFreshUpstreamBillingRate(account, now) + 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 + } + 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 1288107573..8b402eeb25 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 0000000000..86ab5add45 --- /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 dc9366ba9e..d6497775cf 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 750f01728b..40619e29e4 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 77bac7b211..873d40849a 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 83c1422568..a305d390cf 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 4c8c06a7a3..4f0e66b086 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 99e82fb12b..7037d2e368 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 df178cb67d..ec7614a102 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 c25b06eb3b..15ed5582b9 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 8491ff9e30..8103259845 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/205] 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 32d38cf40d..0f6611c142 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/205] 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 a534f5c246..6f68a53429 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 0000000000..e4af8a4593 --- /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/205] 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 56861979bf..b116dff2e3 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 0000000000..222cd54b08 --- /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/205] 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 d76d71d0f1..b83204dc95 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 b5985a226e..2490043284 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 4158d0559c..92186dd6ab 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 c1f9f06954..8804cab702 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 5e2fa0f71d..9e2baa3b09 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/205] 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 a534f5c246..e409b15e8d 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 0000000000..5e2b65e3a3 --- /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/205] 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 e2adb3a17a..c719ee80e3 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 cbb5d3c6b6..40bca32bc4 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 6701a3c999..347600df05 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 0000000000..faee600804 --- /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 6e351d75d2..c85dec07a3 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 d164519330..f03d48491e 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 637ab8d69f..481fa6ca6c 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 93c364153c..8a32d7a92b 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 0000000000..4cc8955f79 --- /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 3944ca5f07..10ecbd9e99 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 0000000000..64c90f68e8 --- /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 a5ca947e11..8754f19583 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 afdbd36db6..8576736b9c 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 c7c017e4b9..ace6fff1e4 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 0000000000..21254ebc38 --- /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 e360be3ce3..3c6c1c2bf6 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 0000000000..8d3cf67979 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,323 @@ + + + diff --git a/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts new file mode 100644 index 0000000000..6306a5364b --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts @@ -0,0 +1,179 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue' + +const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({ + listInviteRecords: vi.fn(), + listRebateRecords: vi.fn(), + listTransferRecords: vi.fn(), + getUserOverview: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => { + const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } + return { affiliatesAPI, default: affiliatesAPI } +}) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const TablePageLayoutStub = { + template: '
', +} + +const DataTableStub = { + props: ['columns', 'data'], + template: ` + + + + +
+ +
+ `, +} + +const WithdrawDialogStub = { + name: 'AffiliateOfflineWithdrawDialog', + props: ['show'], + emits: ['close', 'success'], + template: '
', +} + +function mountTable(type: 'invites' | 'rebates' | 'transfers') { + return mount(AdminAffiliateRecordsTable, { + props: { type }, + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + Icon: true, + OrderStatusBadge: true, + BaseDialog: true, + AffiliateOfflineWithdrawDialog: WithdrawDialogStub, + }, + }, + }) +} + +function page(items: T[]) { + return { items, total: items.length, page: 1, page_size: 20, pages: 1 } +} + +describe('AdminAffiliateRecordsTable', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders non-order rebate accruals with empty order fields', async () => { + listRebateRecords.mockResolvedValue(page([ + { + order_id: null, + out_trade_no: '', + inviter_id: 1, + inviter_email: 'inviter@example.com', + inviter_username: 'inviter', + invitee_id: 2, + invitee_email: 'invitee@example.com', + invitee_username: 'invitee', + order_amount: null, + pay_amount: null, + rebate_amount: 2, + payment_type: '', + order_status: '', + created_at: '2026-09-17T08:00:00Z', + }, + ])) + + const wrapper = mountTable('rebates') + await flushPromises() + + const row = wrapper.get('[data-test="row-0"]') + expect(row.get('[data-test="cell-order"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_status"]').text()).toBe('-') + expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00') + expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false) + }) + + it('lists offline withdrawals next to balance transfers with their type', async () => { + listTransferRecords.mockResolvedValue(page([ + { + ledger_id: 11, + action: 'withdraw', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 12.5, + balance_after: 5.5, + available_quota_after: 7.5, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-17T08:00:00Z', + }, + { + ledger_id: 10, + action: 'transfer', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 3, + balance_after: 5.5, + available_quota_after: 20, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-16T08:00:00Z', + }, + ])) + + const wrapper = mountTable('transfers') + await flushPromises() + + const withdrawRow = wrapper.get('[data-test="row-0"]') + expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw') + expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50') + + const transferRow = wrapper.get('[data-test="row-1"]') + expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer') + }) + + it('opens the offline withdrawal dialog and reloads the first page after recording', async () => { + listTransferRecords.mockResolvedValue(page([])) + + const wrapper = mountTable('transfers') + await flushPromises() + expect(listTransferRecords).toHaveBeenCalledTimes(1) + + const button = wrapper.get('[data-test="affiliate-withdraw-open"]') + expect(button.text()).toBe('admin.affiliates.withdraw.button') + await button.trigger('click') + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true') + + wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 }) + await flushPromises() + + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false') + expect(listTransferRecords).toHaveBeenCalledTimes(2) + expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 }) + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts new file mode 100644 index 0000000000..f6aac2570a --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -0,0 +1,152 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue' + +const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({ + lookupUsers: vi.fn(), + getUserOverview: vi.fn(), + withdrawUserQuota: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => ({ + affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + }), + } +}) + +const BaseDialogStub = { + props: ['show', 'title'], + emits: ['close'], + template: '
', +} + +function mountDialog() { + return mount(AffiliateOfflineWithdrawDialog, { + props: { show: true }, + global: { stubs: { BaseDialog: BaseDialogStub } }, + }) +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue({ + user_id: 42, + email: 'inviter@example.com', + username: 'inviter', + aff_code: 'INVITER', + rebate_rate_percent: 20, + invited_count: 3, + rebated_invitee_count: 2, + available_quota: availableQuota, + history_quota: 50, + }) + await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() +} + +describe('AffiliateOfflineWithdrawDialog', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('records the exact available quota as the withdrawal amount', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 12.34567891) + + expect(lookupUsers).toHaveBeenCalledWith('inviter') + expect(getUserOverview).toHaveBeenCalledWith(42) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891') + + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891') + + withdrawUserQuota.mockResolvedValue({ + ledger_id: 9, + user_id: 42, + amount: 12.34567891, + available_quota_after: 0, + frozen_quota_after: 0, + history_quota_after: 50, + }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 }) + }) + + it('blocks amounts above the available quota', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001') + + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + expect(withdrawUserQuota).not.toHaveBeenCalled() + }) + + it('treats a user without an affiliate profile as having no available quota', async () => { + lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }]) + getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' }) + const wrapper = mountDialog() + + await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() + + expect(showError).not.toHaveBeenCalled() + expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com') + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('1') + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + }) + + it('shows the API error and reloads the available quota when recording fails', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + withdrawUserQuota.mockRejectedValue({ reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}') + expect(getUserOverview).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('success')).toBeUndefined() + }) +}) From a9ff6633870f1d4b47d84619c7ce2764d406ee0b Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 18 Sep 2026 06:47:43 +0800 Subject: [PATCH 025/205] =?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 b668eda82a..4d874660de 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 0000000000..aa4a34023b --- /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 0000000000..832109c129 --- /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 0000000000..e5a6dcfe75 --- /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 0000000000..41b45bf80f --- /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 aea9aeeb8c..6e1d10ea74 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) +} - records, _ := s.loadRecordsLocked(ctx) +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, 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 876e8243d4..e6d5b4f634 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 25a6e5bd9c..7f4b89f6db 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 0000000000..f1a1b1e30f --- /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 725cdada28..3df37d96b1 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 b3b3e706d2..2f5b44f495 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 740cd440e1..3c2d92058d 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 d7a257a7ae..62ba71713b 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/205] 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 738e0531c1..fae7ec6f83 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 57cb4b394d..b1c5e799db 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 50740b7cef..b102ab56c1 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/205] 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 26c0c72ec4..81df742e9b 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 97e35fdb1b..897979dec5 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/205] 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 29b17d1655..916063cae5 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 efb9f2e0d8..8956bb58a1 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 cf4611ccce..c3c5474eea 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/205] 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 e073659cc2..24df06ae0f 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 0000000000..68186d80ab --- /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 0000000000..de72068b02 --- /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 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 030/205] =?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 e2658581e7..cee3336c76 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 888a32f55c..5ab3b87f6c 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 031/205] 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 02a1fcf3be..dc45774754 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 f151e33cd8..c67a8259fd 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 9d5c8d961c61fcffa9d07bb99fa4aae7b9155c07 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 18 Sep 2026 16:37:03 +0800 Subject: [PATCH 032/205] 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 e24c6fc226..ba0fe6a051 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 0000000000..3e4182f3c2 --- /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 033/205] 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 7bf1a21f02..04612638b5 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 0000000000..02bee44af7 --- /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 052/205] 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 4bce43ad53..b9e59433b6 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/admin/channel/__tests__/IntervalRow.spec.ts b/frontend/src/components/admin/channel/__tests__/IntervalRow.spec.ts new file mode 100644 index 0000000000..079a8a2ea7 --- /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 066/205] 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 519d37cc50..b191dea4f8 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 8c8ad058ee..041650c6c1 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 6bed4ee6a5..6b848bfa02 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 067/205] 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 3ed62dcb43..1aba399372 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 0000000000..1994d7f046 --- /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 068/205] 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 ef24fe7eb0..02d90356ef 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 0000000000..da4c129cbc --- /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 069/205] =?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 d0e336ec6c..a7ce166165 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 1e19c07bda..8058c0c488 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 0000000000..1654a26ca1 --- /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 0000000000..a3a5b4773b --- /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 070/205] =?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 97e35fdb1b..0b6094fcf8 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 28e30dfadf..0085182333 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 859e782291..2f9a750caa 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 0000000000..9e30512a61 --- /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 071/205] =?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 0085182333..c136f8c3fb 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 9e30512a61..2e1ac52176 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 072/205] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5a7dd4e8c9..b4627c71fb 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 073/205] test(frontend): run platform quota modal spec in CI --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index fec155a8dc..1c5737f63c 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 074/205] 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 600d2c7372..bf92e3a82e 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 0000000000..dc6014f469 --- /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 0fe06a334b..8ef483e537 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 0000000000..92d4ccf052 --- /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 0000000000..d96fa0ebbf --- /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 075/205] 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 2bac7259a9..8361b273b5 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 076/205] 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 033064d109..c188248069 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 737d88aa70..1ded62c5b7 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 b13200d7a18486797f77b7833f4b2e35c00be7ff Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 19 Sep 2026 21:26:18 +0800 Subject: [PATCH 077/205] fix(gateway): forward received chat stream usage --- .../anthropic_chat_stream_usage_test.go | 111 ++++++++++++++++++ .../gateway_forward_as_chat_completions.go | 22 +++- ...ateway_forward_as_chat_completions_test.go | 4 +- ...enai_gateway_anthropic_native_pump_test.go | 4 +- ...teway_chat_completions_anthropic_native.go | 9 +- 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/anthropic_chat_stream_usage_test.go diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go new file mode 100644 index 0000000000..99ce4d2789 --- /dev/null +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -0,0 +1,111 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, adapter := range []string{"anthropic", "native"} { + for _, options := range []string{"", `,"stream_options":{"include_usage":true}`, `,"stream_options":{"include_usage":false}`} { + for _, tc := range []struct { + name, start, delta string + want bool + prompt, output, cached, created int + }{ + {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, true, 566, 49, 241, 17}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, + {"absent", "", "", false, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, false, 0, 0, 0, 0}, + } { + for _, terminal := range []string{"stop", "eof"} { + t.Run(adapter+"/"+options+"/"+tc.name+"/"+terminal, func(t *testing.T) { + sse := fmt.Sprintf("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k3\",\"content\":[]%s}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.start, tc.delta) + if terminal == "stop" { + // Repeated terminal events and EOF must not duplicate usage. + sse += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + body := []byte(`{"model":"k3","messages":[{"role":"user","content":"hi"}],"stream":true` + options + "}") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + if adapter == "native" { + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + result, err := svc.forwardChatCompletionsViaNativeAnthropic(context.Background(), c, nativeAnthropicTestAccount(), body, "") + require.NoError(t, err) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } else { + svc := &GatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, nil) + require.NoError(t, err) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } + count, done := 0, 0 + var last *apicompat.ChatCompletionsChunk + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + done++ + if tc.want { + require.NotNil(t, last) + require.NotNil(t, last.Usage) + } + continue + } + require.Zero(t, done, "chunk after DONE") + var chunk apicompat.ChatCompletionsChunk + require.NoError(t, json.Unmarshal([]byte(payload), &chunk)) + last = &chunk + if chunk.Usage == nil { + continue + } + count++ + require.Empty(t, chunk.Choices) + require.Equal(t, tc.prompt, chunk.Usage.PromptTokens) + require.Equal(t, tc.output, chunk.Usage.CompletionTokens) + require.Equal(t, tc.prompt+tc.output, chunk.Usage.TotalTokens) + if tc.cached > 0 { + require.NotNil(t, chunk.Usage.PromptTokensDetails) + require.Equal(t, tc.cached, chunk.Usage.PromptTokensDetails.CachedTokens) + require.Equal(t, tc.created, chunk.Usage.PromptTokensDetails.CacheCreationTokens) + } + } + require.Equal(t, 1, done) + if tc.want { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + }) + } + } + } + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40d..687e31cc6f 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -42,7 +42,6 @@ func (s *GatewayService) ForwardAsChatCompletions( } originalModel := ccReq.Model clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -185,7 +184,7 @@ func (s *GatewayService) ForwardAsChatCompletions( var result *ForwardResult var handleErr error if clientStream { - result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime, includeUsage) + result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } else { result, handleErr = s.handleCCBufferedFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } @@ -358,7 +357,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mappedModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -376,7 +374,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -467,6 +464,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), nil } @@ -512,3 +513,16 @@ func writeGatewayCCError(c *gin.Context, statusCode int, errType, message string }, }) } + +// anthropicChatStreamHasUsage distinguishes an explicit zero-valued usage object +// from omitted/null usage before the Anthropic→Responses conversion loses that distinction. +func anthropicChatStreamHasUsage(event *apicompat.AnthropicStreamEvent, payload string) bool { + switch event.Type { + case "message_start": + return event.Message != nil && gjson.Get(payload, "message.usage").IsObject() + case "message_delta": + return event.Usage != nil + default: + return false + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go index f7cb42c69c..163b204490 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions_test.go +++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go @@ -199,7 +199,7 @@ func TestHandleCCStreamingFromAnthropic_CompactSSEFormat(t *testing.T) { } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 21, result.Usage.InputTokens) @@ -236,7 +236,7 @@ func TestHandleCCStreamingFromAnthropic_PreservesMessageStartCacheUsageAndReason } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 20, result.Usage.InputTokens) diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go index 83bbd4eeff..e5b9d0dd84 100644 --- a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -140,7 +140,7 @@ func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { resp, pr, pw := newHangingUpstreamResponse() start := time.Now() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start) _ = pw.Close() _ = pr.Close() @@ -220,7 +220,7 @@ func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { }() defer func() { _ = pr.Close() }() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) if err != nil { t.Fatalf("unexpected error: %v", err) } 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 bc865af81c..40b00c81e6 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -54,7 +54,6 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("missing model in request") } clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -136,7 +135,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) if clientStream { - return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } @@ -303,7 +302,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( upstreamModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -320,7 +318,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -461,6 +458,10 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), nil } From ece1ecccca842585c99bc738bb93115bd1455e3e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 12:36:32 +0800 Subject: [PATCH 078/205] fix(gateway): normalize streamed Anthropic usage --- .../anthropic_chat_stream_usage_test.go | 27 +++-- .../gateway_forward_as_chat_completions.go | 5 + .../service/gateway_forward_as_responses.go | 67 ++++++++--- .../gateway_forward_as_responses_test.go | 110 ++++++++++++++++++ ...teway_chat_completions_anthropic_native.go | 5 + .../service/openai_gateway_cn_fixes_test.go | 80 +++++++++++++ ...enai_gateway_responses_anthropic_native.go | 6 + 7 files changed, 277 insertions(+), 23 deletions(-) diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go index 99ce4d2789..ff1558b073 100644 --- a/backend/internal/service/anthropic_chat_stream_usage_test.go +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -22,19 +22,28 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { for _, adapter := range []string{"anthropic", "native"} { for _, options := range []string{"", `,"stream_options":{"include_usage":true}`, `,"stream_options":{"include_usage":false}`} { for _, tc := range []struct { - name, start, delta string - want bool - prompt, output, cached, created int + name, start, delta, repeatDelta string + want bool + prompt, billableInput, totalInput, output, cached, created int }{ - {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, true, 566, 49, 241, 17}, - {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, - {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, - {"absent", "", "", false, 0, 0, 0, 0}, - {"null", `,"usage":null`, `,"usage":null`, false, 0, 0, 0, 0}, + {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, "", true, 566, 308, 566, 49, 241, 17}, + {"deepseek_partial_cache", "", `,"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"openai_partial_cache", "", `,"usage":{"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"repeated_cache_bucket", `,"usage":{"input_tokens":1200}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, + {"kimi_full_cache", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", "", true, 0, 0, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0, 0, 0}, + {"absent", "", "", "", false, 0, 0, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, "", false, 0, 0, 0, 0, 0, 0}, } { for _, terminal := range []string{"stop", "eof"} { t.Run(adapter+"/"+options+"/"+tc.name+"/"+terminal, func(t *testing.T) { sse := fmt.Sprintf("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k3\",\"content\":[]%s}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.start, tc.delta) + if tc.repeatDelta != "" { + sse += fmt.Sprintf("event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.repeatDelta) + } if terminal == "stop" { // Repeated terminal events and EOF must not duplicate usage. sse += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" @@ -52,6 +61,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} result, err := svc.forwardChatCompletionsViaNativeAnthropic(context.Background(), c, nativeAnthropicTestAccount(), body, "") require.NoError(t, err) + require.Equal(t, tc.totalInput, result.Usage.InputTokens) require.Equal(t, tc.output, result.Usage.OutputTokens) require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) @@ -60,6 +70,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}} result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, nil) require.NoError(t, err) + require.Equal(t, tc.billableInput, result.Usage.InputTokens) require.Equal(t, tc.output, result.Usage.OutputTokens) require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 687e31cc6f..0936af2b46 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -430,6 +430,11 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Chain: Anthropic event → Responses events → CC chunks responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState) for _, resEvt := range responsesEvents { diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 1b97a73961..6903e659ad 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -279,22 +279,22 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { return } + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache // fields. Prefer those authoritative totals or hit/miss buckets over the // overloaded input_tokens field. This covers Kimi's changing stream // semantics as well as GLM/DeepSeek cache aliases. if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { - cacheReadTokens := src.CacheReadInputTokens - if cacheReadTokens == 0 && src.CachedTokens > 0 { - cacheReadTokens = src.CachedTokens - } - if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { - cacheReadTokens = src.PromptTokensDetails.CachedTokens - } - if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { - cacheReadTokens = max(*src.PromptCacheHitTokens, 0) - } - if src.PromptCacheMissTokens != nil { dst.InputTokens = max(*src.PromptCacheMissTokens, 0) } else { @@ -303,13 +303,21 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { + previousCacheReadTokens := dst.CacheReadInputTokens + previousCacheCreationTokens := dst.CacheCreationInputTokens if src.InputTokens > 0 { dst.InputTokens = src.InputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } else if src.CachedTokens > 0 { - dst.CacheReadInputTokens = src.CachedTokens + if src.InputTokens == 0 && dst.InputTokens > 0 && (cacheReadTokens > 0 || src.CacheCreationInputTokens > 0) { + // Some compatible streams put the total prompt count in message_start, + // then provide only cumulative cache buckets in message_delta. Subtract + // only newly observed buckets so repeated deltas are idempotent. + newCacheReadTokens := max(cacheReadTokens-previousCacheReadTokens, 0) + newCacheCreationTokens := max(src.CacheCreationInputTokens-previousCacheCreationTokens, 0) + dst.InputTokens = max(dst.InputTokens-newCacheReadTokens-newCacheCreationTokens, 0) + } + if cacheReadTokens > 0 { + dst.CacheReadInputTokens = cacheReadTokens } if src.CacheCreationInputTokens > 0 { dst.CacheCreationInputTokens = src.CacheCreationInputTokens @@ -320,6 +328,29 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { } } +func syncAnthropicResponsesUsage(state *apicompat.AnthropicEventToResponsesState, usage ClaudeUsage) { + state.InputTokens = usage.InputTokens + state.OutputTokens = usage.OutputTokens + state.CacheReadInputTokens = usage.CacheReadInputTokens + state.CacheCreationInputTokens = usage.CacheCreationInputTokens +} + +func normalizeAnthropicEventUsageForResponses(event *apicompat.AnthropicStreamEvent, usage ClaudeUsage) { + normalize := func(dst *apicompat.AnthropicUsage) { + if dst == nil { + return + } + dst.InputTokens = usage.InputTokens + dst.OutputTokens = usage.OutputTokens + dst.CacheReadInputTokens = usage.CacheReadInputTokens + dst.CacheCreationInputTokens = usage.CacheCreationInputTokens + } + normalize(event.Usage) + if event.Message != nil { + normalize(&event.Message.Usage) + } +} + // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". // According to the SSE spec (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation), // the space after the colon is optional. This function handles both formats. @@ -545,6 +576,12 @@ func (s *GatewayService) handleResponsesStreamingResponse( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the terminal Responses usage aligned with the normalized billing + // buckets. Normalize the converter input too, so message handlers cannot + // restore the provider's overlapping raw input total. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Convert to Responses events events := apicompat.AnthropicEventToResponsesEvents(event, state) for _, evt := range events { diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 972c6bc1a6..353eeb8846 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -307,6 +307,116 @@ func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *tes require.Contains(t, rec.Body.String(), `response.completed`) } +func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + { + name: "cache_read_input_tokens full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"cache_read_input_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "cached_tokens full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"cached_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "prompt_tokens_details full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"prompt_tokens_details":{"cached_tokens":173306}`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "DeepSeek input total with hit and miss buckets", + startUsage: `"input_tokens":0`, + deltaUsage: `"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "OpenAI prompt total with cached details", + startUsage: `"input_tokens":0`, + deltaUsage: `"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "cache creation only without prompt total or miss bucket", + startUsage: `"input_tokens":1200`, + deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 400, + wantOutput: 30, + wantCacheCreation: 800, + }, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + resp := &http.Response{Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&GatewayService{}).handleResponsesStreamingResponse(resp, c, "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput, result.Usage.InputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestParseAnthropicSSEField(t *testing.T) { t.Parallel() 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 40b00c81e6..03c8b8e729 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -407,6 +407,11 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // 客户端已断开:跳过转换与写出,继续读上游直到流结束(usage 完整、 // 连接及时归还),不再提前 return。 if clientDisconnected { diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 0dfb7c35b6..470c789c74 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -11,9 +11,12 @@ package service import ( "context" + "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -109,6 +112,83 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te "output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1)") } +func TestResponsesStreamingFromNativeAnthropic_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + repeatDelta bool + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + {name: "full cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, + {name: "partial cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "cache creation", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, + {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if tt.repeatDelta { + lines = append(lines, `event: message_delta`, `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{`+tt.deltaUsage+`}}`, ``) + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&OpenAIGatewayService{}).handleResponsesStreamingFromNativeAnthropic( + resp, c, "k3", "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, result.Usage.InputTokens) + require.Equal(t, tt.wantOutput, result.Usage.OutputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { repo := &rateLimitAccountRepoStub{} diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index fa2e190b97..c5f8ef6887 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -397,6 +397,12 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep terminal Responses usage aligned with the normalized billing + // buckets. Normalize converter input too so raw overlapping totals cannot + // overwrite the state when message_start/message_delta handlers run. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + events := apicompat.AnthropicEventToResponsesEvents(event, state) if clientDisconnected { return From d0ed0eacacfa5d6ed495960b6314e99797c9e4c5 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 21 Sep 2026 10:11:01 +0800 Subject: [PATCH 079/205] =?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 66763b1199..7781d5cedd 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 21b6e9af97..959c1c8490 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 306d84d8f4..f02879ca9a 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 0000000000..ea3f7ebc66 --- /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 0000000000..03e5c24983 --- /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 080/205] 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 777c1de9f1..0cede4c0e8 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 0000000000..39309153ba --- /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 1e1a0f8477..5a9a00aea7 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 21b6e9af97..6c5418516a 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 306d84d8f4..0b7c154fec 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 0000000000..718867ec30 --- /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 0000000000..8ab96bce74 --- /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 bc865af81c..bc029bf480 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 0000000000..4a001d4f0d --- /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 928e3fa982..b5f1fbbc17 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 fa2e190b97..4295fa9544 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 3e253e378f..86ebf21ebd 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 081/205] =?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 39b1c982a4..7f03536b7f 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 bf92f8b3f8..8763da57b0 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 777c1de9f1..3ce3e1fc24 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 b835a84105..086466d319 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 fbf02cc1c5..88c4906864 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 3e9764249c..d0c17765a9 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 082/205] 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 c141431515..3bd69cead7 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 65634598f1..427b9bbf6d 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 f9d58e5a0c..eee030c4fe 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 29161ce338..1be36ba528 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 59f20e0762..ed83a97308 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 e2658581e7..2c334524da 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 5b9990b52c..06a17a961e 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 27e49773e9..284b0dcf9f 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 a4bf6b1500..5e2dcc78a9 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 766657f843..b22ec1c4a8 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 579d18db86..bab05e4188 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 91a847bf19..80696b28f8 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 083/205] 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 807ed7056b..c868cf6417 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 0b7c154fec..a5c39ea3d8 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 3d7fda044e..07fafa9493 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 084/205] 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 cb757a1faa..f0d35b56ce 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 e2658581e7..008b41b86e 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 888a32f55c..ae0d1f0396 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 085/205] 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 8b8c82dd66..84e8fc156a 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 0000000000..ebbf77a37e --- /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 086/205] 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 0000000000..009f52224f --- /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 7b42b04d70..30dd4f911f 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 0000000000..44287125b0 --- /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 087/205] 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 0000000000..0bb110807a --- /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 7b42b04d70..a6f4e93e82 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 0000000000..0e59f02fb6 --- /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 088/205] 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 b56861ebb6..4da9668dd8 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 a68a691d27..4fe14c380b 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 103423e8aa..0fe504a6f8 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 7f899d2c34..036754037f 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 263eceba04..1634a556fc 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 f421a3f6bd..5380e9fc6d 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 f5aaca641f..3e3f5faea3 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 b4e455df7c..53341f8e26 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 6fc119f7ba..5c80de5a6f 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 5c8f782598..8768b41fea 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 089/205] =?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 6568b3f6b4..badb2427cf 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 f5dd160c79..059d7e2b2c 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 28e30dfadf..3cf56f234e 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 f867e3e2ece67781d23cff532c64fcd8bbc90c73 Mon Sep 17 00:00:00 2001 From: Achord Chan Date: Tue, 22 Sep 2026 18:34:36 +0800 Subject: [PATCH 090/205] test(antigravity): check schema properties type assertion --- backend/internal/pkg/antigravity/schema_const_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/antigravity/schema_const_test.go b/backend/internal/pkg/antigravity/schema_const_test.go index ac83a4026c..05551fbd83 100644 --- a/backend/internal/pkg/antigravity/schema_const_test.go +++ b/backend/internal/pkg/antigravity/schema_const_test.go @@ -35,7 +35,8 @@ func TestBuildToolsPreservesStringConst(t *testing.T) { }}) require.Len(t, tools, 1) require.Len(t, tools[0].FunctionDeclarations, 1) - properties := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any) + properties, ok := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any) + require.True(t, ok, "tool parameters must contain a properties object") got, err := json.Marshal(properties["action"]) require.NoError(t, err) require.JSONEq(t, tc.want, string(got)) From 66843cc95cdbc7e39902082e01693613f836d2ad Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 22 Sep 2026 19:07:04 +0800 Subject: [PATCH 091/205] 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 013c10c13d..3886bfff8e 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 d51dc21ba7..2ba8890524 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 092/205] 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 59af5edbd9..29cdcf94aa 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 b56861ebb6..421a982077 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 093/205] 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 cb1522e4fb..5fc8bb77d5 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 b300b22a2e..10fd5dd6e0 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 094/205] =?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 cbc5de9d28..79de86b45b 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 a53eefb982..f07e47dd48 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 938ab59ab7..7b1714e30d 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 095/205] 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 29cdcf94aa..3104b4e430 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 421a982077..c2540f5b10 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 096/205] 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 14f52bbbf3..1ba999198f 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 0000000000..0f5b0843e1 --- /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 097/205] 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 bc1ed1685c..7670dcecbe 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 0000000000..069d7c62f1 --- /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 098/205] 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 e8474cff02..f0070b0340 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 0000000000..068bc2424d --- /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 099/205] 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 c915b1dffa..bc55c16411 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 0000000000..cf52174c2f --- /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 100/205] 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 97bf2a63dd..d46d83d6ce 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 0bd4d5e51c..05217cad84 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 44d9fcf9b9..b4b2170097 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 1af3269adb1bceea4e8d362ed5e55438f081ece8 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 22 Sep 2026 21:43:13 +0800 Subject: [PATCH 101/205] fix(groups): clean up modal listeners and pending searches --- .../admin/group/GroupRPMOverridesModal.vue | 6 +++- .../admin/group/GroupRateMultipliersModal.vue | 6 +++- .../__tests__/GroupModal.cleanup.spec.ts | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/admin/group/__tests__/GroupModal.cleanup.spec.ts diff --git a/frontend/src/components/admin/group/GroupRPMOverridesModal.vue b/frontend/src/components/admin/group/GroupRPMOverridesModal.vue index 025054162f..4398780143 100644 --- a/frontend/src/components/admin/group/GroupRPMOverridesModal.vue +++ b/frontend/src/components/admin/group/GroupRPMOverridesModal.vue @@ -206,7 +206,7 @@