From c74a4f521c7b71635b20a2f15e9d2758991de05c Mon Sep 17 00:00:00 2001 From: spin6lock Date: Mon, 31 Aug 2026 19:51:50 +0800 Subject: [PATCH 01/27] =?UTF-8?q?fix(billing):=20=E8=A1=A5=E4=B8=8A=20glm-?= =?UTF-8?q?5.3=20/=20glm-5.3-flash=20=E5=85=9C=E5=BA=95=E4=BB=B7=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A2=AB=20glm-5=20=E5=AD=90=E4=B8=B2?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E6=8A=A2=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/billing_service.go | 26 ++++++++++++++-- .../internal/service/billing_service_test.go | 30 ++++++++++++++++++- frontend/src/composables/useModelWhitelist.ts | 5 +++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 7002fc09cc..bf29bf6537 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -538,7 +538,20 @@ func (s *BillingService) initFallbackPricing() { // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 // GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。 - // GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3 / GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3-Flash 列表价 $0.15/$0.50(2026-09-09 前五折促销,此处按列表价,与其它模型口径一致)。 + s.fallbackPrices["glm-5.3-flash"] = &ModelPricing{ + InputPricePerToken: 0.15e-6, // $0.15 per MTok + OutputPricePerToken: 0.5e-6, // $0.50 per MTok + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["glm-5.3"] = &ModelPricing{ + InputPricePerToken: 1.4e-6, // $1.40 per MTok + OutputPricePerToken: 4.4e-6, // $4.40 per MTok + CacheReadPricePerToken: 0.26e-6, + SupportsCacheBreakdown: false, + } s.fallbackPrices["glm-5.2"] = &ModelPricing{ InputPricePerToken: 1.4e-6, // $1.40 per MTok OutputPricePerToken: 4.4e-6, // $4.40 per MTok @@ -870,9 +883,16 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。 // 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。 - // 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) + // 智谱 GLM(z.ai 公开 SKU:glm-5.3 / glm-5.3-flash / glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) // 匹配顺序:先判别最高 tier,再依次降级。 - // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。 + // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走; + // glm-5.3-flash 必须排在 glm-5.3 之前(前者包含后者子串)。 + if strings.Contains(modelLower, "glm-5.3-flash") || strings.Contains(modelLower, "glm-5.3flash") { + return s.fallbackPrices["glm-5.3-flash"] + } + if strings.Contains(modelLower, "glm-5.3") { + return s.fallbackPrices["glm-5.3"] + } if strings.Contains(modelLower, "glm-5.2") { return s.fallbackPrices["glm-5.2"] } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 99b8ebd8d9..6f001c40db 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -532,6 +532,20 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 智谱 GLM(z.ai USD 口径)---- + { + name: "glm 5.3 flagship", + model: "glm-5.3", + expectedInput: 1.4e-6, + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, + { + name: "glm 5.3 flash", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, { name: "glm 5.2 flagship", model: "glm-5.2", @@ -622,7 +636,21 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedInput: 0.1e-6, expectedOutput: floatPtr(0.1e-6), }, - // 关键:5.1 / 5.2 必须先于 5 匹配(避免被 glm-5 抢走) + // 关键:5.1 / 5.2 / 5.3 必须先于 5 匹配(避免被 glm-5 抢走) + { + name: "glm 5.3-flash vs glm 5.3 ordering (verbatim 5.3-flash)", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, // = glm-5.3-flash 价格(不是 glm-5.3 的 1.4e-6,更不是 glm-5 的 1e-6) + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, + { + name: "glm 5.3 vs glm 5 ordering (verbatim 5.3)", + model: "glm-5.3", + expectedInput: 1.4e-6, // = glm-5.3 价格(不是 glm-5 的 1e-6) + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)", model: "glm-5.1", diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 548044394b..753b578222 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -90,7 +90,10 @@ const antigravityModels = [ const zhipuModels = [ 'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520', 'glm-4-air', 'glm-4-airx', 'glm-4-long', 'glm-4-flash', - 'glm-4v-plus', 'glm-4.5', 'glm-4.6', + 'glm-4v-plus', 'glm-4.5', 'glm-4.5-x', 'glm-4.5-air', 'glm-4.5-flash', + 'glm-4.6', 'glm-4.7', 'glm-4.7-flash', 'glm-4.7-flashx', + 'glm-5', 'glm-5-turbo', 'glm-5.1', 'glm-5.2', + 'glm-5.3', 'glm-5.3-flash', 'glm-3-turbo', 'glm-4-alltools', 'chatglm_turbo', 'chatglm_pro', 'chatglm_std', 'chatglm_lite', 'cogview-3', 'cogvideo' From a7742306616c9596bb564d790d5a21e5a88dd0b0 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:09:21 +0800 Subject: [PATCH 02/27] fix(grok): keep inconclusive billing accounts eligible --- README.md | 2 +- backend/internal/service/account.go | 14 ++++++++++---- .../service/account_grok_media_eligibility_test.go | 8 ++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 169adb28d3..b4e189c54d 100644 --- a/README.md +++ b/README.md @@ -827,7 +827,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco `401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. -New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. +New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations. diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ad1ad82a1b..d36dc6ecff 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1840,9 +1840,10 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } // GrokMediaGenerationEligibility reports whether a Grok account may receive -// new image/video generation requests. OAuth media fails closed unless billing -// observations provide positive paid-entitlement evidence. An explicit -// operator override takes precedence over probe data. +// new image/video generation requests. Explicit evidence of a forbidden or +// free account blocks media, while an incomplete successful billing response +// remains eligible for backwards compatibility. An explicit operator +// override takes precedence over probe data. func (a *Account) GrokMediaGenerationEligibility() (bool, string) { if a == nil || !a.IsGrok() { return false, "not_grok" @@ -1868,7 +1869,12 @@ func (a *Account) GrokMediaGenerationEligibility() (bool, string) { return false, "billing_free_tier" } if !grokBillingHasAuthoritativeQuota(billing) { - return false, "billing_inconclusive" + // Billing endpoints can return 200 with an account-specific schema that + // omits plan/quota fields (for example, some SuperGrok accounts). An + // incomplete observation is not proof of ineligibility; keep the account + // routable and expose the reason for diagnostics. Operators can still + // quarantine a known-bad account with grok_media_eligible=false. + return true, "billing_inconclusive" } return true, "eligible" } diff --git a/backend/internal/service/account_grok_media_eligibility_test.go b/backend/internal/service/account_grok_media_eligibility_test.go index 47d1f2b94f..b53d88dc59 100644 --- a/backend/internal/service/account_grok_media_eligibility_test.go +++ b/backend/internal/service/account_grok_media_eligibility_test.go @@ -46,6 +46,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { {name: "non grok account", account: &Account{Platform: PlatformOpenAI}, want: false, wantReason: "not_grok"}, {name: "non oauth grok account stays eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, want: true, wantReason: "non_oauth"}, {name: "unobserved oauth fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: false, wantReason: "billing_unobserved"}, + {name: "inconclusive successful billing remains eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: &xai.BillingSummary{StatusCode: http.StatusOK, Partial: true}}}, want: true, wantReason: "billing_inconclusive"}, {name: "weekly paid usage is eligible without inferring from period type", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyAllowance}}, want: true, wantReason: "eligible"}, {name: "billing forbidden is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: forbiddenBilling}}, want: false, wantReason: "billing_forbidden"}, {name: "weekly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyForbidden}}, want: false, wantReason: "billing_forbidden"}, @@ -65,7 +66,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { } } -func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing.T) { +func TestGrokMediaCapabilityKeepsUnobservedAndInconclusiveOAuthAsCandidates(t *testing.T) { unobserved := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} eligible, reason := unobserved.GrokMediaGenerationEligibility() require.False(t, eligible) @@ -80,7 +81,10 @@ func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing. Partial: true, }}, } - require.False(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) + eligible, reason = inconclusive.GrokMediaGenerationEligibility() + require.True(t, eligible) + require.Equal(t, "billing_inconclusive", reason) + require.True(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) } func TestGrokMediaCapabilityFiltersOnlyGeneration(t *testing.T) { From a7f1ed8fa49a2f3a7ae9ba81eac45f8d22f426f7 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:59:13 +0800 Subject: [PATCH 03/27] fix(openai-ws): track pending passthrough turns --- .../service/openai_ws_v2/passthrough_relay.go | 18 ++++++- .../openai_ws_v2/passthrough_relay_test.go | 54 +++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index fdb539a073..e443f2320a 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -538,7 +538,7 @@ func runUpstreamToClient( // the upstream has started a Responses turn, success still requires a // terminal protocol event. Treat an early 1000/EOF as a relay failure so // the adapter does not report relay_completed with an active turn. - if graceful && openAIWSRelayActiveTurnID(state) != "" { + if graceful && state.hasUnfinishedTurn() { graceful = false err = errors.New("upstream websocket closed before terminal event: " + err.Error()) } @@ -585,7 +585,14 @@ func runUpstreamToClient( } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: - // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 + // Binary frames remain opaque for usage/result observation, but a JSON + // terminal still settles relay lifecycle. Otherwise the pending-turn + // disconnect guard would turn an already-delivered terminal into a false + // missing-terminal failure when the upstream closes normally. + if isTerminalEvent(strings.TrimSpace(gjson.GetBytes(payload, "type").String())) { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) + } } emitTurnComplete(onTurnComplete, state, observedEvent) if dropDownstreamWrites != nil && dropDownstreamWrites.Load() { @@ -995,6 +1002,13 @@ func (s *relayState) consumePendingTurnStartedAt() time.Time { return *startedAt } +func (s *relayState) hasUnfinishedTurn() bool { + if s == nil { + return false + } + return s.pendingTurnStart.Load() != nil || s.activeTurn != nil +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5505b4981..fc20d7bb08 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -309,11 +309,46 @@ func TestRelay_UpstreamDisconnect(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - // 上游 EOF 属于 disconnect,标记为 graceful - require.Nil(t, relayExit, "上游 EOF 应被视为 graceful disconnect") + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") require.Equal(t, "gpt-4o", result.RequestModel) } +func TestRelay_UpstreamNormalCloseBeforeResponseIDIsFailure(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := &eofReplacementFrameConn{ + FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.created","status":"in_progress"}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.in_progress","status":"in_progress"}`), + }, + }, true), + err: coderws.CloseError{Code: coderws.StatusNormalClosure}, + } + + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.True(t, relayExit.WroteDownstream) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") + require.Empty(t, result.RequestID) + require.Empty(t, result.TerminalEventType) + require.Len(t, clientConn.Writes(), 2) +} + func TestRelay_UpstreamNormalCloseBeforeTerminalIsFailure(t *testing.T) { t.Parallel() @@ -921,7 +956,10 @@ func TestRelay_BinaryFramePassthrough(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - require.Nil(t, relayExit) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") // binary frame 不解析 usage require.Equal(t, 0, result.Usage.InputTokens) @@ -986,7 +1024,12 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { t.Parallel() clientConn := newPassthroughTestFrameConn(nil, false) - upstreamConn := newPassthroughTestFrameConn(nil, true) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageBinary, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary_first_type"}}`), + }, + }, true) firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -1001,6 +1044,9 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { require.Len(t, upstreamWrites, 1) require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType) require.Equal(t, firstPayload, upstreamWrites[0].payload) + clientWrites := clientConn.Writes() + require.Len(t, clientWrites, 1) + require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType) } func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) { From 3bdb869fcad8ce2c6881a3f503c2964ef23d5575 Mon Sep 17 00:00:00 2001 From: Varrge <26441281+varrge@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:54:38 +0800 Subject: [PATCH 04/27] fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices --- .../service/gateway_record_usage_test.go | 36 ++++++++++++++++ backend/internal/service/pricing_service.go | 15 +++---- .../internal/service/pricing_service_test.go | 41 +++++++++---------- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c14bf9c27e..ecb72f985f 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,42 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_GeminiFlashThinkingTierUsesCatalogPrice(t *testing.T) { + for _, baseModel := range []string{"gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.billingService = NewBillingService(svc.cfg, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: {InputCostPerToken: 0.75e-6, OutputCostPerToken: 3.75e-6, CacheReadInputTokenCost: 0.075e-6}, + }}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + group := &Group{ID: 27, Platform: PlatformGemini, RateMultiplier: 0.15} + model := baseModel + "-medium" + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gemini_thinking_tier", + Model: model, + UpstreamModel: model, + Usage: ClaudeUsage{InputTokens: 8498, OutputTokens: 469, CacheReadInputTokens: 159248}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, GroupID: &group.ID, Group: group}, + User: &User{ID: 601}, + Account: &Account{ID: 701, Platform: PlatformGemini, Type: AccountTypeAPIKey}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, model, usageRepo.lastLog.Model) + require.InDelta(t, 0.02007585, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.0030113775, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.0030113775, userRepo.lastAmount, 1e-12) + }) + } +} + func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index de7955e0a1..641943a162 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1190,7 +1190,7 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string normalized := normalizeModelNameForPricing(modelLower) // A tier-specific entry should take precedence when the pricing catalog gains - // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // one later. Antigravity's Gemini Flash thinking tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { @@ -1249,15 +1249,16 @@ func normalizeModelNameForPricing(model string) string { return normalizeGeminiThinkingTierAlias(model) } -// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash // thinking-tier model IDs to the public base model. The tier controls reasoning // behavior, not the published token rate, so this keeps -high/-low/-medium and -// -tiered requests on the same price card as gemini-3.6-flash. +// -tiered requests on the corresponding base model's price card. func normalizeGeminiThinkingTierAlias(model string) string { - const baseModel = "gemini-3.6-flash" - for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { - if model == baseModel+tier { - return baseModel + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } } } return model diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index a02c74fe72..cdad4d3305 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -477,38 +477,37 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } -func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { +func TestPricingService_GeminiFlashThinkingTiersUseBasePricing(t *testing.T) { basePricing := &LiteLLMModelPricing{ InputCostPerToken: 1.5e-6, OutputCostPerToken: 7.5e-6, CacheReadInputTokenCost: 0.15e-6, } - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - }} - - for _, model := range []string{ - "gemini-3.6-flash", - "gemini-3.6-flash-high", - "gemini-3.6-flash-low", - "gemini-3.6-flash-medium", - "gemini-3.6-flash-tiered", - } { - t.Run(model, func(t *testing.T) { - require.Same(t, basePricing, svc.GetModelPricing(model)) - }) + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{baseModel: basePricing}} + for _, tier := range []string{"", "-high", "-low", "-medium", "-tiered"} { + model := baseModel + tier + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + require.Same(t, basePricing, svc.GetIdentifiedModelPricing("models/"+model)) + }) + } } } -func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { +func TestPricingService_GeminiFlashTierSpecificPricingTakesPrecedence(t *testing.T) { basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - "gemini-3.6-flash-low": tierPricing, - }} + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: basePricing, + baseModel + "-low": tierPricing, + }} - require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) + require.Same(t, tierPricing, svc.GetModelPricing("models/"+baseModel+"-low")) + }) + } } func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { From b1ce821c497adabcdce6631577134b6ac23040ec Mon Sep 17 00:00:00 2001 From: Robert Guo Date: Sat, 5 Sep 2026 17:18:01 +0800 Subject: [PATCH 05/27] fix(frontend): preserve Codex manifest edit state --- frontend/src/views/admin/GroupsView.vue | 3 +- .../GroupsView.codexManifest.spec.ts | 286 ++++++++++++++++++ 2 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 04ec052583..c67ce20136 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3302,7 +3302,8 @@ v-if="editForm.platform === 'openai' && editingGroup" ref="editCodexManifestRef" :group-id="editingGroup.id" - v-model="editCodexManifestConfig" + :model-value="editCodexManifestConfig" + @update:model-value="Object.assign(editCodexManifestConfig, $event)" :account-names="editCodexManifestAccountNames" /> diff --git a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts new file mode 100644 index 0000000000..9595d27ed3 --- /dev/null +++ b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts @@ -0,0 +1,286 @@ +import { defineComponent, h, type PropType } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminGroup, CodexModelsManifestConfig } from "@/types"; +import GroupsView from "@/views/admin/GroupsView.vue"; + +const { + listGroups, + getModelsListCandidates, + getUsageSummary, + getCapacitySummary, + getLiveCapability, +} = vi.hoisted(() => ({ + listGroups: vi.fn(), + getModelsListCandidates: vi.fn(), + getUsageSummary: vi.fn(), + getCapacitySummary: vi.fn(), + getLiveCapability: vi.fn(), +})); + +vi.mock("@/api/admin", () => ({ + adminAPI: { + groups: { + list: listGroups, + getAll: vi.fn(), + getModelsListCandidates, + getUsageSummary, + getCapacitySummary, + getLiveCapability, + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + duplicate: vi.fn(), + updateSortOrder: vi.fn(), + }, + accounts: { + list: vi.fn(), + getById: vi.fn(), + }, + }, +})); + +vi.mock("@/stores/app", () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + }), +})); + +vi.mock("@/stores/onboarding", () => ({ + useOnboardingStore: () => ({ + isCurrentStep: vi.fn(() => false), + nextStep: vi.fn(), + }), +})); + +vi.mock("vue-i18n", async () => { + const actual = await vi.importActual("vue-i18n"); + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + }; +}); + +const sourceGroup = { + id: 42, + name: "OpenAI", + description: null, + platform: "openai", + rate_multiplier: 1, + rpm_limit: 0, + is_exclusive: false, + status: "active", + subscription_type: "standard", + daily_limit_usd: null, + weekly_limit_usd: null, + monthly_limit_usd: null, + long_context_pricing_enabled: true, + force_openai_fast: false, + free_openai_fast: false, + model_pricing: [], + profit_control_enabled: false, + profit_min_margin: 0, + profit_safety_buffer: 0, + allow_image_generation: false, + allow_batch_image_generation: false, + image_rate_independent: false, + image_rate_multiplier: 1, + batch_image_discount_multiplier: 0.5, + batch_image_hold_multiplier: 0.6, + image_price_1k: null, + image_price_2k: null, + image_price_4k: null, + video_rate_independent: false, + video_rate_multiplier: 1, + video_price_480p: null, + video_price_720p: null, + video_price_1080p: null, + web_search_price_per_call: null, + search_price_per_1k: null, + audio_realtime_price_per_min: null, + audio_tts_price_per_million_chars: null, + audio_stt_price_per_hour: null, + peak_rate_enabled: false, + peak_start: "", + peak_end: "", + peak_rate_multiplier: 1, + claude_code_only: false, + fallback_group_id: null, + fallback_group_id_on_invalid_request: null, + allow_messages_dispatch: false, + allow_live: false, + require_oauth_only: false, + require_privacy_set: false, + created_at: "2026-09-05T00:00:00Z", + updated_at: "2026-09-05T00:00:00Z", + model_routing: null, + model_routing_enabled: false, + mcp_xml_inject: true, + supported_model_scopes: [], + account_count: 1, + active_account_count: 1, + rate_limited_account_count: 0, + models_list_config: undefined, + codex_models_manifest_config: { + enabled: false, + account_ids: [], + fallback_to_scheduler: false, + }, + sort_order: 10, +} satisfies AdminGroup; + +const AppLayoutStub = defineComponent({ + template: "
", +}); + +const TablePageLayoutStub = defineComponent({ + template: '
', +}); + +const DataTableStub = defineComponent({ + props: { + data: { type: Array, default: () => [] }, + }, + template: '
', +}); + +const BaseDialogStub = defineComponent({ + props: { + show: { type: Boolean, default: false }, + }, + template: '
', +}); + +const CodexManifestAccountsFieldStub = defineComponent({ + name: "CodexManifestAccountsField", + props: { + modelValue: { + type: Object as PropType, + required: true, + }, + }, + emits: ["update:modelValue"], + setup(props, { emit, expose }) { + expose({ validate: () => true, resetValidation: () => undefined }); + return () => + h("div", { "data-testid": "codex-manifest-field" }, [ + h( + "output", + { "data-testid": "codex-manifest-value" }, + JSON.stringify(props.modelValue), + ), + h( + "button", + { + type: "button", + "data-testid": "codex-manifest-enable", + onClick: () => + emit("update:modelValue", { + ...props.modelValue, + enabled: true, + }), + }, + "enable", + ), + h( + "button", + { + type: "button", + "data-testid": "codex-manifest-select-account", + onClick: () => + emit("update:modelValue", { + ...props.modelValue, + account_ids: [17], + }), + }, + "select account", + ), + ]); + }, +}); + +const mountView = () => + mount(GroupsView, { + global: { + stubs: { + AppLayout: AppLayoutStub, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + BaseDialog: BaseDialogStub, + ConfirmDialog: true, + EmptyState: true, + Select: true, + PlatformIcon: true, + Icon: true, + GroupCapacityBadge: true, + GroupRateMultipliersModal: true, + GroupRPMOverridesModal: true, + ReasoningEffortPolicyFields: true, + CodexManifestAccountsField: CodexManifestAccountsFieldStub, + PricingEntryCard: true, + VueDraggable: true, + }, + }, + }); + +describe("GroupsView Codex manifest binding", () => { + beforeEach(() => { + localStorage.clear(); + listGroups.mockReset(); + getModelsListCandidates.mockReset(); + getUsageSummary.mockReset(); + getCapacitySummary.mockReset(); + getLiveCapability.mockReset(); + + listGroups.mockResolvedValue({ + items: [sourceGroup], + total: 1, + page: 1, + page_size: 20, + pages: 1, + }); + getModelsListCandidates.mockResolvedValue([]); + getUsageSummary.mockResolvedValue([]); + getCapacitySummary.mockResolvedValue([]); + getLiveCapability.mockResolvedValue({ supported: false }); + }); + + it("preserves consecutive child updates on the reactive edit config", async () => { + const wrapper = mountView(); + await flushPromises(); + + const editButton = wrapper + .findAll("button") + .find((button) => button.text().includes("common.edit")); + expect(editButton).toBeTruthy(); + await editButton!.trigger("click"); + await flushPromises(); + + expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toBe( + JSON.stringify(sourceGroup.codex_models_manifest_config), + ); + + await wrapper.get('[data-testid="codex-manifest-enable"]').trigger("click"); + await flushPromises(); + expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toContain( + '"enabled":true', + ); + + await wrapper + .get('[data-testid="codex-manifest-select-account"]') + .trigger("click"); + await flushPromises(); + expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toBe( + JSON.stringify({ + enabled: true, + account_ids: [17], + fallback_to_scheduler: false, + }), + ); + + wrapper.unmount(); + }); +}); From 00eabe8ab9dd9de16cf07f62a165b98671c40a97 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 5 Sep 2026 19:20:10 +0800 Subject: [PATCH 06/27] feat(accounts): add month and year expiry presets --- .../components/account/CreateAccountModal.vue | 9 ++++ .../components/account/EditAccountModal.vue | 9 ++++ .../__tests__/CreateAccountModal.spec.ts | 46 ++++++++++++++++++- .../__tests__/EditAccountModal.spec.ts | 46 ++++++++++++++++++- .../account/__tests__/accountExpiry.spec.ts | 19 ++++++++ .../src/components/account/accountExpiry.ts | 7 +++ 6 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/account/__tests__/accountExpiry.spec.ts create mode 100644 frontend/src/components/account/accountExpiry.ts diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 451ac2a1ef..f93c486833 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2961,6 +2961,14 @@
+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -3868,6 +3876,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { OPENAI_WS_MODE_CTX_POOL, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8968bdc2e0..684181c6af 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1617,6 +1617,14 @@

+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -2964,6 +2972,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index c13a69fbdd..a1a4d80a3e 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -1,6 +1,6 @@ import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, @@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) + afterEach(() => vi.useRealTimers()) + + it('sets month and year expiry presets without submitting the account form', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-01-31T12:34:00')) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2026-02-28T12:34'], + ['payment.oneYear', '2027-01-31T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(createAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('allows a manually entered expiry to override a preset before account creation', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await selectButtonByText(wrapper, 'payment.oneMonth') + await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000) + wrapper.unmount() + }) + it('hides only the redundant account toggle when every selected group enables tier pricing', async () => { authIsSimpleMode.value = false const wrapper = mountModal([ diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7d50e0f91f..5c85b7c2af 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' @@ -328,6 +328,50 @@ describe('EditAccountModal', () => { authIsSimpleMode.value = true }) + afterEach(() => vi.useRealTimers()) + + it('sets expiry presets from now instead of extending the saved expiry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2028-02-29T12:34:00')) + const account = buildAccount() + account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000 + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2028-03-29T12:34'], + ['payment.oneYear', '2029-02-28T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(updateAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('can clear a selected expiry preset before saving the account', async () => { + const account = buildAccount() + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')! + await button.trigger('click') + const input = wrapper.get('input[type="datetime-local"]') + expect(input.element.value).not.toBe('') + await input.setValue('') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0) + wrapper.unmount() + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/components/account/__tests__/accountExpiry.spec.ts b/frontend/src/components/account/__tests__/accountExpiry.spec.ts new file mode 100644 index 0000000000..7d243a8434 --- /dev/null +++ b/frontend/src/components/account/__tests__/accountExpiry.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { getAccountExpiryTimestamp } from '../accountExpiry' + +describe('getAccountExpiryTimestamp', () => { + it.each([ + ['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'], + ['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'], + ['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'], + ['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'], + // With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date. + ['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'], + ])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => { + const now = new Date(input) + const originalTimestamp = now.getTime() + + expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000) + expect(now.getTime()).toBe(originalTimestamp) + }) +}) diff --git a/frontend/src/components/account/accountExpiry.ts b/frontend/src/components/account/accountExpiry.ts new file mode 100644 index 0000000000..33665d340c --- /dev/null +++ b/frontend/src/components/account/accountExpiry.ts @@ -0,0 +1,7 @@ +/** Set an expiry from local calendar months, clamping month-end dates. */ +export function getAccountExpiryTimestamp(months: number, now = new Date()): number { + const expiry = new Date(now.getTime()) + const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate() + expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay)) + return Math.floor(expiry.getTime() / 1000) +} From 3aeab296d8ec9a07bdaacdab80cfcaf011f6c15d Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 5 Sep 2026 19:55:03 +0800 Subject: [PATCH 07/27] fix(onboarding): remove button text shadows --- frontend/src/styles/onboarding.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/styles/onboarding.css b/frontend/src/styles/onboarding.css index 4894c4d723..1a12b1e19d 100644 --- a/frontend/src/styles/onboarding.css +++ b/frontend/src/styles/onboarding.css @@ -187,6 +187,7 @@ padding: 8px 16px !important; font-size: 13px !important; font-weight: 500 !important; + text-shadow: none !important; border-radius: 6px !important; cursor: pointer !important; transition: all 0.2s !important; From 666a5c6f877a306c4743310c04dc5f400656f7a5 Mon Sep 17 00:00:00 2001 From: TT <92159997+ssharkkky@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:38:50 +0800 Subject: [PATCH 08/27] fix(redeem): use a fixed 10-minute failure window The redeem failure counter used INCR followed by Expire(24h) on every attempt, so each failed attempt refreshed the key TTL and the window never actually closed for a user who kept retrying within 24h. - Count attempts in a fixed 10-minute window via a small Lua script (INCR, then PEXPIRE only when the key is created or has no TTL), which is atomic and also repairs keys that lost their TTL. - Move the counter to the redeem:ratelimit:v2: key space so pre-existing 24h counters do not seed the new window. - Rename the service threshold to redeemMaxFailedAttempts (30) to reflect that it bounds attempts per window, not per hour. Public redeem behavior is unchanged apart from the window semantics: the check fails open when the counter read fails, and a failed increment never masks the underlying domain error. --- backend/internal/repository/redeem_cache.go | 26 ++- .../redeem_cache_integration_test.go | 30 ++- .../internal/repository/redeem_cache_test.go | 8 +- .../service/redeem_rate_limit_test.go | 176 ++++++++++++++++++ backend/internal/service/redeem_service.go | 5 +- 5 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 backend/internal/service/redeem_rate_limit_test.go diff --git a/backend/internal/repository/redeem_cache.go b/backend/internal/repository/redeem_cache.go index 831aaf570a..9d0430724a 100644 --- a/backend/internal/repository/redeem_cache.go +++ b/backend/internal/repository/redeem_cache.go @@ -10,11 +10,22 @@ import ( ) const ( - redeemRateLimitKeyPrefix = "redeem:ratelimit:" + redeemRateLimitKeyPrefix = "redeem:ratelimit:v2:" redeemLockKeyPrefix = "redeem:lock:" - redeemRateLimitDuration = 24 * time.Hour + redeemRateLimitWindow = 10 * time.Minute ) +var incrementRedeemAttemptScript = redis.NewScript(` +local current = redis.call('INCR', KEYS[1]) +local ttl = redis.call('PTTL', KEYS[1]) + +if current == 1 or ttl == -1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end + +return current +`) + // redeemRateLimitKey generates the Redis key for redeem attempt rate limiting. func redeemRateLimitKey(userID int64) string { return fmt.Sprintf("%s%d", redeemRateLimitKeyPrefix, userID) @@ -44,11 +55,12 @@ func (c *redeemCache) GetRedeemAttemptCount(ctx context.Context, userID int64) ( func (c *redeemCache) IncrementRedeemAttemptCount(ctx context.Context, userID int64) error { key := redeemRateLimitKey(userID) - pipe := c.rdb.Pipeline() - pipe.Incr(ctx, key) - pipe.Expire(ctx, key, redeemRateLimitDuration) - _, err := pipe.Exec(ctx) - return err + return incrementRedeemAttemptScript.Run( + ctx, + c.rdb, + []string{key}, + redeemRateLimitWindow.Milliseconds(), + ).Err() } func (c *redeemCache) AcquireRedeemLock(ctx context.Context, code string, ttl time.Duration) (bool, error) { diff --git a/backend/internal/repository/redeem_cache_integration_test.go b/backend/internal/repository/redeem_cache_integration_test.go index 6398a80141..fb7e207431 100644 --- a/backend/internal/repository/redeem_cache_integration_test.go +++ b/backend/internal/repository/redeem_cache_integration_test.go @@ -39,7 +39,35 @@ func (s *RedeemCacheSuite) TestIncrementAndGetRedeemAttemptCount() { ttl, err := s.rdb.TTL(s.ctx, key).Result() require.NoError(s.T(), err, "TTL") - s.AssertTTLWithin(ttl, 1*time.Second, redeemRateLimitDuration) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) +} + +func (s *RedeemCacheSuite) TestIncrementDoesNotRefreshFixedWindow() { + userID := int64(3) + key := redeemRateLimitKey(userID) + shortTTL := 5 * time.Minute + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + require.NoError(s.T(), s.rdb.PExpire(s.ctx, key, shortTTL).Err()) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, shortTTL-time.Second, shortTTL) +} + +func (s *RedeemCacheSuite) TestIncrementRepairsMissingTTL() { + userID := int64(4) + key := redeemRateLimitKey(userID) + require.NoError(s.T(), s.rdb.Set(s.ctx, key, 5, 0).Err()) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + require.Equal(s.T(), time.Duration(-1), ttl) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID) + require.NoError(s.T(), err) + require.Equal(s.T(), 6, count) + ttl, err = s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) } func (s *RedeemCacheSuite) TestMultipleIncrements() { diff --git a/backend/internal/repository/redeem_cache_test.go b/backend/internal/repository/redeem_cache_test.go index 9b547b7485..8e637241ef 100644 --- a/backend/internal/repository/redeem_cache_test.go +++ b/backend/internal/repository/redeem_cache_test.go @@ -18,22 +18,22 @@ func TestRedeemRateLimitKey(t *testing.T) { { name: "normal_user_id", userID: 123, - expected: "redeem:ratelimit:123", + expected: "redeem:ratelimit:v2:123", }, { name: "zero_user_id", userID: 0, - expected: "redeem:ratelimit:0", + expected: "redeem:ratelimit:v2:0", }, { name: "negative_user_id", userID: -1, - expected: "redeem:ratelimit:-1", + expected: "redeem:ratelimit:v2:-1", }, { name: "max_int64", userID: math.MaxInt64, - expected: "redeem:ratelimit:9223372036854775807", + expected: "redeem:ratelimit:v2:9223372036854775807", }, } diff --git a/backend/internal/service/redeem_rate_limit_test.go b/backend/internal/service/redeem_rate_limit_test.go new file mode 100644 index 0000000000..7beb302935 --- /dev/null +++ b/backend/internal/service/redeem_rate_limit_test.go @@ -0,0 +1,176 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// redeemRateLimitCacheStub 实现 RedeemCache,记录各方法调用次数并允许注入错误。 +type redeemRateLimitCacheStub struct { + count int + getErr error + incrementErr error + getCalls int + incrementCalls int + acquireCalls int + releaseCalls int +} + +func (c *redeemRateLimitCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) { + c.getCalls++ + return c.count, c.getErr +} + +func (c *redeemRateLimitCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error { + c.incrementCalls++ + if c.incrementErr != nil { + return c.incrementErr + } + c.count++ + return nil +} + +func (c *redeemRateLimitCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) { + c.acquireCalls++ + return true, nil +} + +func (c *redeemRateLimitCacheStub) ReleaseRedeemLock(context.Context, string) error { + c.releaseCalls++ + return nil +} + +func TestPublicRedeemAllowsLastAttemptBeforeLimit(t *testing.T) { + cache := &redeemRateLimitCacheStub{count: redeemMaxFailedAttempts - 1} + redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, redeemMaxFailedAttempts, cache.count) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestPublicRedeemCountsOnlyConfiguredDomainFailures(t *testing.T) { + past := time.Now().Add(-time.Minute) + tests := []struct { + name string + code RedeemCode + input string + wantErr error + }{ + { + name: "not found", + code: RedeemCode{Code: "OTHER"}, + input: "MISSING", + wantErr: ErrRedeemCodeNotFound, + }, + { + name: "expired", + code: RedeemCode{ + ID: 1, Code: "EXPIRED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUnused, ExpiresAt: &past, + }, + input: "EXPIRED", + wantErr: ErrRedeemCodeExpired, + }, + { + name: "used", + code: RedeemCode{ + ID: 2, Code: "USED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUsed, + }, + input: "USED", + wantErr: ErrRedeemCodeUsed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := &redeemRateLimitCacheStub{} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: tt.code}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, tt.input) + + require.Nil(t, result) + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.count) + }) + } +} + +func TestPublicRedeemRateLimitFailsOpenWhenCounterReadFails(t *testing.T) { + cache := &redeemRateLimitCacheStub{ + count: redeemMaxFailedAttempts, + getErr: errors.New("redis unavailable"), + } + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) +} + +func TestPublicRedeemCounterWriteFailurePreservesDomainError(t *testing.T) { + cache := &redeemRateLimitCacheStub{incrementErr: errors.New("redis unavailable")} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.incrementCalls) + require.Zero(t, cache.count) +} + +func TestSuccessfulPublicRedeemDoesNotMutateFailureCounter(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + userID := int64(42) + code := &RedeemCode{ + ID: 101, Code: "PUBLIC-SUCCESS", Type: RedeemTypeBalance, Value: 10, Status: StatusUnused, + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{code.Code: code}, + } + userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } + cache := &redeemRateLimitCacheStub{count: 7} + svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) + + result, err := svc.Redeem(ctx, userID, code.Code) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 7, cache.count) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Len(t, redeemRepo.useCalls, 1) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 8794872e3d..b5f59cce14 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -25,8 +25,7 @@ var ( ) const ( - redeemMaxErrorsPerHour = 20 - redeemRateLimitDuration = time.Hour + redeemMaxFailedAttempts = 30 redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) @@ -337,7 +336,7 @@ func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) return nil } - if count >= redeemMaxErrorsPerHour { + if count >= redeemMaxFailedAttempts { return ErrRedeemRateLimited } From 95023e7d49cde9f740a4875b3c5d2582ea230628 Mon Sep 17 00:00:00 2001 From: TT <92159997+ssharkkky@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:11:56 +0800 Subject: [PATCH 09/27] fix(backup): serialize pg_dump/psql with the migrations advisory lock --- backend/cmd/server/wire_gen.go | 2 +- .../internal/repository/backup_pg_dumper.go | 95 +++++++++-- .../repository/backup_pg_dumper_test.go | 157 ++++++++++++++++++ 3 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 backend/internal/repository/backup_pg_dumper_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5ec4892e16..8f4a0482bd 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -210,7 +210,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dataManagementService := service.NewDataManagementService() dataManagementHandler := admin.NewDataManagementHandler(dataManagementService) backupObjectStoreFactory := repository.NewS3BackupStoreFactory() - dbDumper := repository.NewPgDumper(configConfig) + dbDumper := repository.NewPgDumper(configConfig, db) backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db) imageStorageFactory := repository.ProvideImageStorageFactory() imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) diff --git a/backend/internal/repository/backup_pg_dumper.go b/backend/internal/repository/backup_pg_dumper.go index e9a92ef29d..01510bf831 100644 --- a/backend/internal/repository/backup_pg_dumper.go +++ b/backend/internal/repository/backup_pg_dumper.go @@ -2,9 +2,14 @@ package repository import ( "context" + "database/sql" + "database/sql/driver" + "errors" "fmt" "io" "os/exec" + "sync" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" @@ -12,16 +17,39 @@ import ( // PgDumper implements service.DBDumper using pg_dump/psql type PgDumper struct { - cfg *config.DatabaseConfig + cfg *config.DatabaseConfig + db *sql.DB + commandContext func(context.Context, string, ...string) *exec.Cmd } // NewPgDumper creates a new PgDumper -func NewPgDumper(cfg *config.Config) service.DBDumper { - return &PgDumper{cfg: &cfg.Database} +func NewPgDumper(cfg *config.Config, db *sql.DB) service.DBDumper { + return &PgDumper{ + cfg: &cfg.Database, + db: db, + commandContext: exec.CommandContext, + } } // Dump executes pg_dump and returns a streaming reader of the output func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { + if d.db == nil { + return nil, errors.New("acquire backup migration lock: nil sql db") + } + lockConn, err := d.db.Conn(ctx) + if err != nil { + return nil, fmt.Errorf("acquire backup migration lock connection: %w", err) + } + if err := pgAdvisoryLock(ctx, lockConn); err != nil { + // Lock acquisition can fail after PostgreSQL accepted the request. Discard + // the session because ownership is ambiguous and advisory locks are session-scoped. + discardSQLConnection(lockConn) + return nil, fmt.Errorf("acquire backup migration lock: %w", err) + } + releaseLock := func() error { + return releaseBackupMigrationLock(lockConn) + } + args := []string{ "-h", d.cfg.Host, "-p", fmt.Sprintf("%d", d.cfg.Port), @@ -33,7 +61,11 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { "--if-exists", } - cmd := exec.CommandContext(ctx, "pg_dump", args...) + commandContext := d.commandContext + if commandContext == nil { + commandContext = exec.CommandContext + } + cmd := commandContext(ctx, "pg_dump", args...) if d.cfg.Password != "" { cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password) } @@ -43,15 +75,34 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { stdout, err := cmd.StdoutPipe() if err != nil { - return nil, fmt.Errorf("create stdout pipe: %w", err) + return nil, errors.Join(fmt.Errorf("create stdout pipe: %w", err), releaseLock()) } if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start pg_dump: %w", err) + _ = stdout.Close() + return nil, errors.Join(fmt.Errorf("start pg_dump: %w", err), releaseLock()) } - // 返回一个 ReadCloser:读 stdout,关闭时等待进程退出 - return &cmdReadCloser{ReadCloser: stdout, cmd: cmd}, nil + return &cmdReadCloser{ReadCloser: stdout, cmd: cmd, release: releaseLock}, nil +} + +func releaseBackupMigrationLock(conn *sql.Conn) error { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil { + // A failed unlock must not return a possibly lock-owning session to the pool. + discardSQLConnection(conn) + return fmt.Errorf("release backup migration lock: %w", err) + } + if err := conn.Close(); err != nil { + return fmt.Errorf("close backup migration lock connection: %w", err) + } + return nil +} + +func discardSQLConnection(conn *sql.Conn) { + _ = conn.Raw(func(any) error { return driver.ErrBadConn }) + _ = conn.Close() } // Restore executes psql to restore from a streaming reader @@ -84,15 +135,27 @@ func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error { // cmdReadCloser wraps a command stdout pipe and waits for the process on Close type cmdReadCloser struct { io.ReadCloser - cmd *exec.Cmd + cmd *exec.Cmd + release func() error + closeOnce sync.Once + closeErr error } func (c *cmdReadCloser) Close() error { - // Close the pipe first - _ = c.ReadCloser.Close() - // Wait for the process to exit - if err := c.cmd.Wait(); err != nil { - return fmt.Errorf("pg_dump exited with error: %w", err) - } - return nil + c.closeOnce.Do(func() { + var closeErrs []error + if err := c.ReadCloser.Close(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("close pg_dump stdout: %w", err)) + } + if err := c.cmd.Wait(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("pg_dump exited with error: %w", err)) + } + if c.release != nil { + if err := c.release(); err != nil { + closeErrs = append(closeErrs, err) + } + } + c.closeErr = errors.Join(closeErrs...) + }) + return c.closeErr } diff --git a/backend/internal/repository/backup_pg_dumper_test.go b/backend/internal/repository/backup_pg_dumper_test.go new file mode 100644 index 0000000000..586182b70f --- /dev/null +++ b/backend/internal/repository/backup_pg_dumper_test.go @@ -0,0 +1,157 @@ +package repository + +import ( + "context" + "errors" + "io" + "os/exec" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newTestPgDumper(t *testing.T, commandContext func(context.Context, string, ...string) *exec.Cmd) (*PgDumper, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return &PgDumper{ + cfg: &config.DatabaseConfig{ + Host: "db.example.test", + Port: 5432, + User: "sub2api", + Password: "secret", + DBName: "sub2api", + SSLMode: "require", + }, + db: db, + commandContext: commandContext, + }, mock +} + +func expectBackupMigrationLock(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true)) +} + +func expectBackupMigrationUnlock(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) +} + +func TestPgDumperHoldsMigrationLockThroughReaderClose(t *testing.T) { + var mock sqlmock.Sqlmock + commandCreated := false + dumper, createdMock := newTestPgDumper(t, func(ctx context.Context, name string, args ...string) *exec.Cmd { + commandCreated = true + require.Equal(t, "pg_dump", name) + require.Contains(t, args, "--clean") + require.NoError(t, mock.ExpectationsWereMet(), "migration lock must be acquired before pg_dump is created") + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + mock = createdMock + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + require.True(t, commandCreated) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "backup-data", string(data)) + + expectBackupMigrationUnlock(mock) + require.Error(t, mock.ExpectationsWereMet(), "migration lock was released before the reader closed") + require.NoError(t, reader.Close()) + require.NoError(t, mock.ExpectationsWereMet()) + require.NoError(t, reader.Close(), "reader close must be idempotent") +} + +func TestPgDumperReleasesMigrationLockWhenStdoutPipeSetupFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", "true") + cmd.Stdout = io.Discard + return cmd + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "create stdout pipe") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessStartFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "/path/that/does/not/exist/pg_dump") + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "start pg_dump") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf partial-backup; exit 7") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "partial-backup", string(data)) + expectBackupMigrationUnlock(mock) + require.ErrorContains(t, reader.Close(), "pg_dump exited with error") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReportsUnlockFailureAndDiscardsConnection(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + _, err = io.ReadAll(reader) + require.NoError(t, err) + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("unlock unavailable")) + require.ErrorContains(t, reader.Close(), "release backup migration lock") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperDoesNotStartProcessWhenMigrationLockFails(t *testing.T) { + commandCreated := false + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + commandCreated = true + return exec.CommandContext(ctx, "sh", "-c", "true") + }) + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("database unavailable")) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "acquire backup migration lock") + require.False(t, commandCreated) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperRejectsNilDatabase(t *testing.T) { + dumper := &PgDumper{cfg: &config.DatabaseConfig{}} + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "nil sql db") +} From 58e35a4f327e82fe4f4c9de9e53c0e53a7fd7bad Mon Sep 17 00:00:00 2001 From: WUJI-Labs Date: Sun, 6 Sep 2026 00:25:21 +0800 Subject: [PATCH 10/27] fix(antigravity): always set toolConfig for reasoning models without tools Upstream returns 400 INVALID_ARGUMENT when toolConfig is absent. The guard `if !isReasoning || len(tools) > 0` skipped it for reasoning models called without tools, contradicting the intent stated in the comment above it. Set toolConfig unconditionally and cover the three cases with a unit test. --- .../pkg/antigravity/request_transformer.go | 29 ++++++++------- .../antigravity/request_transformer_test.go | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1750848348..5f9e74bc6f 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -148,21 +148,20 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map } // 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig - isReasoning := IsGeminiReasoningModel(targetModel) - if !isReasoning || len(tools) > 0 { - // 总是设置 toolConfig,与官方客户端一致 - innerRequest.ToolConfig = &GeminiToolConfig{ - FunctionCallingConfig: &GeminiFunctionCallingConfig{ - Mode: "VALIDATED", - }, - } - // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 - // includeServerSideToolInvocations,否则返回 400(issue #5709)。 - // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 - if hasMixedToolInvocations(tools) { - enabled := true - innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled - } + // toolConfig must always be present: upstream rejects requests without it, + // including reasoning models called without any tools. + // 总是设置 toolConfig,与官方客户端一致 + innerRequest.ToolConfig = &GeminiToolConfig{ + FunctionCallingConfig: &GeminiFunctionCallingConfig{ + Mode: "VALIDATED", + }, + } + // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 + // includeServerSideToolInvocations,否则返回 400(issue #5709)。 + // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 + if hasMixedToolInvocations(tools) { + enabled := true + innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled } if systemInstruction != nil { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 05764ef7ff..62d038640c 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -621,3 +621,38 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { require.NotContains(t, raw, "includeServerSideToolInvocations") }) } + +// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for +// reasoning models without any tools: upstream rejects requests that omit it. +func TestToolConfigAlwaysPresent(t *testing.T) { + cases := []struct { + name string + model string + tools []ClaudeTool + }{ + {name: "reasoning model without tools", model: "gemini-3.1-pro-high"}, + {name: "reasoning model with tools", model: "gemini-3.1-pro-high", tools: []ClaudeTool{{ + Name: "web_search", + Description: "Search the web", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }}}, + {name: "non-reasoning model without tools", model: "gemini-3.1-pro"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: tc.model, + Messages: []ClaudeMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Tools: tc.tools, + } + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", tc.model, DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.NotNil(t, req.Request.ToolConfig, "toolConfig must be present") + require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) + require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode) + }) + } +} From 7ae0312091520ce46aea4fe0016f57e14c17d730 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Fri, 4 Sep 2026 19:05:14 +0800 Subject: [PATCH 11/27] =?UTF-8?q?fix(claude):=20=F0=9F=90=9B=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20thinking=20block=20binding=20beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- backend/internal/pkg/claude/constants.go | 12 +++++++----- .../gateway_context_management_test.go | 19 +++++++++++++++++++ backend/internal/service/gateway_request.go | 13 +++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index df9b7613b2..b9b5e51b2b 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -20,11 +20,12 @@ const ( BetaFastMode = "fast-mode-2026-02-01" // 新增(对齐官方 CLI 2.1.9x 以来的流量) - BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" - BetaEffort = "effort-2025-11-24" - BetaRedactThinking = "redact-thinking-2026-02-12" - BetaContextManagement = "context-management-2025-06-27" - BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" + BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" + BetaEffort = "effort-2025-11-24" + BetaRedactThinking = "redact-thinking-2026-02-12" + BetaContextManagement = "context-management-2025-06-27" + BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01" + BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" // server-side refusal fallback beta 字段族(beta Messages API 专有)。 // 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks / @@ -99,6 +100,7 @@ func FullClaudeCodeMimicryBetas() []string { BetaPromptCachingScope, BetaEffort, BetaContextManagement, + BetaThinkingBindingControls, BetaExtendedCacheTTL, } } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 57ae438309..0e48f2cb91 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -114,6 +114,23 @@ func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) { require.Empty(t, out) } +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingKeptWhenBetaPresent(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaThinkingBindingControls) + require.False(t, changed) + require.Equal(t, "drop_block", + gjson.GetBytes(out, "thinking.block_binding.prefix_mismatch_behavior").String()) +} + +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingStrippedWhenBetaMissing(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaContextManagement) + require.True(t, changed) + require.False(t, gjson.GetBytes(out, "thinking.block_binding").Exists()) + require.Equal(t, "adaptive", gjson.GetBytes(out, "thinking.type").String()) + require.Equal(t, "summarized", gjson.GetBytes(out, "thinking.display").String()) +} + // ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。 // 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta; // 即使 model 是 haiku,sanitize 也不应剥离功能字段。 @@ -145,6 +162,8 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement "OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27") require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) + require.True(t, anthropicBetaTokensContains(final, claude.BetaThinkingBindingControls), + "OAuth mimic 必须注入 thinking block binding 所需的 beta") } func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) { diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 9a3ca9fb88..ab12f4ad22 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -912,6 +912,12 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27" // FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta), // 若不 strip,body 字段与 header 不对称 → 所有模型 400 // +// thinking.block_binding 场景: +// - Claude Fable 5.1 的会话前缀绑定控制受 +// `thinking-binding-controls-2026-08-01` beta 保护 +// - 缺 token 时上游拒收: +// "thinking.adaptive.block_binding: Extra inputs are not permitted" +// // 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段: // 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。 // 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。 @@ -935,6 +941,13 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string) body, changed = b, true } + // thinking.block_binding:需要 thinking-binding-controls beta。 + if b, deleted := stripAnthropicBodyFieldUnlessBeta( + body, "thinking.block_binding", anthropicBetaHeader, claude.BetaThinkingBindingControls, + ); deleted { + body, changed = b, true + } + // fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。 if b, deleted := stripAnthropicBodyFieldUnlessBeta( body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback, From 8a469478659b0a87405193afa89df0e0a9f3302f Mon Sep 17 00:00:00 2001 From: DAlaoli <265052293+gebdalaoli-arch@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:50:01 +0800 Subject: [PATCH 12/27] Fix stale official Astra input modalities --- .../service/openai_codex_astra_image_test.go | 72 +++++++++++++++++++ .../service/openai_codex_models_service.go | 25 ++++++- 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 backend/internal/service/openai_codex_astra_image_test.go diff --git a/backend/internal/service/openai_codex_astra_image_test.go b/backend/internal/service/openai_codex_astra_image_test.go new file mode 100644 index 0000000000..ec4cd3eeea --- /dev/null +++ b/backend/internal/service/openai_codex_astra_image_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildCodexModelsManifestForGroupCorrectsOfficialAstraStaleModalities(t *testing.T) { + t.Parallel() + + const groupID int64 = 780 + account := Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupPreservesCompatibleAstraTextOnlyMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 781 + account := Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://relay.example.test/v1"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..442d2853ce 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -888,13 +888,15 @@ func buildCodexModelsManifest( seen[modelID] = struct{}{} descriptor := newConfiguredCodexModelDescriptor(metadataModelID) descriptor.Slug = modelID - if imageInputModels[modelID] { - descriptor.InputModalities = []string{"text", "image"} - } descriptor.SupportsSearchTool = searchToolModels[modelID] if metadata, ok := modelMetadata[modelID]; ok { applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) } + if imageInputModels[modelID] { + // Apply the capability-derived modality after upstream metadata so + // a stale official Astra snapshot cannot downgrade the catalog. + descriptor.InputModalities = []string{"text", "image"} + } if metadataModelID != modelID { descriptor.DisplayName = modelID descriptor.Description = configuredCodexCustomDescription @@ -1186,6 +1188,13 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) case PlatformOpenAI: 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 + // text-only modality list. Keep explicit provider metadata + // authoritative for compatible hosts, but repair that stale + // official snapshot at the capability boundary. + if isOpenAIGPT6AstraModel(upstreamModel) && isOfficialOpenAICodexAccount(account) { + return true + } return stringSliceContains(modalities, "image") } } @@ -1212,6 +1221,16 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) } } +func isOfficialOpenAICodexAccount(account *Account) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } + if account.IsOpenAIOAuth() { + return true + } + return account.IsOpenAIApiKey() && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()) +} + func isGrokCodexImageInputModel(model string) bool { switch strings.ToLower(strings.TrimSpace(model)) { case "grok-4.3", From f49a3935667e1a081cd0d2f5b9669cd429c2a597 Mon Sep 17 00:00:00 2001 From: cyh Date: Sun, 6 Sep 2026 02:02:49 +0800 Subject: [PATCH 13/27] fix(openai): retain default models in mixed account catalogs --- backend/internal/handler/gateway_handler.go | 16 +-- .../internal/handler/gateway_models_test.go | 103 +++++++++++++++++- .../openai_codex_models_handler_test.go | 58 +++++++++- backend/internal/service/gateway_service.go | 4 + backend/internal/service/group_models_list.go | 22 +++- .../service/openai_codex_models_service.go | 6 +- .../openai_codex_models_service_test.go | 6 +- 7 files changed, 191 insertions(+), 24 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 941a73f53c..a513fff8c9 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1138,7 +1138,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, service.PlatformComposite, availableModels) + writeModelsList(c, service.PlatformComposite, availableModels) return } if len(availableModels) > 0 { @@ -1154,7 +1154,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { fallbackModels := defaultModelIDsForPlatform(platform) availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, platform, availableModels) + writeModelsList(c, platform, availableModels) return } @@ -1295,6 +1295,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * } func writeModelsList(c *gin.Context, platform string, modelIDs []string) { + if platform == service.PlatformOpenAI { + writeOpenAIModelsList(c, modelIDs) + return + } if platform == service.PlatformGrok { writeGrokModelsList(c, modelIDs) return @@ -1314,14 +1318,6 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) { }) } -func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) { - if platform == service.PlatformOpenAI { - writeOpenAIModelsList(c, modelIDs) - return - } - writeModelsList(c, platform, modelIDs) -} - type grokReasoningEffortOption struct { Value string `json:"value"` Label string `json:"label"` diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 386954a17e..a840d6dc0b 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { } } -// Scenario: Composite manifests aggregate only administrator-configured models. +// Composite manifests include defaults from unmapped accounts and explicit mappings. func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 120 @@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T require.Equal(t, http.StatusOK, rec.Code) var got codexModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) + want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil) + require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models)) +} + +func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 28 + const sparkModel = "gpt-5.3-codex-spark" + const alias = "team-coder" + parentID := int64(1) + accounts := []service.Account{ + {ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + { + ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}}, + }, + } + tests := []struct { + name string + accounts []service.Account + config service.GroupModelsListConfig + want []string + }{ + { + name: "unmapped parent and Spark shadow retain defaults and aliases", + accounts: accounts, + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped API key account also contributes defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...), + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped accounts alone retain default response shape", + accounts: accounts[:1], + want: openai.DefaultModelIDs(), + }, + { + name: "custom list can select defaults and aliases", + accounts: accounts, + config: service.GroupModelsListConfig{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}}, + want: []string{alias, "gpt-5.6-sol", sparkModel}, + }, + { + name: "unavailable custom selection remains empty", + accounts: accounts, + config: service.GroupModelsListConfig{Enabled: true, Models: []string{"unknown-model"}}, + want: []string{}, + }, + { + name: "mapped accounts alone do not gain defaults", + accounts: accounts[1:], + want: []string{sparkModel, alias}, + }, + { + name: "unmapped accounts from another platform do not add defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...), + want: []string{sparkModel, alias}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: tt.accounts}, + }) + for range 2 { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelsListConfig: tt.config}, + }) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "list", got.Object) + require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data)) + for _, model := range got.Data { + require.Equal(t, "model", model.Object, model.ID) + require.Positive(t, model.Created, model.ID) + require.Equal(t, "openai", model.OwnedBy, model.ID) + require.Empty(t, model.CreatedAt, model.ID) + } + if tt.config.Enabled { + require.Equal(t, tt.want, modelIDsForTest(got.Data)) + } + } + }) + } + require.Empty(t, accounts[0].GetModelMapping()) + require.True(t, accounts[0].IsModelSupported("gpt-future-model")) + require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol")) } func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 89d3763602..b0bf5e3094 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -292,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { require.True(t, sawGroupB) } -// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 -func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { +func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) { gin.SetMode(gin.TestMode) groupID := int64(44) repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ @@ -354,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) } - if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { - t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol") + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3") + for _, model := range envelope.Models { + require.Contains(t, model, "supported_reasoning_levels") } - if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { - t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) +} + +func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const sparkModel = "gpt-5.3-codex-spark" + parentID := int64(1) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + }, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ID: 45, Platform: service.PlatformOpenAI} + first := performCodexModelsRequestForGroup(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + slugs := codexHandlerManifestSlugs(t, first) + require.Contains(t, slugs, "gpt-5.6-sol") + require.Contains(t, slugs, sparkModel) + require.NotContains(t, slugs, "gpt-image-2") + require.NotContains(t, slugs, "codex-auto-review") + firstETag := first.Header().Get("ETag") + require.NotEmpty(t, firstETag) + + group.ModelsListConfig = service.GroupModelsListConfig{ + Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"}, } + second := performCodexModelsRequestForGroup(t, handler, group, firstETag) + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second)) + require.NotEqual(t, firstETag, second.Header().Get("ETag")) + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, third.Code) + require.Empty(t, third.Body.Bytes()) + require.Empty(t, upstream.calls()) } func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 54ebcfde18..97e35fdb1b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1452,6 +1452,10 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } sort.Strings(models) + if platform == PlatformOpenAI { + models = supplementUnmappedOpenAIModels(accounts, models) + } + if s.modelsListCache != nil { s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL) modelsListCacheStoreTotal.Add(1) diff --git a/backend/internal/service/group_models_list.go b/backend/internal/service/group_models_list.go index b10de724d9..640a71d6fa 100644 --- a/backend/internal/service/group_models_list.go +++ b/backend/internal/service/group_models_list.go @@ -1,6 +1,26 @@ package service -import "strings" +import ( + "slices" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// A partial mapping catalog must not hide models from unmapped OpenAI accounts. +// Keep an empty catalog unchanged so callers retain their existing discovery fallback. +func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string { + if len(models) == 0 { + return models + } + for i := range accounts { + account := &accounts[i] + if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 { + return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs())) + } + } + return models +} func normalizeGroupModelsListConfig(cfg GroupModelsListConfig) GroupModelsListConfig { out := GroupModelsListConfig{Enabled: cfg.Enabled} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..57ae14582a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -110,8 +110,8 @@ type CodexModelsManifest struct { NotModified bool } -// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively -// from the public model names configured on accounts in an OpenAI group. The +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog from configured +// public model names, supplemented by defaults for unmapped OpenAI accounts. The // boolean result distinguishes "no explicit configuration" from a configured // catalog that becomes empty after group-level filtering. func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( @@ -277,7 +277,7 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string { } func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { - models := openAIConfiguredCodexModelIDs(accounts) + models := supplementUnmappedOpenAIModels(accounts, openAIConfiguredCodexModelIDs(accounts)) if group == nil || !group.CustomModelsListEnabled() { return models } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index f6e51d3453..594a0fe7cb 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1105,7 +1105,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) } -// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +// Mixed groups retain configured metadata alongside defaults for unmapped accounts. func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { t.Parallel() @@ -1150,8 +1150,10 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t require.NoError(t, err) require.True(t, configured) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 1) require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review") require.Equal(t, "GLM 5.3", models[0]["display_name"]) require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) require.Equal(t, "medium", models[0]["default_reasoning_level"]) From 958a21ad6e2eddae4b1ebafa68c24dee8666092a Mon Sep 17 00:00:00 2001 From: qiu121 <99729305+qiu121@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:15:21 +0800 Subject: [PATCH 14/27] =?UTF-8?q?fix(billing):=20=E4=BF=AE=E5=A4=8D=20Deep?= =?UTF-8?q?Seek=20=E9=AB=98=E5=B3=B0=E8=B4=A6=E5=8F=B7=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E5=B0=91=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将两个用量记录入口的请求级定价时刻传递给账号成本统计,使跨峰谷请求的成本与客户计费采用同一时间基准。 - 默认目录成本改用统一计费入口并隔离分组与渠道售价,使 DeepSeek 高峰成本正确应用两倍价格,同时保留服务层级、长上下文和单次推理等级倍率。 - 增加 Flash 与 Pro 的峰谷边界、缓存用量、规则优先级及真实网关回归测试,确保账号成本按最终上游模型计算且客户扣款金额与次数保持一致。 验证:Go 1.27.0 下全量 unit 与 integration 测试命令通过;golangci-lint 2.13.0 报告 0 个问题。定向回归无失败或跳过,全量套件保留原有条件跳过项。 --- .../internal/service/account_stats_pricing.go | 28 ++-- .../service/account_stats_pricing_test.go | 146 +++++++++++++++--- .../service/gateway_record_usage_test.go | 63 ++++++++ .../internal/service/gateway_usage_billing.go | 2 +- .../openai_gateway_record_usage_test.go | 69 +++++++++ .../internal/service/openai_gateway_usage.go | 2 +- 6 files changed, 275 insertions(+), 35 deletions(-) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index c25a66f674..f27d5bb367 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -3,6 +3,7 @@ package service import ( "context" "strings" + "time" ) // resolveAccountStatsCost 计算账号统计定价费用。 @@ -17,6 +18,7 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 // reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, @@ -29,6 +31,7 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + pricingAt time.Time, reasoningEfforts ...string, ) *float64 { reasoningEffort := "" @@ -61,7 +64,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) } return nil @@ -69,20 +72,26 @@ func resolveAccountStatsCost( // tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。 // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, -// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 -// 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 { +// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的 +// 语义:只取模型定价文件,不引入自定义售价。 +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 { reasoningEffort := "" if len(reasoningEfforts) > 0 { reasoningEffort = reasoningEfforts[0] } - breakdown, err := billingService.CalculateCostWithServiceTier( - model, tokens, 1, normalizeBillingServiceTier(serviceTier), - ) + breakdown, err := billingService.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: model, + Tokens: tokens, + RateMultiplier: 1, + ServiceTier: normalizeBillingServiceTier(serviceTier), + ReasoningEffort: reasoningEffort, + PricingAt: pricingAt, + Resolver: NewModelPricingResolver(nil, billingService), + }) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)) return &breakdown.TotalCost } @@ -253,6 +262,7 @@ func applyAccountStatsCost( upstreamModel, requestedModel string, tokens UsageTokens, totalCost float64, + pricingAt time.Time, ) { model := upstreamModel if model == "" { @@ -271,6 +281,6 @@ func applyAccountStatsCost( reasoningEffort = *usageLog.ReasoningEffort } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index a346f4405f..7d4d8c7d03 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { }, }) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 require.InDelta(t, 0.2, *result, 1e-12) @@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { "claude-fable-5-1": {InputPricePerToken: 0.001}, }) tokens := UsageTokens{InputTokens: 100} - standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh") - max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max") + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") require.NotNil(t, standard) require.NotNil(t, max) require.InDelta(t, *standard*3, *max, 1e-12) @@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { }) tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}) require.NotNil(t, result) // Input and cache-read use the 2x input tier; output uses the 1.5x tier. @@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}) require.NotNil(t, result) require.InDelta(t, tt.want, *result, 1e-12) }) @@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) CacheReadTokens: 5, } - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}) require.NotNil(t, result) // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。 @@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) { // "nonexistent-model" does not match any fallback pattern bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "nonexistent-model", tokens, "") + result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}) require.Nil(t, result) } @@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) { "claude-sonnet-4": nil, }) tokens := UsageTokens{InputTokens: 100} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) { }, }) tokens := UsageTokens{} // all zero tokens → cost = 0 → nil - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { OutputTokens: 50, ImageOutputTokens: 10, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 @@ -644,13 +644,111 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { CacheCreationTokens: 200, CacheReadTokens: 300, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 require.InDelta(t, 0.95, *result, 1e-12) } +func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) { + weekday := func(hour, minute int) time.Time { + return time.Date(2026, time.August, 24, hour, minute, 0, 0, time.UTC) + } + for _, model := range []struct { + name string + input, output, cacheReadPrice float64 + }{ + {"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9}, + {"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8}, + } { + for _, usage := range []struct { + name string + tokens UsageTokens + }{ + {"input", UsageTokens{InputTokens: 1000}}, + {"output", UsageTokens{OutputTokens: 500}}, + {"cache_read", UsageTokens{CacheReadTokens: 1000}}, + {"mixed", UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}}, + } { + t.Run(model.name+"/"+usage.name, func(t *testing.T) { + bs := newTestBillingService() + tokens := usage.tokens + baseCost := float64(tokens.InputTokens)*model.input + + float64(tokens.OutputTokens)*model.output + float64(tokens.CacheReadTokens)*model.cacheReadPrice + for _, slot := range []struct { + name string + at time.Time + multiplier float64 + }{ + {"before_morning_peak", weekday(0, 59), 1}, + {"morning_peak_start", weekday(1, 0), 2}, + {"morning_peak_last_minute", weekday(3, 59), 2}, + {"morning_peak_end", weekday(4, 0), 1}, + {"afternoon_peak_start", weekday(6, 0), 2}, + {"afternoon_peak_last_minute", weekday(9, 59), 2}, + {"afternoon_peak_end", weekday(10, 0), 1}, + {"saturday", time.Date(2026, time.August, 22, 2, 0, 0, 0, time.UTC), 1}, + {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1}, + } { + t.Run(slot.name, func(t *testing.T) { + cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at) + require.NotNil(t, cost) + require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12) + }) + } + }) + } + } +} + +func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) { + peak := time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC) + for _, tt := range []struct { + name string + customRule bool + applyPricing bool + noChannel bool + want float64 + }{ + {name: "catalog", want: 1000 * 2.2e-7 * 2}, + {name: "custom_rule", customRule: true, want: 1}, + {name: "custom_rule_before_customer_price", customRule: true, applyPricing: true, want: 1}, + {name: "customer_price", applyPricing: true, want: 0.75}, + {name: "no_channel", noChannel: true}, + } { + t.Run(tt.name, func(t *testing.T) { + channel := &Channel{ + ID: 1, Status: StatusActive, ApplyPricingToAccountStats: tt.applyPricing, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.02), + }}, + } + if tt.customRule { + channel.AccountStatsPricingRules = []AccountStatsPricingRule{{ + AccountIDs: []int64{1}, + Pricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.001), + }}, + }} + } + cs := newTestChannelServiceForStats(t, channel, 10, PlatformDeepseek) + groupID := int64(10) + if tt.noChannel { + groupID = 99 + } + cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(), + 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak) + if tt.noChannel { + require.Nil(t, cost) + return + } + require.NotNil(t, cost) + require.InDelta(t, tt.want, *cost, 1e-12) + }) + } +} + // --------------------------------------------------------------------------- // resolveAccountStatsCost — integration tests covering the 4-level priority chain // --------------------------------------------------------------------------- @@ -661,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) { nil, // channelService is nil newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -677,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "", // empty upstream model - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -694,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 99, "claude-sonnet-4", // groupID 99 has no channel - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -725,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) { context.Background(), cs, nil, // billingService not needed when custom rule hits 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "priority", // 自定义账号价格不叠加服务层级倍率 + tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率 ) require.NotNil(t, result) // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0 @@ -747,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 0.75, "priority", // 已完成用户计费,不再重复应用服务层级倍率 + tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率 ) require.NotNil(t, result) require.InDelta(t, 0.75, *result, 1e-12) @@ -765,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - UsageTokens{}, 1, 0.0, "", // totalCost = 0 + UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0 ) require.Nil(t, result) } @@ -792,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "", // totalCost ignored + tokens, 1, 999.0, "", time.Time{}, // totalCost ignored ) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 @@ -813,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-opus-5", UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, - 1, 0, "fast", + 1, 0, "fast", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 60, *result, 1e-12) @@ -832,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing context.Background(), cs, bs, 1, 10, "gemini-3.6-flash-low", - UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 9.15, *result, 1e-12) @@ -856,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { context.Background(), cs, bs, 1, 10, "totally-unknown-model", - tokens, 1, 0.0, "", + tokens, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -873,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) { context.Background(), cs, nil, // billingService is nil 1, 10, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.0, "", + UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -906,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 99.0, "", // totalCost = 99.0 (would be used if ApplyPricing wins) + tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins) ) require.NotNil(t, result) // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost) @@ -934,7 +1032,7 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) { applyAccountStatsCost( context.Background(), usageLog, cs, bs, 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", - UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, + UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, ) require.NotNil(t, usageLog.AccountStatsCost) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c14bf9c27e..5d5ec7a3ee 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -424,6 +424,69 @@ func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T } } +func TestGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + groupID := int64(905) + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, groupID, PlatformDeepseek) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, UpstreamModel: model.name, + Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 805, GroupID: &groupID, Group: group}, + User: &User{ID: 605}, Account: &Account{ID: 705, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0cc8467523..1e1a0f8477 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -831,7 +831,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage CacheReadTokens: result.Usage.CacheReadInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, }, - cost.TotalCost, + cost.TotalCost, pricingAt, ) } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b3f4153784..e5dca2d7b7 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -569,6 +569,75 @@ func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *tes require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12) require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + groupID := int64(18) + cache := newEmptyChannelCache() + cache.channelByGroupID[groupID] = &Channel{ID: 1, Status: StatusActive} + cache.groupPlatform[groupID] = PlatformDeepseek + cache.loadedAt = time.Now() + svc.channelService = &ChannelService{} + svc.channelService.cache.Store(cache) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, BillingModel: alias, UpstreamModel: model.name, + Usage: OpenAIUsage{InputTokens: 2000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 1008, GroupID: &groupID, Group: group}, + User: &User{ID: 2008}, Account: &Account{ID: 3008, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 79a7999799..4570ed1631 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -472,7 +472,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil { applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, - tokens, cost.TotalCost, + tokens, cost.TotalCost, pricingAt, ) } From dc6b318c343dfc4e7b5cb2a4c4b4b6455b23237a Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 5 Sep 2026 17:54:17 +0800 Subject: [PATCH 15/27] fix(usage): load all API keys for record filters --- frontend/src/views/user/UsageView.vue | 15 ++- .../views/user/__tests__/UsageView.spec.ts | 105 +++++++++++++++++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 1f874bc660..94e4605957 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -811,13 +811,24 @@ const handleColumnClickOutside = (event: MouseEvent) => { } } +const loadApiKeys = async () => { + const firstPage = await keysAPI.list(1, 100) + const keys = [...firstPage.items] + for (let page = 2; page <= firstPage.pages && keys.length > 0; page++) { + const response = await keysAPI.list(page, 100) + if (response.items.length === 0) break + keys.push(...response.items) + } + return keys +} + const loadFilterOptions = async () => { try { const [keys, availableGroups] = await Promise.all([ - keysAPI.list(1, 100), + loadApiKeys(), userGroupsAPI.getAvailable(), ]) - apiKeys.value = keys.items + apiKeys.value = keys groups.value = availableGroups } catch (error) { console.error('Failed to load usage filter options:', error) diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts index a27cf08a68..0099a1095b 100644 --- a/frontend/src/views/user/__tests__/UsageView.spec.ts +++ b/frontend/src/views/user/__tests__/UsageView.spec.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import UsageView from '../UsageView.vue' +import Select, { type SelectOption } from '@/components/common/Select.vue' const { query, getStats, getDashboardModels, getDashboardSnapshotV2, + listMyErrorRequests, list, getAvailable, showError, @@ -19,6 +21,7 @@ const { getStats: vi.fn(), getDashboardModels: vi.fn(), getDashboardSnapshotV2: vi.fn(), + listMyErrorRequests: vi.fn(), list: vi.fn(), getAvailable: vi.fn(), showError: vi.fn(), @@ -47,6 +50,9 @@ const messages: Record = { 'admin.usage.allGroups': 'All groups', 'admin.usage.allModels': 'All models', 'usage.allApiKeys': 'All API Keys', + 'usage.errors.allKeys': 'All API Keys', + 'usage.tabs.usage': 'Usage records', + 'usage.tabs.errors': 'Error records', 'usage.apiKeyFilter': 'API Key', 'usage.model': 'Model', 'usage.type': 'Type', @@ -73,6 +79,7 @@ vi.mock('@/api', () => ({ getStats, getDashboardModels, getDashboardSnapshotV2, + listMyErrorRequests, }, keysAPI: { list, @@ -83,7 +90,10 @@ vi.mock('@/api', () => ({ })) vi.mock('@/stores/app', () => ({ - useAppStore: () => ({ showError, showWarning, showSuccess, showInfo }), + useAppStore: () => ({ + showError, showWarning, showSuccess, showInfo, + cachedPublicSettings: { allow_user_view_error_requests: true }, + }), })) vi.mock('vue-i18n', async () => { @@ -142,6 +152,7 @@ function mountUsageView() { Icon: true, UsageStatsCards: chartStub, UsageTable: chartStub, + UserErrorRequestsTable: chartStub, ModelDistributionChart: chartStub, GroupDistributionChart: chartStub, EndpointDistributionChart: chartStub, @@ -157,6 +168,7 @@ describe('user UsageView', () => { getStats.mockReset() getDashboardModels.mockReset() getDashboardSnapshotV2.mockReset() + listMyErrorRequests.mockReset() list.mockReset() getAvailable.mockReset() showError.mockReset() @@ -191,7 +203,8 @@ describe('user UsageView', () => { trend: [], groups: [], }) - list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] }) + listMyErrorRequests.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 }) + list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }], total: 1, page: 1, page_size: 100, pages: 1 }) getAvailable.mockResolvedValue([{ id: 1, name: 'default' }]) }) @@ -207,10 +220,98 @@ describe('user UsageView', () => { include_model_stats: false, include_group_stats: true, })) + expect(list).toHaveBeenCalledTimes(1) expect(list).toHaveBeenCalledWith(1, 100) expect(getAvailable).toHaveBeenCalled() }) + it('includes API keys after the first page in both record filters and queries by the selected key', async () => { + const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + name: `key-${index + 1}`, + })) + const laterKey = { id: 101, name: 'key-from-second-page' } + list + .mockResolvedValueOnce({ items: firstPageKeys, total: 101, page: 1, page_size: 100, pages: 2 }) + .mockResolvedValueOnce({ items: [laterKey], total: 101, page: 2, page_size: 100, pages: 2 }) + + const wrapper = mountUsageView() + await flushPromises() + + expect(list.mock.calls).toEqual([[1, 100], [2, 100]]) + const usageKeySelect = wrapper.findAllComponents(Select).find((select) => + select.props('options').some((option: SelectOption) => option.label === 'All API Keys') + )! + expect(usageKeySelect.props('options')).toHaveLength(102) + expect(usageKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name }) + + query.mockClear() + usageKeySelect.vm.$emit('update:modelValue', laterKey.id) + usageKeySelect.vm.$emit('change', laterKey.id) + await flushPromises() + + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ api_key_id: laterKey.id, page: 1 }), + expect.anything() + ) + + await wrapper.findAll('button').find((button) => button.text() === 'Error records')!.trigger('click') + await flushPromises() + + const errorKeySelect = wrapper.findAllComponents(Select).find((select) => + select.props('options').some((option: SelectOption) => option.label === 'All API Keys') + )! + expect(errorKeySelect.props('options')).toHaveLength(102) + expect(errorKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name }) + + listMyErrorRequests.mockClear() + errorKeySelect.vm.$emit('update:modelValue', laterKey.id) + errorKeySelect.vm.$emit('change', laterKey.id) + await flushPromises() + + expect(listMyErrorRequests).toHaveBeenCalledWith( + expect.objectContaining({ api_key_id: laterKey.id, page: 1 }) + ) + expect(list).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + + it('does not request another API key page when the user has no keys', async () => { + list.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100, pages: 0 }) + + const wrapper = mountUsageView() + await flushPromises() + + expect(list.mock.calls).toEqual([[1, 100]]) + const keySelect = wrapper.findAllComponents(Select).find((select) => + select.props('options').some((option: SelectOption) => option.label === 'All API Keys') + )! + expect(keySelect.props('options')).toEqual([{ value: null, label: 'All API Keys' }]) + expect(getAvailable).toHaveBeenCalled() + wrapper.unmount() + }) + + it('stops loading API keys when a later page is empty despite an outdated page count', async () => { + const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + name: `key-${index + 1}`, + })) + list + .mockResolvedValueOnce({ items: firstPageKeys, total: 201, page: 1, page_size: 100, pages: 3 }) + .mockResolvedValueOnce({ items: [], total: 201, page: 2, page_size: 100, pages: 3 }) + + const wrapper = mountUsageView() + await flushPromises() + + expect(list.mock.calls).toEqual([[1, 100], [2, 100]]) + const keySelect = wrapper.findAllComponents(Select).find((select) => + select.props('options').some((option: SelectOption) => option.label === 'All API Keys') + )! + expect(keySelect.props('options')).toHaveLength(101) + expect(keySelect.props('options')).toContainEqual({ value: 100, label: 'key-100' }) + wrapper.unmount() + }) + it('propagates and resets the native compaction filter across page requests', async () => { const wrapper = mountUsageView() await flushPromises() From b8ed24508555406a34b0bbc9c980ff158362d678 Mon Sep 17 00:00:00 2001 From: r266-tech Date: Sun, 6 Sep 2026 18:42:06 +0800 Subject: [PATCH 16/27] fix(openai-ws): recover later turns from quota exhaustion --- .../service/openai_ws_v2/passthrough_relay.go | 25 +++++++- .../openai_ws_v2/passthrough_relay_test.go | 58 +++++++++++++++++++ .../openai_ws_v2_passthrough_adapter.go | 7 +++ ...openai_ws_v2_passthrough_lifecycle_test.go | 56 ++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index fdb539a073..c37847e561 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -102,6 +102,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage turnUsage Usage + turnWroteDownstream atomic.Bool requestModelMu sync.RWMutex requestModel string pendingTurnStart atomic.Pointer[time.Time] @@ -204,7 +205,8 @@ func Relay( return upstreamConn.WriteFrame(writeCtx, msgType, payload) } writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { - if isClientResponseCreateFrame(msgType, payload) { + isResponseCreate := isClientResponseCreateFrame(msgType, payload) + if isResponseCreate { state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) turnStartedAt := time.Time{} if options.TakeNextTurnStartedAt != nil { @@ -214,8 +216,18 @@ func Relay( turnStartedAt = nowFn() } state.setPendingTurnStartedAt(turnStartedAt) + // The policy-enforcing client connection has accepted this turn. + // Reset before the write so an immediate upstream response cannot race + // with the transport returning from WriteFrame. + state.turnWroteDownstream.Store(false) } - return writeUpstream(msgType, payload) + err := writeUpstream(msgType, payload) + if err != nil && isResponseCreate { + // The relay exits on this error, but retain the previous turn's state + // for accurate diagnostics while the two relay goroutines settle. + state.turnWroteDownstream.Store(true) + } + return err } writeClient := func(msgType coderws.MessageType, payload []byte) error { // 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write @@ -559,7 +571,11 @@ func runUpstreamToClient( } markActivity() if beforeWriteClient != nil { - if err := beforeWriteClient(msgType, payload, wroteDownstream); err != nil { + wroteDownstreamInTurn := wroteDownstream + if state != nil { + wroteDownstreamInTurn = state.turnWroteDownstream.Load() + } + if err := beforeWriteClient(msgType, payload, wroteDownstreamInTurn); err != nil { emitRelayTrace(onTrace, RelayTraceEvent{ Stage: "upstream_message_rejected", Direction: "upstream_to_client", @@ -630,6 +646,9 @@ func runUpstreamToClient( return } wroteDownstream = true + if state != nil { + state.turnWroteDownstream.Store(true) + } if afterWriteClient != nil { afterWriteClient(msgType, payload) } diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5505b4981..dced90de33 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "strings" "sync" "sync/atomic" "testing" @@ -903,6 +904,63 @@ func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBound } } +func TestRelay_BeforeWriteClientTracksDownstreamPerTurn(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wroteStates := make(chan bool, 3) + done := make(chan *RelayExit, 1) + stopErr := errors.New("stop after second-turn error") + go func() { + _, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{BeforeWriteClient: func(_ coderws.MessageType, payload []byte, wroteDownstream bool) error { + wroteStates <- wroteDownstream + if strings.Contains(string(payload), `"type":"error"`) { + return stopErr + } + return nil + }}, + ) + done <- relayExit + }() + + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + require.False(t, <-wroteStates) + require.Eventually(t, func() bool { return len(clientConn.Writes()) == 1 }, time.Second, time.Millisecond) + + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + } + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"error","error":{"type":"usage_limit_reached"}}`), + } + require.False(t, <-wroteStates, "the next turn must not inherit the first turn's downstream write") + + select { + case relayExit := <-done: + require.NotNil(t, relayExit) + require.ErrorIs(t, relayExit.Err, stopErr) + require.True(t, relayExit.WroteDownstream, "connection-wide diagnostics must retain prior output") + case <-time.After(time.Second): + t.Fatal("relay did not stop after the rejected second-turn event") + } +} + func TestRelay_BinaryFramePassthrough(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index b7c2940965..5b25f31e74 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1299,6 +1299,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen), ) + if completedTurns.Load() > 0 { + return NewOpenAIWSClientCloseError( + coderws.StatusTryAgainLater, + "upstream rate limit exceeded; please reconnect", + errors.New("later passthrough turn was rate limited before output"), + ) + } return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw) }, OnTrace: func(event openaiwsv2.RelayTraceEvent) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index 5fa6a7def9..24afbeda09 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -210,6 +211,61 @@ func startPassthroughLifecycleServerWithHooks( return server, serverErr } +func TestPassthroughLifecycle_LaterTurnPreOutputRateLimitRequestsReconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + account := passthroughLifecycleAccount() + repo := &openAIWSRateLimitSignalRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}}} + svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) + svc.accountRepo = repo + svc.rateLimitService = &RateLimitService{accountRepo: repo} + + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + firstRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(firstRequest, "type").String()) + + upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + completed, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + secondRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(secondRequest, "type").String()) + + resetAt := time.Now().Add(90 * time.Minute).Unix() + upstream.Send(fmt.Sprintf(`{"type":"error","error":{"code":"rate_limit_exceeded","type":"usage_limit_reached","message":"The usage limit has been reached","resets_at":%d}}`, resetAt)) + _, err = readPassthroughLifecycleFrame(t, clientConn, time.Second) + var websocketCloseErr coderws.CloseError + require.ErrorAs(t, err, &websocketCloseErr) + require.Equal(t, coderws.StatusTryAgainLater, websocketCloseErr.Code) + require.Equal(t, "upstream rate limit exceeded; please reconnect", websocketCloseErr.Reason) + require.Len(t, repo.rateLimitCalls, 1) + require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second) + + select { + case err := <-serverErr: + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode()) + case <-time.After(time.Second): + t.Fatal("later-turn rate limit did not terminate passthrough") + } + select { + case replay := <-upstream.writes: + t.Fatalf("later-turn reconnect must not replay the retained first request: %s", replay) + default: + } +} + func TestPassthroughLifecycle_CyberTerminalEventsMarkBeforeAfterTurn(t *testing.T) { gin.SetMode(gin.TestMode) From 2db78bd3d68f58968d73b664c289c79ebee71aee Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:12:55 +0800 Subject: [PATCH 17/27] fix(codex): preserve Astra Ultra workflow metadata --- .../service/openai_codex_model_metadata.go | 5 +- .../openai_codex_model_metadata_test.go | 52 +++++++++++++++++++ .../service/openai_codex_models_service.go | 8 ++- .../openai_codex_models_service_test.go | 9 ++-- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 656919fd8c..393621a181 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -9,6 +9,7 @@ import ( var codexToolCapabilityFields = []string{ "supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite", + "multi_agent_reasoning_effort", "multi_agent_version", } func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool { @@ -18,7 +19,7 @@ func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite b if len(value) == 0 { continue } - // These five Codex fields are nullable booleans or strings, never arbitrary objects. + // These Codex fields are nullable booleans or strings, never arbitrary objects. if !bytes.Equal(value, []byte("null")) { if field == "supports_search_tool" || field == "use_responses_lite" { if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) { @@ -396,6 +397,8 @@ func configuredCodexReasoningLevelDescription(level string) string { return "Extra-high reasoning depth for difficult tasks" case "max": return "Maximum reasoning depth for complex tasks" + case "ultra": + return "Maximum reasoning with automatic task delegation" default: return "Reasoning effort supported by the upstream model" } diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 55bc233ed2..470c9faf5f 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -9,6 +9,58 @@ import ( "github.com/stretchr/testify/require" ) +func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) { + // Ultra is a Codex workflow. Its inference effort must survive catalog sync, + // aliases and group generation instead of silently falling back to max. + _, metadata, err := extractUpstreamModelCatalog([]byte(`{"models":[{ + "slug":"gpt-6-astra","supported_reasoning_levels":[{"effort":"high"},{"effort":"ultra"}], + "multi_agent_reasoning_effort":"high","multi_agent_version":"v2" + }]}`), false) + require.NoError(t, err) + account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: metadata}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account}, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "high", model["multi_agent_reasoning_effort"]) + require.Equal(t, "v2", model["multi_agent_version"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, model)) + + peer := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: account.Credentials} + body, err = buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account, peer}, nil, true) + require.NoError(t, err) + model = decodeCodexManifestModels(t, body)[0] + require.Nil(t, model["multi_agent_reasoning_effort"], "do not advertise one account's override for all peers") + require.Nil(t, model["multi_agent_version"]) +} + +func TestAstraUltraCatalogPreservesExplicitWorkflowOverrides(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + for _, fields := range []string{ + `"multi_agent_reasoning_effort":"high","multi_agent_version":"v1"`, + `"multi_agent_reasoning_effort":null,"multi_agent_version":null`, + } { + for _, source := range []string{ + `{"models":[{"slug":"gpt-6-astra",` + fields + `}]}`, + `{"data":[{"id":"gpt-6-astra",` + fields + `}]}`, + } { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := completeAPIKeyCodexModelsManifestMetadata(converted, true, account) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + var expected map[string]any + require.NoError(t, json.Unmarshal([]byte(`{`+fields+`}`), &expected)) + for key, value := range expected { + require.Contains(t, model, key) + require.Equal(t, value, model[key]) + } + } + } +} + func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testing.T) { newAccount := func(baseURL string) Account { return Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..03cf6de5dd 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -361,6 +361,7 @@ type configuredCodexModelDescriptor struct { Description string `json:"description"` DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + MultiAgentReasoningEffort *string `json:"multi_agent_reasoning_effort,omitempty"` ShellType string `json:"shell_type"` Visibility string `json:"visibility"` SupportedInAPI bool `json:"supported_in_api"` @@ -492,6 +493,11 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } if isOpenAIGPT6AstraModel(modelID) { + // Codex resolves the Ultra workflow to this effort before inference. + // openai/codex a9896da3: codex-rs/models-manager/models.json. + multiAgentEffort := "xhigh" + descriptor.MultiAgentReasoningEffort = &multiAgentEffort + descriptor.MultiAgentVersion = "v2" descriptor.ContextWindow = configuredCodexGPT6AstraContext descriptor.MaxContextWindow = configuredCodexGPT6AstraContext } @@ -602,7 +608,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin Description: "Maximum reasoning depth for complex tasks", }) } - if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + if isOpenAIGPT6AstraModel(modelID) || normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { levels = append(levels, configuredCodexReasoningLevel{ Effort: "ultra", Description: "Maximum reasoning with automatic task delegation", diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index f6e51d3453..a21bc4f53a 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -353,8 +353,10 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName) require.NotNil(t, gpt6Astra.DefaultReasoningLevel) require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel) - require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) - require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"}) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) + require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "v2", gpt6Astra.MultiAgentVersion) require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra")) require.Equal(t, []configuredCodexServiceTier{{ @@ -370,7 +372,8 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) gpt6 := newConfiguredCodexModelDescriptor("gpt-6") require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) - require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort) require.Equal(t, int64(1_050_000), gpt6.ContextWindow) gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") From 415a0dc9d0cae3bda18bb1c1573e16de999eb3bf Mon Sep 17 00:00:00 2001 From: milesians <37968554+Milesians@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:11:07 +0800 Subject: [PATCH 18/27] fix: display multiplier-only interval prices --- .../handler/available_channel_handler.go | 44 ++++++++------- .../handler/available_channel_handler_test.go | 18 ++++++- frontend/src/api/channels.ts | 4 ++ frontend/src/api/modelPlaza.ts | 2 +- .../channels/SupportedModelChip.vue | 25 ++++++--- .../__tests__/SupportedModelChip.spec.ts | 54 +++++++++++++++++++ .../modelPlaza/PlazaModelPricingTable.vue | 43 +++++++++------ .../__tests__/PlazaModelPricingTable.spec.ts | 38 +++++++++++++ 8 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index ffa2408bcd..ae01687bdb 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -81,15 +81,19 @@ type userSupportedModelPricing struct { // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 type userPricingIntervalDTO struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` } // userSupportedModel 用户可见的支持模型条目。 @@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval intervals := make([]userPricingIntervalDTO, 0, len(src)) for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheWrite1hPrice: iv.CacheWrite1hPrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheWrite1hPrice: iv.CacheWrite1hPrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, }) } return intervals diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 24d7d1cb45..325f10133b 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { } // pricing interval 白名单:不应暴露 id / sort_order。 + inputMultiplier := 2.0 + outputMultiplier := 1.5 + cacheWriteMultiplier := 2.0 + cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, Intervals: []service.PricingInterval{ - {ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3}, + { + ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, + InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier, + CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier, + }, }, }) require.NotNil(t, pricing) @@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { _, exists := ivDecoded[key] require.Falsef(t, exists, "user pricing interval must not expose %q", key) } + for key, want := range map[string]float64{ + "input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier, + "cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier, + } { + got, exists := ivDecoded[key] + require.Truef(t, exists, "user pricing interval must expose %q", key) + require.InDelta(t, want, got.(float64), 1e-12) + } } func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index c998fc3efb..5c3f83542e 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -31,6 +31,10 @@ export interface UserPricingInterval { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + input_multiplier?: number | null + output_multiplier?: number | null + cache_write_multiplier?: number | null + cache_read_multiplier?: number | null per_request_price: number | null } diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index e96a621934..5096efbe82 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -46,7 +46,7 @@ export interface PlazaTimePricing { export interface PlazaModel { name: string platform: string - /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ + /** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null /** 仅多档模型返回。 */ diff --git a/frontend/src/components/channels/SupportedModelChip.vue b/frontend/src/components/channels/SupportedModelChip.vue index b94fc9f072..04a2ad9de5 100644 --- a/frontend/src/components/channels/SupportedModelChip.vue +++ b/frontend/src/components/channels/SupportedModelChip.vue @@ -153,7 +153,7 @@ - {{ formatInterval(iv, model.pricing.billing_mode) }} + {{ formatInterval(iv, model.pricing) }}

@@ -172,12 +172,11 @@ import { formatScaled } from '@/utils/pricing' import { BILLING_MODE_TOKEN, BILLING_MODE_PER_REQUEST, - BILLING_MODE_IMAGE, - type BillingMode + BILLING_MODE_IMAGE } from '@/constants/channel' // 复用 api/channels.ts 的用户侧最小形态 DTO。 // admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。 -import type { UserPricingInterval, UserSupportedModel } from '@/api/channels' +import type { UserPricingInterval, UserSupportedModel, UserSupportedModelPricing } from '@/api/channels' import PlatformIcon from '@/components/common/PlatformIcon.vue' import type { GroupPlatform } from '@/types' import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors' @@ -246,12 +245,22 @@ function formatRange(min: number, max: number | null): string { return `(${min}, ${maxLabel}]` } -function formatInterval(iv: UserPricingInterval, mode: BillingMode): string { - if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) { +function effectiveIntervalPrice( + price: number | null | undefined, + multiplier: number | null | undefined, + basePrice: number | null +): number | null { + if (price != null) return price + if (multiplier != null && basePrice != null) return basePrice * multiplier + return basePrice +} + +function formatInterval(iv: UserPricingInterval, pricing: UserSupportedModelPricing): string { + if (pricing.billing_mode === BILLING_MODE_PER_REQUEST || pricing.billing_mode === BILLING_MODE_IMAGE) { return formatScaled(iv.per_request_price, 1) } - const input = formatScaled(iv.input_price, perMillionScale) - const output = formatScaled(iv.output_price, perMillionScale) + const input = formatScaled(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, pricing.input_price), perMillionScale) + const output = formatScaled(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, pricing.output_price), perMillionScale) return `${input} / ${output}` } diff --git a/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts b/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts new file mode 100644 index 0000000000..b3799b697c --- /dev/null +++ b/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts @@ -0,0 +1,54 @@ +import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import SupportedModelChip from '../SupportedModelChip.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }) + } +}) + +describe('SupportedModelChip', () => { + it('仅配置区间倍率时按基础价展示 token 档位', async () => { + const wrapper = mount(SupportedModelChip, { + attachTo: document.body, + props: { + model: { + name: 'gpt-test', + platform: '', + pricing: { + billing_mode: 'token', + input_price: 10e-6, + output_price: 50e-6, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [{ + min_tokens: 272000, + max_tokens: null, + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + input_multiplier: 2, + output_multiplier: 1.5, + per_request_price: null + }] + } + }, + showPlatform: false + } + }) + + await wrapper.find('[tabindex="0"]').trigger('mouseenter') + await nextTick() + + expect(document.body.textContent).toContain('$20 / $75') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 43aee2c140..59910f2a36 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -119,7 +119,7 @@ class="whitespace-nowrap text-xs leading-5" > {{ tierLabel(iv) }} - {{ paidPerMillion(iv.input_price, period) }} + {{ paidPerMillion(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.pricing?.input_price), period) }} @@ -132,7 +132,7 @@ class="whitespace-nowrap text-xs leading-5" :title="tierHint(m)" > - {{ paidPerMillion(iv.output_price, period) }} + {{ paidPerMillion(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.pricing?.output_price), period) }} @@ -145,15 +145,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200" :title="tierHint(m)" > - @@ -243,15 +243,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400" :title="t('modelPlaza.table.tierHint')" > - @@ -145,15 +145,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200" :title="tierHint(m)" > - @@ -243,15 +243,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400" :title="t('modelPlaza.table.tierHint')" > -