From 5fb5dfb34cd1540a39639983f0e7b17c2a62ce58 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 9 Aug 2026 20:35:36 +0800 Subject: [PATCH 01/53] fix(openai): reacquire passthrough turn slots --- .../handler/openai_gateway_handler.go | 19 ++- .../service/openai_ws_forwarder_ingress.go | 6 +- ...openai_ws_passthrough_turn_pricing_test.go | 128 ++++++++++++------ .../openai_ws_v2_passthrough_adapter.go | 5 + 4 files changed, 105 insertions(+), 53 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e57cc759e6..08625facf8 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1616,10 +1616,10 @@ const ( // 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 // 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 // -// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须 -// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。 -// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行 -// 准入门、没有 turn 级利润复核的既有行为。 +// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持 +// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create +// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻 +// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。 type openAIWSTurnPricing struct { mu sync.Mutex at time.Time @@ -2321,8 +2321,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // turn-tagged slot preserves the exact mapping used for the in-flight request. var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) - // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号; - // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 + // turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由 + // BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ ClientLifecycleContext: clientLifecycleCtx, @@ -2335,10 +2335,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { c.Set(securityAuditWSTurnContextKey, turn) service.BeginOpsStreamTurn(c, turn) setCyberTurnBody(turn, payload) - // Passthrough ingress intentionally skips BeforeTurn, so enforce only - // the connection-level cyber session gate here as well. Native ingress - // visits this hook first and gets the same side-effect-free close error; - // its original BeforeTurn guard remains as defense in depth. + // 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与 + // passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。 + // BeforeTurn 中保留同一检查作为防御式兜底。 if cyberBlockedThisConn { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5f5976c7d0..4e2a1f13a2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -137,9 +137,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( forceHTTPBridge = true break } - // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 - // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 - // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 + // 首轮准入由握手路径完成;后续 response.create 会在写入上游前 + // 依次回调 BeforeRequest 和 BeforeTurn,并在终止或失败时回调 + // AfterTurn,从而覆盖 turn 级利润复核、定价冻结和并发槽位释放。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index 16be2bd4c6..a405fbb29a 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,50 +60,38 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 -// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 -// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 -// -// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) -// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 -func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { +func TestPassthroughIngressFollowUpCallsBeforeTurnAfterBeforeRequest(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() - upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) var hooksMu sync.Mutex - beforeTurnCalls := 0 - expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) - type hookEvent struct { - name string - turn int - startedAt time.Time - } - var hookEvents []hookEvent + var callbacks []string + afterTurnCalls := 0 hooks := &OpenAIWSIngressHooks{ - InitialTurnStartedAt: expectedTurnStartedAt, - TurnStarted: func(turn int, startedAt time.Time) { + BeforeRequest: func(int, []byte, string) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) - hooksMu.Unlock() - }, - BeforeTurn: func(int) error { - hooksMu.Lock() - beforeTurnCalls++ + callbacks = append(callbacks, "before_request") hooksMu.Unlock() return nil }, - AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { + BeforeTurn: func(int) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) + callbacks = append(callbacks, "before_turn") + hooksMu.Unlock() + return nil + }, + AfterTurn: func(int, *OpenAIForwardResult, error) { + hooksMu.Lock() + afterTurnCalls++ hooksMu.Unlock() }, } - server, serverErr := startPassthroughHookRecordingServer( + server, _ := startPassthroughHookRecordingServer( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), @@ -113,30 +101,90 @@ func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) - // 等待连接自然结束(inter-turn idle 超时),确保 AfterTurn 已提交。 - _, _ = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + event, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + hooksMu.Lock() + gotCallbacks := append([]string(nil), callbacks...) + gotAfter := afterTurnCalls + hooksMu.Unlock() + + require.Equal(t, []string{"before_request", "before_turn"}, gotCallbacks) + require.Equal(t, 2, gotAfter) +} + +func TestPassthroughIngressBeforeTurnRejectionDoesNotForwardFollowUp(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_reject_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + rejection := errors.New("turn rejected") + var hooksMu sync.Mutex + afterTurnCalls := 0 + var finalErr error + beforeTurnTurn := 0 + hooks := &OpenAIWSIngressHooks{ + BeforeTurn: func(turn int) error { + hooksMu.Lock() + beforeTurnTurn = turn + hooksMu.Unlock() + return rejection + }, + AfterTurn: func(_ int, _ *OpenAIForwardResult, turnErr error) { + hooksMu.Lock() + afterTurnCalls++ + if turnErr != nil { + finalErr = turnErr + } + hooksMu.Unlock() + }, + } + + server, serverErr := startPassthroughHookRecordingServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), hooks) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + requirePassthroughUpstreamWrite(t, upstream, time.Second) + _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) select { - case <-serverErr: + case payload := <-upstream.writes: + t.Fatalf("rejected response.create was forwarded upstream: %s", payload) + case <-time.After(100 * time.Millisecond): + } + select { + case err = <-serverErr: + require.ErrorIs(t, err, rejection) case <-time.After(3 * time.Second): - t.Fatal("passthrough ingress did not exit") + t.Fatal("passthrough ingress did not exit after BeforeTurn rejection") } hooksMu.Lock() - gotBefore := beforeTurnCalls - gotEvents := append([]hookEvent(nil), hookEvents...) + gotBeforeTurn, gotAfter, gotFinalErr := beforeTurnTurn, afterTurnCalls, finalErr hooksMu.Unlock() - - require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") - require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") - require.Equal(t, "TurnStarted", gotEvents[0].name) - require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") - require.Equal(t, "AfterTurn", gotEvents[1].name) - require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") + require.Equal(t, 2, gotBeforeTurn) + require.Equal(t, 2, gotAfter, "each started turn must be finalized exactly once") + require.ErrorIs(t, gotFinalErr, rejection) } func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 95de10df71..cf8c0de7cb 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1054,6 +1054,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return payload, nil, err } } + if hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turnNo); err != nil { + return payload, nil, err + } + } if hooks != nil && hooks.MapRequestModel != nil { upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) if err != nil { From 222181efd6bebc28d2258a7ac5b5b4a8044fb649 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:02:38 +0800 Subject: [PATCH 02/53] fix(anthropic): scope Fable credits errors to model --- backend/internal/service/ratelimit_service.go | 60 ++++++++++++++- ...mit_service_anthropic_window_limit_test.go | 76 +++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..47f234f85d 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -366,12 +366,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc // otherwise a broad "rate limit" keyword rule can shorten a multi-hour // cooldown to a local temporary pause. if statusCode == http.StatusTooManyRequests && account.Platform == PlatformAnthropic { + // Fable may be rejected because the organization has no usage credits for + // this model. Anthropic reports that as 429, but it is a model entitlement + // failure rather than a shared account window exhaustion. + fableCreditsRequired := s.persistAnthropicFableCreditsRequired(ctx, account, headers, responseBody, firstRequestedModel(requestedModel)) // 7d_oi 是 Fable 模型专属的 7d 窗口:只标记模型级限流,账号对其他模型仍可调度。 fableLimited := s.persistAnthropicFableWindowLimit(ctx, account, headers) if s.persistAnthropicExhaustedWindowLimit(ctx, account, headers) { return false } - if fableLimited { + if fableCreditsRequired || fableLimited { return false } } @@ -1486,7 +1490,59 @@ func (s *RateLimitService) persistAnthropicExhaustedWindowLimit(ctx context.Cont return true } -const anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" +const ( + anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" + anthropicFableCreditsRequiredReason = "anthropic_fable_credits_required" +) + +// persistAnthropicFableCreditsRequired handles Anthropic's credits_required +// response for Fable. Although the upstream status is 429, this response only +// says that the organization cannot use Fable; marking the whole account rate +// limited would unnecessarily stop Sonnet, Opus, and Haiku scheduling. +func (s *RateLimitService) persistAnthropicFableCreditsRequired(ctx context.Context, account *Account, headers http.Header, responseBody []byte, requestedModel string) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.error_code").String()), "credits_required") { + return false + } + + model := strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.model").String()) + if model == "" { + model = strings.TrimSpace(requestedModel) + } + if !isAnthropicFableModel(model) { + return false + } + + now := time.Now() + resetAt, ok := parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-reset"), now, 366*24*time.Hour) + if !ok { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("anthropic_fable_credits_required_cooldown_ignored", "account_id", account.ID) + return true + } + resetAt = now.Add(cooldown) + } + + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, anthropicFableRateLimitKey, resetAt, anthropicFableCreditsRequiredReason); err != nil { + slog.Warn("anthropic_fable_credits_required_rate_limit_set_failed", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "error", err) + // The response is still known to be Fable-specific. Do not widen a + // persistence failure into an account-level rate limit. + return true + } + slog.Info("anthropic_fable_credits_required_model_rate_limited", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "reset_in", time.Until(resetAt).Truncate(time.Second)) + return true +} // selectAnthropicFableWindowLimit parses the Anthropic 7d_oi per-model window // headers (the Fable-only 7d window, e.g. anthropic-ratelimit-unified-7d_oi-*). diff --git a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go index a13e8fe333..328bf07fe6 100644 --- a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go @@ -160,6 +160,82 @@ func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"]) } +func TestHandleUpstreamError_AnthropicFableCreditsRequiredOnlyMarksModelRateLimit(t *testing.T) { + resetAt := time.Now().Add(29 * 24 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5","disabled_reason":"org_level_disabled"},"message":"Usage credits are required for this model."}}`) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.False(t, shouldDisable) + require.Zero(t, repo.rateLimitCalls, "Fable entitlement failure must not rate limit the whole account") + require.Zero(t, repo.sessionWindowCalls, "Fable entitlement failure must not rewrite the shared session window") + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.Equal(t, resetAt, repo.lastModelRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicFableCreditsRequiredFallsBackToRequestedModel(t *testing.T) { + startedAt := time.Now() + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body, "claude-fable-5[1m]") + + require.Zero(t, repo.rateLimitCalls) + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.True(t, repo.lastModelRateLimitReset.After(startedAt)) +} + +func TestHandleUpstreamError_AnthropicNonFableCreditsRequiredKeepsLegacyBehavior(t *testing.T) { + resetAt := time.Now().Add(2 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-opus-5"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-opus-5") + + require.Zero(t, repo.modelRateLimitCalls) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, resetAt, repo.lastRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicSharedWindowStillWinsWithFableCreditsRequired(t *testing.T) { + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + reset7d := now.Add(80 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset5h.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5"}}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.Equal(t, 1, repo.modelRateLimitCalls, "Fable-specific failure should still be recorded") + require.Equal(t, 1, repo.rateLimitCalls, "an explicitly rejected shared window must still rate limit the account") + require.Equal(t, reset5h, repo.lastRateLimitReset) +} + func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) { // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。 now := time.Now() From 9be9c0b68c47eb433d886453deecf73c7ed55d68 Mon Sep 17 00:00:00 2001 From: wall-wxk <582104384@qq.com> Date: Wed, 2 Sep 2026 18:45:20 +0800 Subject: [PATCH 03/53] =?UTF-8?q?fix(gateway):=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E8=AF=B7=E6=B1=82=E7=AB=8B=E5=8D=B3?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E4=BC=9A=E8=AF=9D=E6=A7=BD=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8D=A0=E7=94=A8=E6=95=B4=E4=B8=AA=E7=A9=BA=E9=97=B2?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放 机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号 耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致 max_sessions 受限的账号在窗口内拒绝所有新会话。 修复: - SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册 - GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐 - Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时 统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放; 成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义 - CountTokens:转发失败时同样释放选号时注册的会话槽 Co-Authored-By: Claude Opus 4.6 --- backend/internal/handler/gateway_handler.go | 36 ++++++ .../repository/session_limit_cache.go | 9 ++ .../session_limit_cache_integration_test.go | 92 +++++++++++++ .../internal/service/gateway_scheduling.go | 22 ++++ .../internal/service/session_limit_cache.go | 6 + .../service/session_limit_release_test.go | 121 ++++++++++++++++++ backend/internal/testutil/stubs.go | 3 + 7 files changed, 289 insertions(+) create mode 100644 backend/internal/repository/session_limit_cache_integration_test.go create mode 100644 backend/internal/service/session_limit_release_test.go diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..e00ca04013 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -610,6 +610,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) { c.Request = c.Request.WithContext(ctx) } + // 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话 + // (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽), + // 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions + // 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。 + // 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。 + sessionSlotAccounts := make(map[int64]*service.Account) + upstreamServedSession := false + defer func() { + if upstreamServedSession { + return + } + // 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + }() + for { fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession) retryWithFallback := false @@ -763,10 +780,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) return } + // 尝试被否决(从未转发),立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue } account = latest selection.Account = latest + // 记录本请求注册过会话槽的账号(profit 准入后账号已定) + sessionSlotAccounts[account.ID] = account // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已 // 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 if selection.ProfitGateActive() || !selection.Acquired { @@ -983,6 +1005,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { currentSubscription = nil fallbackUsed = true retryWithFallback = true + // 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + sessionSlotAccounts = make(map[int64]*service.Account) break } _ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body) @@ -998,6 +1025,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) switch action { case FailoverContinue: + // 本次尝试已确定性失败,立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue case FailoverExhausted: h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted) @@ -1036,6 +1066,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 不会走到这里重复计费。 if result != nil { submitForwardUsage(result) + // 上游已接受并计量本次会话(流中断),会话槽保持既有语义 + upstreamServedSession = true } return } @@ -1061,6 +1093,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } submitForwardUsage(result) + // 转发成功,会话槽保持既有空闲超时语义 + upstreamServedSession = true return } if !retryWithFallback { @@ -2163,6 +2197,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil { reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) // 错误响应已在 ForwardCountTokens 中处理 + // 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx) + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash) return } } diff --git a/backend/internal/repository/session_limit_cache.go b/backend/internal/repository/session_limit_cache.go index 1711256937..4b38e58f50 100644 --- a/backend/internal/repository/session_limit_cache.go +++ b/backend/internal/repository/session_limit_cache.go @@ -216,6 +216,15 @@ func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64 return result == 1, nil } +// UnregisterSession 立即移除会话注册(不等待空闲超时) +// 请求最终失败时调用:上游从未服务该会话,继续占槽会卡住 max_sessions 受限的账号 +func (c *sessionLimitCache) UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error { + if sessionUUID == "" { + return nil + } + return c.rdb.ZRem(ctx, sessionLimitKey(accountID), sessionUUID).Err() +} + // RefreshSession 刷新会话时间戳 func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error { if sessionUUID == "" { diff --git a/backend/internal/repository/session_limit_cache_integration_test.go b/backend/internal/repository/session_limit_cache_integration_test.go new file mode 100644 index 0000000000..7f07300cb3 --- /dev/null +++ b/backend/internal/repository/session_limit_cache_integration_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package repository + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/suite" +) + +type SessionLimitCacheSuite struct { + IntegrationRedisSuite + cache service.SessionLimitCache +} + +func (s *SessionLimitCacheSuite) SetupTest() { + s.IntegrationRedisSuite.SetupTest() + s.cache = NewSessionLimitCache(s.rdb, 5) +} + +// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景: +// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住 +// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。 +// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() { + const accountID = int64(101) + maxSessions := 1 + idleTimeout := 5 * time.Minute + + // 会话 A 注册成功(请求开始转发) + allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession A") + s.True(allowed, "首个会话应被允许") + + // 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为) + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B before release") + s.False(allowed, "槽位被 A 占用时新会话应被拒绝") + + // 请求最终失败:立即释放 A 的会话注册(不等待空闲超时) + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + // A 释放后 B 立即可注册,无需等待空闲超时 + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B after release") + s.True(allowed, "释放后新会话应立即可注册") + + count, err := s.cache.GetActiveSessionCount(s.ctx, accountID) + s.RequireNoError(err, "GetActiveSessionCount") + s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)") +} + +// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等: +// 重复释放与释放不存在的会话均不应报错。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() { + const accountID = int64(102) + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话") + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放") + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op") +} + +// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话, +// 不影响同账号上的其他活跃会话。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() { + const accountID = int64(103) + maxSessions := 2 + idleTimeout := 5 * time.Minute + + for _, sid := range []string{"session-A", "session-B"} { + allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession "+sid) + s.True(allowed) + } + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A") + s.RequireNoError(err, "IsSessionActive A") + s.False(active, "A 应已释放") + + active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B") + s.RequireNoError(err, "IsSessionActive B") + s.True(active, "B 不应受影响") +} + +func TestSessionLimitCacheSuite(t *testing.T) { + suite.Run(t, new(SessionLimitCacheSuite)) +} diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 977a28a99d..2cc0c8ba75 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -1435,6 +1435,28 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A return allowed } +// ReleaseAccountSession 立即释放会话槽(不等待空闲超时) +// 供 handler 在请求最终失败(选号成功但转发失败/客户端中断)时调用: +// 上游从未真正服务该会话,若继续占槽,max_sessions 受限的账号会被失败请求的 +// session hash 卡满整个空闲窗口,后续新会话全部被拒。 +// 适用条件与 checkAndRegisterSession 对齐;不适用账号为 no-op,幂等可安全重复调用。 +func (s *GatewayService) ReleaseAccountSession(ctx context.Context, account *Account, sessionID string) { + if s == nil || s.sessionLimitCache == nil || account == nil || sessionID == "" { + return + } + if !account.IsAnthropicOAuthOrSetupToken() { + return + } + if account.GetMaxSessions() <= 0 { + return + } + if err := s.sessionLimitCache.UnregisterSession(ctx, account.ID, sessionID); err != nil { + slog.Debug("session_limit.release_failed", + "account_id", account.ID, + "error", err) + } +} + func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) { var ( account *Account diff --git a/backend/internal/service/session_limit_cache.go b/backend/internal/service/session_limit_cache.go index 5482d61063..41f8fd5b0b 100644 --- a/backend/internal/service/session_limit_cache.go +++ b/backend/internal/service/session_limit_cache.go @@ -33,6 +33,12 @@ type SessionLimitCache interface { // 用于活跃会话保持活动状态 RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error + // UnregisterSession 立即移除会话注册(不等待空闲超时) + // 用于请求最终失败的场景:上游从未真正服务该会话,不应继续占用会话槽, + // 否则 max_sessions 受限的账号会被失败请求的 session hash 卡满整个空闲窗口, + // 导致后续新会话在超时窗口内全部被拒 + UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error + // GetActiveSessionCount 获取当前活跃会话数 // 返回未过期的会话数量 GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) diff --git a/backend/internal/service/session_limit_release_test.go b/backend/internal/service/session_limit_release_test.go new file mode 100644 index 0000000000..ba29ccec27 --- /dev/null +++ b/backend/internal/service/session_limit_release_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// sessionLimitReleaseCacheStub 记录 UnregisterSession 调用,用于验证释放逻辑 +type sessionLimitReleaseCacheStub struct { + SessionLimitCache + + unregistered map[int64][]string + err error +} + +func newSessionLimitReleaseCacheStub() *sessionLimitReleaseCacheStub { + return &sessionLimitReleaseCacheStub{ + unregistered: make(map[int64][]string), + } +} + +func (s *sessionLimitReleaseCacheStub) UnregisterSession(_ context.Context, accountID int64, sessionUUID string) error { + if s.err != nil { + return s.err + } + s.unregistered[accountID] = append(s.unregistered[accountID], sessionUUID) + return nil +} + +func newSessionLimitTestAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{"max_sessions": 1}, + } +} + +// TestReleaseAccountSession_ReleasesRegisteredSlot 验证: +// 对启用会话限制的 Anthropic OAuth 账号,ReleaseAccountSession 必须立即移除 +// 该账号上注册的会话(不等待空闲超时)。 +func TestReleaseAccountSession_ReleasesRegisteredSlot(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash-1") + + require.Equal(t, []string{"session-hash-1"}, cache.unregistered[42], + "应立即移除注册的会话槽") +} + +// TestReleaseAccountSession_NoOpForInapplicableAccounts 验证: +// - 非 Anthropic OAuth/SetupToken 账号 +// - 未启用 max_sessions 的账号 +// - 空 sessionID +// 以上场景均为 no-op,不得触发 UnregisterSession。 +func TestReleaseAccountSession_NoOpForInapplicableAccounts(t *testing.T) { + apiKeyAcc := &Account{ + ID: 43, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{"max_sessions": 1}, + } + noLimitAcc := &Account{ + ID: 44, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + } + enabledAcc := newSessionLimitTestAccount() + + cases := []struct { + name string + account *Account + sessionID string + }{ + {"api_key_account", apiKeyAcc, "session-hash"}, + {"max_sessions_disabled", noLimitAcc, "session-hash"}, + {"empty_session_id", enabledAcc, ""}, + {"nil_account", nil, "session-hash"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), tc.account, tc.sessionID) + require.Empty(t, cache.unregistered, "不适用账号不应触发释放") + }) + } +} + +// TestReleaseAccountSession_NilCacheAndErrorTolerance 验证: +// sessionLimitCache 不可用时 no-op;UnregisterSession 返回错误时不 panic(仅记录日志)。 +func TestReleaseAccountSession_NilCacheAndErrorTolerance(t *testing.T) { + // nil cache:no-op + svc := &GatewayService{} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") + + // 底层错误:不 panic + cache := &sessionLimitReleaseCacheStub{err: errors.New("redis down")} + svc = &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") +} + +// TestReleaseAccountSession_Idempotent 验证释放操作幂等,可安全重复调用 +// (failover 链上按次释放 + defer 兜底可能对同一账号重复释放)。 +func TestReleaseAccountSession_Idempotent(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + + // 两次调用都透传到缓存层(Redis ZREM 本身幂等,重复移除无副作用) + require.Len(t, cache.unregistered[42], 2) +} diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 339d57d867..c9b37efb9f 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -139,6 +139,9 @@ func (c StubSessionLimitCache) RegisterSession(_ context.Context, _ int64, _ str func (c StubSessionLimitCache) RefreshSession(_ context.Context, _ int64, _ string, _ time.Duration) error { return nil } +func (c StubSessionLimitCache) UnregisterSession(_ context.Context, _ int64, _ string) error { + return nil +} func (c StubSessionLimitCache) GetActiveSessionCount(_ context.Context, _ int64) (int, error) { return 0, nil } From 8249ab37dbc20eff0abfebac58d63dd9a7fd62a7 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 2 Sep 2026 20:10:05 +0800 Subject: [PATCH 04/53] feat: add Anthropic reasoning effort pricing controls --- .../internal/handler/admin/channel_handler.go | 148 +++++++++--------- .../handler/admin/channel_handler_test.go | 20 ++- .../internal/handler/admin/group_handler.go | 6 +- .../handler/available_channel_handler.go | 42 ++--- .../internal/handler/composite_platform.go | 46 +++++- .../handler/composite_platform_test.go | 70 +++++++++ backend/internal/handler/dto/types.go | 4 +- backend/internal/handler/gateway_handler.go | 13 ++ .../repository/channel_repo_pricing.go | 18 +-- .../channel_repo_pricing_time_test.go | 20 +-- .../internal/service/account_stats_pricing.go | 34 +++- .../service/account_stats_pricing_test.go | 12 ++ backend/internal/service/admin_service.go | 4 +- .../service/admin_service_group_test.go | 2 +- .../internal/service/api_key_auth_cache.go | 2 +- backend/internal/service/billing_service.go | 57 ++++++- .../internal/service/billing_service_test.go | 2 + .../service/billing_token_cost_request.go | 41 ++--- .../billing_token_cost_request_test.go | 38 +++++ backend/internal/service/channel.go | 39 ++--- backend/internal/service/channel_available.go | 51 +++--- .../internal/service/gateway_usage_billing.go | 19 +-- backend/internal/service/group.go | 2 +- .../internal/service/model_plaza_service.go | 18 ++- .../service/model_plaza_service_test.go | 13 ++ .../service/model_pricing_resolver.go | 3 + .../internal/service/openai_gateway_usage.go | 10 +- .../service/openai_reasoning_effort_policy.go | 33 ++-- .../openai_reasoning_effort_policy_test.go | 30 +++- ...hannel_max_reasoning_effort_multiplier.sql | 11 ++ frontend/src/api/admin/channels.ts | 2 + frontend/src/api/channels.ts | 1 + .../admin/channel/PricingEntryCard.vue | 14 +- .../PricingEntryCard.timePricing.spec.ts | 6 +- .../src/components/admin/channel/types.ts | 1 + .../modelPlaza/PlazaModelPricingTable.vue | 7 + .../__tests__/PlazaModelPricingTable.spec.ts | 9 ++ .../src/i18n/locales/en/admin/channels.ts | 4 +- .../src/i18n/locales/en/admin/overview.ts | 2 +- frontend/src/i18n/locales/en/dashboard.ts | 2 + .../src/i18n/locales/zh/admin/channels.ts | 4 +- .../src/i18n/locales/zh/admin/overview.ts | 2 +- frontend/src/i18n/locales/zh/dashboard.ts | 2 + frontend/src/types/index.ts | 2 +- frontend/src/views/admin/ChannelsView.vue | 7 +- .../__tests__/groupsReasoningEffort.spec.ts | 20 ++- .../src/views/admin/groupsReasoningEffort.ts | 18 ++- 47 files changed, 664 insertions(+), 247 deletions(-) create mode 100644 backend/migrations/234_channel_max_reasoning_effort_multiplier.sql diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 9723e3595b..2a7052c745 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,21 +57,22 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` - FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` - TimePricing *channelTimePricingRequest `json:"time_pricing"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` } type channelTimePricingRequest struct { @@ -129,22 +130,23 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` - TimePricing *channelTimePricingResponse `json:"time_pricing"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` } type channelTimePricingResponse struct { @@ -258,22 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon intervals = append(intervals, intervalToResponse(iv)) } return channelModelPricingResponse{ - ID: p.ID, - Platform: platform, - Models: models, - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - FastMultiplier: p.FastMultiplier, - FlexMultiplier: p.FlexMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingToResponse(p.TimePricing), + ID: p.ID, + Platform: platform, + Models: models, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), } } @@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult SortOrder: iv.SortOrder, }) } - var fastMultiplier, flexMultiplier *float64 + var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64 if allowChannelMultipliers { fastMultiplier = r.FastMultiplier flexMultiplier = r.FlexMultiplier + maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier } result = append(result, service.ChannelModelPricing{ - Platform: platform, - Models: r.Models, - BillingMode: billingMode, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - CacheWritePrice: r.CacheWritePrice, - CacheWrite1hPrice: r.CacheWrite1hPrice, - CacheReadPrice: r.CacheReadPrice, - FastMultiplier: fastMultiplier, - FlexMultiplier: flexMultiplier, - ImageInputPrice: r.ImageInputPrice, - ImageOutputPrice: r.ImageOutputPrice, - PerRequestPrice: r.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingRequestToService(r.TimePricing), + Platform: platform, + Models: r.Models, + BillingMode: billingMode, + InputPrice: r.InputPrice, + OutputPrice: r.OutputPrice, + CacheWritePrice: r.CacheWritePrice, + CacheWrite1hPrice: r.CacheWrite1hPrice, + CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier, + ImageInputPrice: r.ImageInputPrice, + ImageOutputPrice: r.ImageOutputPrice, + PerRequestPrice: r.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result @@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { } response.Success(c, gin.H{ - "found": true, - "input_price": pricing.InputPricePerToken, - "output_price": pricing.OutputPricePerToken, - "cache_write_price": cacheWritePrice, - "cache_write_1h_price": cacheWrite1hPrice, - "cache_read_price": pricing.CacheReadPricePerToken, - "image_input_price": pricing.ImageInputPricePerToken, - "image_output_price": pricing.ImageOutputPricePerToken, + "found": true, + "input_price": pricing.InputPricePerToken, + "output_price": pricing.OutputPricePerToken, + "cache_write_price": cacheWritePrice, + "cache_write_1h_price": cacheWrite1hPrice, + "cache_read_price": pricing.CacheReadPricePerToken, + "max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier, + "image_input_price": pricing.ImageInputPricePerToken, + "image_output_price": pricing.ImageOutputPricePerToken, }) } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index cca67c9e47..ef0d5a9c8f 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) { // 避免渠道倍率意外污染账号成本口径。 func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { req := channelModelPricingRequest{ - Models: []string{"gpt-5"}, - BillingMode: "token", - FastMultiplier: float64Ptr(2.5), - FlexMultiplier: float64Ptr(0.5), + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + MaxReasoningEffortMultiplier: float64Ptr(3), Intervals: []pricingIntervalRequest{{ MinTokens: 272000, InputMultiplier: float64Ptr(2), @@ -469,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) @@ -477,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) require.Nil(t, dropped[0].FastMultiplier) require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].MaxReasoningEffortMultiplier) require.Nil(t, dropped[0].Intervals[0].InputMultiplier) require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) @@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) var body struct { Data struct { - Found bool `json:"found"` - CacheWritePrice float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + Found bool `json:"found"` + CacheWritePrice float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` } `json:"data"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) @@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12) require.NotNil(t, body.Data.CacheWrite1hPrice) require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12) + require.NotNil(t, body.Data.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier) } func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) { diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fa6991ee66..acf53eddba 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -156,11 +156,11 @@ type CreateGroupRequest struct { ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 + // Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` @@ -229,7 +229,7 @@ type UpdateGroupRequest struct { ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 + // Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。 MaxReasoningEffort *string `json:"max_reasoning_effort"` // 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。 MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"` diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 5d35ff389e..ffa2408bcd 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -66,16 +66,17 @@ type userAvailableGroup struct { // userSupportedModelPricing 用户可见的定价字段白名单。 type userSupportedModelPricing struct { - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []userPricingIntervalDTO `json:"intervals"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []userPricingIntervalDTO `json:"intervals"` } // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 @@ -323,15 +324,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { billingMode = string(service.BillingModeToken) } return &userSupportedModelPricing{ - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, } } diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 49e87cb332..6e613aa3e4 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe if apiKey == nil || apiKey.Group == nil { return "", nil, "", false } - if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { return "", nil, "", false } - if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) + if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI { return "", nil, "", false } - return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true + maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings + if effectivePlatform == service.PlatformAnthropic { + maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings) + } + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) { + if apiKey == nil || apiKey.Group == nil { + return "", nil, "", false + } + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite { + return "", nil, "", false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic { + return "", nil, "", false + } + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings) + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) { + if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" { + maxEffort = "low" + } + normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...) + for i := range normalizedMappings { + if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" { + normalizedMappings[i].To = "low" + } + } + return maxEffort, normalizedMappings } func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) { @@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu write(c, http.StatusForbidden, "permission_error", err.Error()) } +func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) { + maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return body, false, nil + } + return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} + func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { if c == nil || c.Request == nil { return diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 73db844e7e..d9a642ce06 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -8,6 +8,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) { @@ -160,6 +161,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { require.Equal(t, body, got) } +func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + apiKey := &service.APIKey{Group: &service.Group{ + Platform: service.PlatformAnthropic, + MaxReasoningEffort: "xhigh", + }} + + got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`)) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String()) +} + +func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`) + policy := func(platform string) *service.APIKey { + return &service.APIKey{Group: &service.Group{ + Platform: platform, + MaxReasoningEffort: "xhigh", + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + }} + } + + concrete, _ := gin.CreateTestContext(httptest.NewRecorder()) + concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil) + got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got)) + + denyMax := policy(service.PlatformAnthropic) + denyMax.Group.ReasoningEffortMappings = nil + denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny + _, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body) + require.Error(t, err) + require.False(t, changed) + var overLimit *service.ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + require.Equal(t, "max", overLimit.Requested) + require.Equal(t, "xhigh", overLimit.Max) + + composite, _ := gin.CreateTestContext(httptest.NewRecorder()) + composite.Request = httptest.NewRequest("POST", "/v1/messages", nil) + composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String()) + + openAI, _ := gin.CreateTestContext(httptest.NewRecorder()) + openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil) + openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, body, got) +} + +func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) { + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}}) + require.Equal(t, "low", maxEffort) + require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings) +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..343bf22078 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -151,11 +151,11 @@ type Group struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..73bbeb01d6 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) { reqStream := parsedReq.Stream bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) + if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) + return + } else if changed { + if err := parsedReq.ReplaceBody(policyBody); err != nil { + reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err)) + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy") + return + } + body = parsedReq.Body.Bytes() + reqModel = parsedReq.Model + reqStream = parsedReq.Stream + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) // 解析渠道级模型映射 diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 1a88eeb977..8053d4453f 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -61,10 +61,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW() - WHERE id = $15`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() + WHERE id = $16`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { @@ -96,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -180,7 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.FastMultiplier, &p.FlexMultiplier, + &p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -247,11 +247,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index 9532cdf162..c22630abcf 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("update writes JSON and entry ID", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("update writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 8ac3dba383..c25a66f674 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -17,6 +17,7 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, @@ -28,7 +29,12 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } if channelService == nil || upstreamModel == "" { return nil } @@ -40,7 +46,7 @@ func resolveAccountStatsCost( platform := channelService.GetGroupPlatform(ctx, groupID) // 优先级 1:自定义规则(始终尝试) - if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil { + if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount, reasoningEffort); cost != nil { return cost } @@ -55,7 +61,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort) } return nil @@ -65,13 +71,18 @@ func resolveAccountStatsCost( // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, // 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 // 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 { +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } breakdown, err := billingService.CalculateCostWithServiceTier( model, tokens, 1, normalizeBillingServiceTier(serviceTier), ) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)) return &breakdown.TotalCost } @@ -79,7 +90,12 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us func tryCustomRules( channel *Channel, accountID, groupID int64, platform, model string, tokens UsageTokens, requestCount int, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } modelLower := strings.ToLower(model) for _, rule := range channel.AccountStatsPricingRules { if !matchAccountStatsRule(&rule, accountID, groupID) { @@ -89,7 +105,11 @@ func tryCustomRules( if pricing == nil { continue // 规则匹配但模型不在规则定价中,继续下一条 } - return calculateStatsCost(pricing, tokens, requestCount) + cost := calculateStatsCost(pricing, tokens, requestCount) + if cost != nil { + *cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil) + } + return cost } return nil } @@ -243,10 +263,14 @@ func applyAccountStatsCost( requestCount = usageLog.ImageCount } serviceTier := "" + reasoningEffort := "" if usageLog != nil && usageLog.ServiceTier != nil { serviceTier = *usageLog.ServiceTier } + if usageLog != nil && usageLog.ReasoningEffort != nil { + reasoningEffort = *usageLog.ReasoningEffort + } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 97628f58c0..a346f4405f 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -478,6 +478,18 @@ func TestTryModelFilePricing_Success(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "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") + require.NotNil(t, standard) + require.NotNil(t, max) + require.InDelta(t, *standard*3, *max, 1e-12) +} + func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ "gpt-5.6-sol": { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 392f8d5d46..81d76c15f1 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -275,11 +275,11 @@ type CreateGroupInput struct { ModelsListConfig GroupModelsListConfig // RPMLimit 分组 RPM 上限(0 = 不限制) RPMLimit int - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string - // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []ReasoningEffortMapping // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) ProfitControlEnabled bool diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index e14c2a0b39..bc240850ef 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -960,7 +960,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} - _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic}) + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformGemini}) require.NoError(t, err) require.Empty(t, repo.updated.MaxReasoningEffort) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 59d2774cd7..82067dcfcf 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -112,7 +112,7 @@ type APIKeyAuthGroupSnapshot struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1f3a346f64..03e13bd772 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -105,6 +105,7 @@ type ModelPricing struct { CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为 CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) SupportsCacheBreakdown bool // 是否支持详细的缓存分类 @@ -214,6 +215,42 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { cost.ActualCost *= multiplier } +const claudeFable51MaxReasoningEffortMultiplier = 3.0 + +func isClaudeFable51Model(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} { + if at := strings.Index(model, marker); at >= 0 { + after := at + len(marker) + if after == len(model) || model[after] < '0' || model[after] > '9' { + return true + } + } + } + return false +} + +func defaultMaxReasoningEffortMultiplier(model string) *float64 { + if !isClaudeFable51Model(model) { + return nil + } + multiplier := claudeFable51MaxReasoningEffortMultiplier + return &multiplier +} + +func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 { + if NormalizeMaxReasoningEffort(effort) != "max" { + return 1 + } + if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 { + return *pricing.MaxReasoningEffortMultiplier + } + if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil { + return *multiplier + } + return 1 +} + func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil { return 1 @@ -803,8 +840,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { modelLower := strings.ToLower(model) // 按模型系列匹配 - if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") || - strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") { + if isClaudeFable51Model(modelLower) { return s.fallbackPrices["claude-fable-5-1"] } if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { @@ -1169,6 +1205,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing applyChannelTokenPriceOverrides(pricing, channelPricing) pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier + if channelPricing.MaxReasoningEffortMultiplier != nil { + pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier + } if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1241,6 +1280,7 @@ type CostInput struct { RateMultiplier float64 PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 + ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool @@ -1255,7 +1295,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if input.LongContextBillingEnabled != nil { applyLongContextBilling = *input.LongContextBillingEnabled } - return s.calculateCostInternalWithPolicy( + breakdown, err := s.calculateCostInternalWithPolicy( input.Model, input.Tokens, input.RateMultiplier, @@ -1263,6 +1303,10 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, nil, applyLongContextBilling, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil)) + } + return breakdown, err } // 优先使用预解析结果,避免重复 Resolve 调用 @@ -1346,6 +1390,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing)) return breakdown, nil } @@ -1627,13 +1672,17 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) + needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier { return pricing } cloned := *pricing + if needsMaxReasoningEffortMultiplier { + cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model) + } if isGPT56 && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 956fe73d33..0a93278a08 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1770,6 +1770,8 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) { require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) + require.NotNil(t, pricing.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier) } func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index 6a59be16a5..0794864dd0 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -7,14 +7,15 @@ import ( // TokenCostRequest 通用网关 token 计费请求。 type TokenCostRequest struct { - Ctx context.Context - Model string - Group *Group - Tokens UsageTokens - RateMultiplier float64 - PricingAt time.Time - ServiceTier string - Resolver *ModelPricingResolver + Ctx context.Context + Model string + Group *Group + Tokens UsageTokens + RateMultiplier float64 + PricingAt time.Time + ServiceTier string + ReasoningEffort string + Resolver *ModelPricingResolver // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 Resolved *ResolvedPricing } @@ -33,21 +34,25 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co if req.Resolver != nil && req.Group != nil { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } + if req.ReasoningEffort != "" { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier) } func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput { input := CostInput{ - Ctx: req.Ctx, - Model: req.Model, - Group: req.Group, - Tokens: req.Tokens, - RequestCount: 1, - RateMultiplier: req.RateMultiplier, - PricingAt: req.PricingAt, - ServiceTier: req.ServiceTier, - Resolver: req.Resolver, - Resolved: resolved, + Ctx: req.Ctx, + Model: req.Model, + Group: req.Group, + Tokens: req.Tokens, + RequestCount: 1, + RateMultiplier: req.RateMultiplier, + PricingAt: req.PricingAt, + ServiceTier: req.ServiceTier, + ReasoningEffort: req.ReasoningEffort, + Resolver: req.Resolver, + Resolved: resolved, } if req.Group != nil { gid := req.Group.ID diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 9d282de061..7376d8de19 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -216,3 +216,41 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) require.NoError(t, err) require.Equal(t, want, got) } + +func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} + standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh", + }) + require.NoError(t, err) + max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max", + }) + require.NoError(t, err) + require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12) + require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12) + require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12) + require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12) +} + +func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) { + configured := 1.5 + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6), + MaxReasoningEffortMultiplier: &configured, + }}, nil) + group := &Group{ID: 100, Platform: PlatformAnthropic} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group}) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-fable-5-1", Group: group, + Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max", + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12) + require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index df3b5f892a..f9fea3bcc9 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -87,25 +87,26 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // ChannelTimePricing 渠道模型定价的分时倍率配置。 diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 08b02eb423..46eb15d6bb 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -117,20 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。 +// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。 +// 可用渠道与模型广场共用。 func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { - if pricingService == nil { - return - } for i := range models { - if !pricingNeedsFallback(models[i].Pricing) { - continue + if pricingService != nil && pricingNeedsFallback(models[i].Pricing) { + if lp := pricingService.GetModelPricing(models[i].Name); lp != nil { + models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + } } - lp := pricingService.GetModelPricing(models[i].Name) - if lp == nil { - continue - } - models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name) } } @@ -180,24 +176,33 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode if mode == BillingModeImage || mode == BillingModePerRequest { return &ChannelModelPricing{ - BillingMode: mode, - PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + BillingMode: mode, + PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } return &ChannelModelPricing{ - BillingMode: mode, - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + BillingMode: mode, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } +func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 { + if pricing == nil { + return nil + } + return pricing.MaxReasoningEffortMultiplier +} + func nonZeroPtr(v float64) *float64 { if v == 0 { return nil diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 069758baae..c2922d03e2 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1092,15 +1092,16 @@ func (s *GatewayService) calculateTokenCost( } cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ - Ctx: ctx, - Model: billingModel, - Group: apiKey.Group, - Tokens: tokens, - RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index fc9996da3b..d22026ad80 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -112,7 +112,7 @@ type Group struct { // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。 RPMLimit int - // MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort. + // MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort. // Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max. MaxReasoningEffort string // MaxReasoningEffortOverLimit is the access control when an explicit effort diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index fe2252c594..6b2a87b263 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -231,7 +231,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,7 +239,20 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = plazaImageDisplayPricing(m.Pricing, g) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) +} + +func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { + if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { + return pricing + } + multiplier := defaultMaxReasoningEffortMultiplier(model) + if multiplier == nil { + return pricing + } + cloned := pricing.Clone() + cloned.MaxReasoningEffortMultiplier = multiplier + return &cloned } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -250,6 +263,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice + out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier } first := sched.Tiers[0] out.InputPrice = first.Input diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index 4a588ecb0f..e093a9def9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,6 +57,19 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } +func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { + base := &ChannelModelPricing{BillingMode: BillingModeToken} + got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") + require.NotSame(t, base, got) + require.NotNil(t, got.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) + require.Nil(t, base.MaxReasoningEffortMultiplier) + + configured := 1.25 + custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} + require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +} + func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { // 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。 unpriced := Channel{ diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 1a5382e7c3..ecd3d45209 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -247,6 +247,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier + if chPricing.MaxReasoningEffortMultiplier != nil { + resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier + } // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cb998b55fa..1a37f0e411 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -610,6 +610,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( pricingAt, tokens, serviceTier, + optionalStringValue(result.ReasoningEffort), longContextBillingGate, ) if err == nil { @@ -703,6 +704,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( pricingAt time.Time, tokens UsageTokens, serviceTier string, + reasoningEffort string, longContextBillingGate *bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { @@ -710,17 +712,21 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, - ServiceTier: serviceTier, Resolver: s.resolver, + ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) } - return s.billingService.calculateCostWithServiceTierPolicy( + breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) + } + return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index caa1291120..2ae6214846 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -22,6 +22,7 @@ const ( ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"} type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -83,10 +84,14 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI && platform != PlatformComposite { + switch platform { + case PlatformOpenAI, PlatformComposite: + return openAIReasoningEffortValues + case PlatformAnthropic: + return anthropicReasoningEffortValues + default: return nil } - return openAIReasoningEffortValues } func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) { @@ -97,7 +102,8 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -145,7 +151,8 @@ func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (stri } if len(reasoningEffortValuesForPlatform(platform)) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -272,7 +279,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI routes. Optional model scopes are +// fixed effort values supported by the selected platform. Optional model scopes are // canonicalized to exact/prefix/suffix match; empty type and model keep the // global all-models rule. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { @@ -407,7 +414,7 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { if mappingsErr != nil { mappings = []ReasoningEffortMapping{} } - if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { + if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { overLimit = ReasoningEffortOverLimitDowngrade } group.MaxReasoningEffort = maxEffort @@ -415,12 +422,13 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { group.ReasoningEffortMappings = mappings } -// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to +// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) and then either caps // known effort levels or rejects the request when the group is configured to // deny values above the ceiling. Omitted values remain untouched so upstream -// defaults stay in control. -func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { +// defaults stay in control. It understands both OpenAI and Anthropic request +// field shapes. +func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { return body, false, nil @@ -431,7 +439,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) result := body changed := false - for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} { field := gjson.GetBytes(result, path) if !field.Exists() || field.Type != gjson.String { continue @@ -478,3 +486,8 @@ func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHo } return payload, nil } + +// ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers. +func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { + return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index fae609d7c1..d1188ceb26 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -147,9 +147,9 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { }) t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) @@ -158,6 +158,15 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { _, err = NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) require.ErrorContains(t, err, "empty or unknown") }) + + t.Run("supports Anthropic values except minimal", func(t *testing.T) { + got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got) + + _, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}}) + require.ErrorContains(t, err, "not supported for platform \"anthropic\"") + }) } func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { @@ -168,9 +177,15 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, "max", value) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh") + require.NoError(t, err) + require.Equal(t, "xhigh", value) + _, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal") + require.ErrorContains(t, err, "not supported") + + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") @@ -197,8 +212,9 @@ func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, ReasoningEffortOverLimitDowngrade, value) - _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDeny, value) _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block") require.ErrorContains(t, err, "not supported") @@ -243,6 +259,8 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }{ {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, + {name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true}, + {name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true}, {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false}, {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false}, {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, diff --git a/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql new file mode 100644 index 0000000000..07409f1162 --- /dev/null +++ b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql @@ -0,0 +1,11 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS max_reasoning_effort_multiplier NUMERIC(10,4); + +COMMENT ON COLUMN channel_model_pricing.max_reasoning_effort_multiplier IS + 'Billing/quota multiplier applied when the forwarded reasoning effort is max'; + +ALTER TABLE channel_model_pricing + DROP CONSTRAINT IF EXISTS chk_channel_model_pricing_max_reasoning_effort_multiplier_positive; +ALTER TABLE channel_model_pricing + ADD CONSTRAINT chk_channel_model_pricing_max_reasoning_effort_multiplier_positive + CHECK (max_reasoning_effort_multiplier IS NULL OR max_reasoning_effort_multiplier > 0); diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 3b355bc85f..d956bb92c7 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,6 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -179,6 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number + max_reasoning_effort_multiplier?: number | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 6a88cc5ed8..c998fc3efb 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -41,6 +41,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index 8465887dc2..ba6fe3cf2f 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -144,7 +144,7 @@ -
+
+
+ + +
@@ -308,6 +313,12 @@ const billingModeLabel = computed(() => { return opt ? opt.label : props.entry.billing_mode }) +const maxReasoningEffortMultiplierPlaceholder = computed(() => + props.entry.models.some(model => /fable(?:-5-1|-5\.1|5\.1|51)(?!\d)/i.test(model)) + ? t('admin.channels.form.fable51DefaultMaxReasoningMultiplier') + : t('admin.channels.form.multiplierPlaceholder') +) + function emitField(field: keyof PricingFormEntry, value: string) { emit('update', { ...props.entry, [field]: value === '' ? null : value }) } @@ -383,6 +394,7 @@ async function onModelsUpdate(newModels: string[]) { cache_read_price: perTokenToMTok(result.cache_read_price ?? null), image_input_price: perTokenToMTok(result.image_input_price ?? null), image_output_price: perTokenToMTok(result.image_output_price ?? null), + max_reasoning_effort_multiplier: result.max_reasoning_effort_multiplier ?? null, }) } } catch { diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts index b139f95518..947ac41c1f 100644 --- a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts @@ -18,6 +18,7 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -72,8 +73,8 @@ describe('PricingEntryCard time pricing visibility', () => { }) }) -describe('PricingEntryCard service tier multipliers', () => { - it('shows Fast and Flex controls only when explicitly enabled', () => { +describe('PricingEntryCard request multipliers', () => { + it('shows Fast, Flex, and Max effort controls only when explicitly enabled', () => { const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } }) expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier') @@ -82,5 +83,6 @@ describe('PricingEntryCard service tier multipliers', () => { }) expect(shown.text()).toContain('admin.channels.form.fastMultiplier') expect(shown.text()).toContain('admin.channels.form.flexMultiplier') + expect(shown.text()).toContain('admin.channels.form.maxReasoningEffortMultiplier') }) }) diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 8cdc1417a3..0575e1b8c6 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -29,6 +29,7 @@ export interface PricingFormEntry { cache_read_price: number | string | null fast_multiplier?: number | string | null flex_multiplier?: number | string | null + max_reasoning_effort_multiplier?: number | string | null image_input_price: number | string | null image_output_price: number | string | null per_request_price: number | string | null diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 05eb586951..43aee2c140 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -99,6 +99,13 @@ > {{ t('modelPlaza.table.marginalBadge') }} + + {{ t('modelPlaza.table.maxReasoningMultiplierBadge', { multiplier: m.pricing.max_reasoning_effort_multiplier }) }} +
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 2971431a93..902195f384 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -68,6 +68,15 @@ describe('PlazaModelPricingTable', () => { expect(text).toContain('1x') }) + it('shows the Max reasoning billing multiplier', () => { + const model = tokenModel() + model.pricing!.max_reasoning_effort_multiplier = 3 + const wrapper = mountTable([model], 1) + + expect(wrapper.text()).toContain('modelPlaza.table.maxReasoningMultiplierBadge') + expect(wrapper.find('[title="modelPlaza.table.maxReasoningMultiplierHint"]').exists()).toBe(true) + }) + it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => { const wrapper = mountTable([tokenModel()], 0.5) const text = wrapper.text() diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 5055dc8fd6..ff8648cdf2 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -138,8 +138,10 @@ export default { pricePlaceholder: 'Default', fastMultiplier: 'Fast Multiplier', flexMultiplier: 'Flex Multiplier', + maxReasoningEffortMultiplier: 'Max Effort Multiplier', + fable51DefaultMaxReasoningMultiplier: 'Default: 3', multiplierPlaceholder: 'Not set', - multiplierPositive: 'Fast/Flex multipliers must be greater than 0', + multiplierPositive: 'Fast/Flex/Max effort multipliers must be greater than 0', inputMultiplier: 'Input Mult.', outputMultiplier: 'Output Mult.', cacheWriteMultiplier: 'Cache Write Mult.', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index a31cb65e10..a055077096 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -859,7 +859,7 @@ export default { rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).', maxReasoningEffort: 'Max reasoning effort', maxReasoningEffortUnlimited: 'Unlimited (follow request)', - maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', + maxReasoningEffortHint: 'Limits explicit Anthropic and OpenAI reasoning effort requests. For Composite groups, it applies to the resolved target platform. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', maxReasoningEffortOverLimit: 'Over-limit access control', maxReasoningEffortOverLimitDowngrade: 'Automatically downgrade when over limit', maxReasoningEffortOverLimitDeny: 'Deny access', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 576067c6a3..6855e41d03 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -635,6 +635,8 @@ export default { cacheReadShort: 'R', tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)', tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected', + maxReasoningMultiplierBadge: 'Max ×{multiplier}', + maxReasoningMultiplierHint: 'When the forwarded reasoning effort is max, billing and quota usage for the request are multiplied by {multiplier}', marginalBadge: 'excess-only tiers', timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row', timePricingRowHintWeekdays: diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index b9e40a6a20..d4a5145676 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -138,8 +138,10 @@ export default { pricePlaceholder: '默认', fastMultiplier: 'Fast 倍率', flexMultiplier: 'Flex 倍率', + maxReasoningEffortMultiplier: 'Max 推理倍率', + fable51DefaultMaxReasoningMultiplier: '默认 3', multiplierPlaceholder: '未配置', - multiplierPositive: 'Fast/Flex 倍率必须大于 0', + multiplierPositive: 'Fast/Flex/Max 推理倍率必须大于 0', inputMultiplier: '输入倍率', outputMultiplier: '输出倍率', cacheWriteMultiplier: '缓存写倍率', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 344bbc41f7..168e6e6824 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -841,7 +841,7 @@ export default { rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', maxReasoningEffort: '推理强度上限', maxReasoningEffortUnlimited: '不限制(跟随请求)', - maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', + maxReasoningEffortHint: '仅限制客户端主动请求的 Anthropic/OpenAI 推理强度;Composite 分组按实际目标平台生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', maxReasoningEffortOverLimit: '超限访问控制', maxReasoningEffortOverLimitDowngrade: '超过上限时自动降档', maxReasoningEffortOverLimitDeny: '拒绝访问', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 60117abbbc..a3977e3bc1 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -640,6 +640,8 @@ export default { cacheReadShort: '读', tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价', tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价', + maxReasoningMultiplierBadge: 'Max ×{multiplier}', + maxReasoningMultiplierHint: '最终转发的推理强度为 max 时,整次请求的计费与额度消耗乘以 {multiplier}', marginalBadge: '超出部分计价', timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费', timePricingRowHintWeekdays: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..8334b5a809 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -560,7 +560,7 @@ export interface Group { platform: GroupPlatform rate_multiplier: number rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set - max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited + max_reasoning_effort?: string // Anthropic/OpenAI reasoning ceiling; empty means unlimited max_reasoning_effort_over_limit?: string // downgrade (default) or deny when over the ceiling reasoning_effort_mappings?: ReasoningEffortMapping[] is_exclusive: boolean diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index f040d2e64a..6d1754dfe2 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -865,6 +865,7 @@ function addPricingEntry(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -902,6 +903,7 @@ async function syncLatestModels(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -1132,6 +1134,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ cache_read_price: mTokToPerToken(entry.cache_read_price), fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null, flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null, + max_reasoning_effort_multiplier: entry.max_reasoning_effort_multiplier != null && entry.max_reasoning_effort_multiplier !== '' ? Number(entry.max_reasoning_effort_multiplier) : null, image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null, @@ -1234,6 +1237,7 @@ function apiToForm(channel: Channel): PlatformSection[] { cache_read_price: perTokenToMTok(p.cache_read_price), fast_multiplier: p.fast_multiplier, flex_multiplier: p.flex_multiplier, + max_reasoning_effort_multiplier: p.max_reasoning_effort_multiplier, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1541,7 +1545,8 @@ async function handleSubmit() { for (const section of form.platforms.filter(s => s.enabled)) { for (const entry of section.model_pricing) { if (!isValidPositiveMultiplier(entry.fast_multiplier) || - !isValidPositiveMultiplier(entry.flex_multiplier)) { + !isValidPositiveMultiplier(entry.flex_multiplier) || + !isValidPositiveMultiplier(entry.max_reasoning_effort_multiplier)) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') appStore.showError(`${platformLabel} - ${modelLabel}: ${t('admin.channels.form.multiplierPositive')}`) diff --git a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts index 663d7cefd3..a9aec9ebd4 100644 --- a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts @@ -33,12 +33,14 @@ describe("groupsReasoningEffort", () => { ).toEqual(expected); expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true); } - for (const platform of [ - "anthropic", - "gemini", - "antigravity", - "grok", - ] as const) { + expect( + reasoningEffortOptionsForPlatform("anthropic").map( + (option) => option.value, + ), + ).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(supportsReasoningEffortPolicyPlatform("anthropic")).toBe(true); + + for (const platform of ["gemini", "antigravity", "grok"] as const) { expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]); expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false); } @@ -129,6 +131,12 @@ describe("groupsReasoningEffort", () => { expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe( "max", ); + expect(normalizeReasoningEffortForPlatform("anthropic", "xhigh")).toBe( + "xhigh", + ); + expect(normalizeReasoningEffortForPlatform("anthropic", "minimal")).toBe( + "", + ); expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe(""); expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe(""); }); diff --git a/frontend/src/views/admin/groupsReasoningEffort.ts b/frontend/src/views/admin/groupsReasoningEffort.ts index de2fbbee4b..d3f4544d4e 100644 --- a/frontend/src/views/admin/groupsReasoningEffort.ts +++ b/frontend/src/views/admin/groupsReasoningEffort.ts @@ -13,6 +13,14 @@ const openAIReasoningEffortValues = [ "max", ] as const; +const anthropicReasoningEffortValues = [ + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [ "exact", "prefix", @@ -25,14 +33,16 @@ export const reasoningEffortOverLimitDeny = "deny"; const reasoningEffortValuesForPlatform = ( platform: GroupPlatform, ): readonly string[] => - supportsReasoningEffortPolicyPlatform(platform) - ? openAIReasoningEffortValues - : []; + platform === "anthropic" + ? anthropicReasoningEffortValues + : supportsReasoningEffortPolicyPlatform(platform) + ? openAIReasoningEffortValues + : []; export function supportsReasoningEffortPolicyPlatform( platform: GroupPlatform, ): boolean { - return platform === "openai" || platform === "composite"; + return platform === "anthropic" || platform === "openai" || platform === "composite"; } export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) { From 10c8f523c49663d6061eabc1cdce13a2a975063f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 09:42:50 +0800 Subject: [PATCH 05/53] fix(openai): preserve passthrough reasoning effort --- .../service/openai_gateway_request_body.go | 3 +++ ...penai_gateway_request_body_reasoning_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 46f69b54d6..9dd1654727 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -59,6 +59,9 @@ func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { if account == nil { return false } + if account.IsOpenAIPassthroughEnabled() { + return true + } if account.IsOpenAIOAuthLike() { return true } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 4805eeeb24..013aaa2531 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -331,6 +331,23 @@ func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { } } +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount_APIKeyAutomaticPassthroughPreservesRequest(t *testing.T) { + body := []byte(`{"model":"qwen3.8-27b","input":"hi","max_output_tokens":20,"reasoning":{"effort":"none"},"presence_penalty":1.5}`) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }, + Extra: map[string]any{"openai_passthrough": true}, + } + + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + + require.NoError(t, err) + require.JSONEq(t, string(body), string(got)) +} + // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) From 5e4958c889e0e8a31591a819eb046562807d8beb Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 10:51:12 +0800 Subject: [PATCH 06/53] fix(openai): schedule accounts with mapped model --- .../handler/openai_chat_completions.go | 3 +- backend/internal/handler/openai_embeddings.go | 3 +- .../handler/openai_gateway_handler.go | 26 ++++++++----- .../handler/openai_gateway_handler_test.go | 38 ++++++++++++++++++- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index e74b11b9b6..147f4460a8 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -116,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) @@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { apiKey.GroupID, "", sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, service.OpenAIEndpointCapabilityChatCompletions, diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 913131114e..8dda9faf3f 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { } channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) subscription, _ := middleware2.GetSubscriptionFromContext(c) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) @@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { apiKey.GroupID, "", "", - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportHTTPSSE, service.OpenAIEndpointCapabilityEmbeddings, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..317b17fe1a 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -140,6 +140,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi return billingModel } +// openAIChannelForwardModel returns the model used for upstream capability +// routing. The client-requested model remains separate for logs, billing, and +// responses. +func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string { + if mapping.Mapped { + if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" { + return mappedModel + } + } + return requestedModel +} + type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } @@ -533,10 +545,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) - forwardModel := reqModel - if channelMapping.Mapped { - forwardModel = channelMapping.MappedModel - } + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) c.Request = c.Request.WithContext(service.WithOpenAIForwardModel( c.Request.Context(), forwardModel, @@ -629,7 +638,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, requiredCapability, @@ -2327,10 +2336,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) - wsForwardModel := reqModel - if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" { - wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel) - } + wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel) var currentUserRelease func() var currentAccountRelease func() @@ -2493,7 +2499,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + wsForwardModel, failedAccountIDs, requiredTransport, requiredCapability, diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 23acd158b3..54747cfdbd 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { "each turn must be billed with its own channel-mapped model") } -func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) { +func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) { got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`, secondPayload: `{"type":"response.create","stream":false}`, @@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey "public-alias": "gpt-5.6-sol", }, accountModelMapping: map[string]any{ - "public-alias": "gpt-5.6-terra", + "gpt-5.6-sol": "gpt-5.6-sol", }, }) @@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) { require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil)) } +func TestOpenAIChannelForwardModelForScheduler(t *testing.T) { + tests := []struct { + name string + mapping service.ChannelMappingResult + requested string + want string + }{ + { + name: "mapped model is used for capability filtering", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "}, + requested: "client-alias", + want: "gpt-forward", + }, + { + name: "unmapped request preserves client model", + mapping: service.ChannelMappingResult{MappedModel: "client-model"}, + requested: "client-model", + want: "client-model", + }, + { + name: "empty mapped model safely preserves client model", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "}, + requested: "client-model", + want: "client-model", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested)) + }) + } +} + func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) From af90a9bd1fbcee62c5e8a87dbb3bd71ee4216f27 Mon Sep 17 00:00:00 2001 From: NellPoi Date: Thu, 3 Sep 2026 11:39:23 +0800 Subject: [PATCH 07/53] =?UTF-8?q?fix(openai):=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=BB=BB=E5=8A=A1=E5=A7=94=E6=B4=BE=E7=BB=AD?= =?UTF-8?q?=E8=81=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_delegation_bootstrap_test.go | 18 ++++++--- .../handler/openai_gateway_handler.go | 37 ++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go index 9e30752aa6..38854c0781 100644 --- a/backend/internal/handler/openai_delegation_bootstrap_test.go +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { }{ {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, - {name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`}, - {name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`}, {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`}, - {name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`}, - {name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`}, {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`}, {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`}, {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`}, @@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`) + + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String()) + require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String()) + require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String()) +} + func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { tests := []struct { name string @@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { }{ {name: "absent", changed: true}, {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, - {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true}, {name: "null", value: `,"previous_response_id":null`}, {name: "number", value: `,"previous_response_id":42`}, {name: "boolean", value: `,"previous_response_id":false`}, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..3636ba8331 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1600,14 +1600,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, } func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate) + // 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id; + // 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入, + // 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。 + return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true) } func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate) + return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false) } -func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) { +func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) { if !hasUniqueJSONMembers(body) { return body, false } @@ -1619,7 +1622,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] } if previousResponseID, exists := request["previous_response_id"]; exists { value, ok := previousResponseID.(string) - if !ok || strings.TrimSpace(value) != "" { + if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") { return body, false } } @@ -1628,27 +1631,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] return body, false } - // Any call/reference anchor makes a call-less output ambiguous. Responses - // built-ins follow the *_call / *_call_output naming convention, so classify - // by the wire type shape instead of maintaining an incomplete allowlist. + // Responses built-ins follow the *_call / *_call_output naming convention, + // so classify by the wire type shape instead of maintaining an incomplete + // allowlist. Delegation may coexist with historical anchors only when their + // IDs make them unambiguous; automation retains the bootstrap-only boundary. for _, raw := range input { item, ok := raw.(map[string]any) if !ok { continue } typ := stringField(item, "type") - if typ == "item_reference" || strings.HasSuffix(typ, "_call") { - return body, false - } - if isResponsesCallOutputType(typ) { + if isCandidate(item) { callIDValue, exists := item["call_id"] callID, isString := callIDValue.(string) if exists && (!isString || strings.TrimSpace(callID) != "") { return body, false } - if !isCandidate(item) { - return body, false + continue + } + if typ == "item_reference" { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" { + continue } + return body, false + } + if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" { + continue + } + return body, false } } From 62198286e93a6ecf3c157417c80d136b68bd84c4 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 12:48:02 +0800 Subject: [PATCH 08/53] [verified] fix(handler): distinguish gateway admission rejections --- .../handler/concurrency_error_response.go | 15 ++- .../concurrency_error_response_test.go | 14 ++- backend/internal/handler/gateway_handler.go | 37 +++++--- .../handler/gateway_handler_responses.go | 2 +- .../handler/openai_gateway_handler.go | 32 +++---- .../internal/handler/stream_error_event.go | 9 +- .../handler/stream_error_event_test.go | 93 ++++++++++++++++++- 7 files changed, 163 insertions(+), 39 deletions(-) diff --git a/backend/internal/handler/concurrency_error_response.go b/backend/internal/handler/concurrency_error_response.go index 911f5a54d7..cd631a47b4 100644 --- a/backend/internal/handler/concurrency_error_response.go +++ b/backend/internal/handler/concurrency_error_response.go @@ -9,10 +9,15 @@ import ( const statusClientClosedRequest = 499 -func concurrencyErrorResponse(err error, slotType string) (int, string, string) { +const ( + gatewayQueueFullCode = "gateway_queue_full" + gatewayConcurrencyLimitCode = "gateway_concurrency_limit" +) + +func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) { var waitQueueFullErr *WaitQueueFullError if errors.As(err, &waitQueueFullErr) { - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later" } @@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string) if concurrencyErr.SlotType != "" { slotType = concurrencyErr.SlotType } - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType) } if errors.Is(err, context.Canceled) { - return statusClientClosedRequest, "api_error", "context canceled" + return statusClientClosedRequest, "api_error", "", "context canceled" } - return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later" + return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later" } diff --git a/backend/internal/handler/concurrency_error_response_test.go b/backend/internal/handler/concurrency_error_response_test.go index a2e6b9ab93..2d1b3d6ad2 100644 --- a/backend/internal/handler/concurrency_error_response_test.go +++ b/backend/internal/handler/concurrency_error_response_test.go @@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType string wantStatus int wantType string + wantCode string wantMessage string }{ { @@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType: "user", wantStatus: http.StatusTooManyRequests, wantType: "rate_limit_error", + wantCode: gatewayConcurrencyLimitCode, wantMessage: "Concurrency limit exceeded for account, please retry later", }, + { + name: "full local wait queue has gateway code", + err: &WaitQueueFullError{SlotType: "account"}, + slotType: "account", + wantStatus: http.StatusTooManyRequests, + wantType: "rate_limit_error", + wantCode: gatewayQueueFullCode, + wantMessage: "Too many pending requests, please retry later", + }, { name: "client cancellation is not classified as concurrency limit", err: context.Canceled, @@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType) + status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType) require.Equal(t, tt.wantStatus, status) require.Equal(t, tt.wantType, errType) + require.Equal(t, tt.wantCode, code) require.Equal(t, tt.wantMessage, message) }) } diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..9384d46643 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -395,7 +395,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -719,7 +719,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -1853,8 +1853,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su // handleConcurrencyError handles concurrency-related acquire errors. func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted) } func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) { @@ -1925,6 +1925,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) // handleStreamingAwareError handles errors that may occur after streaming has started func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { + h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted) +} + +func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) { if streamStarted { // 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。 // 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集, @@ -1935,7 +1939,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e // response.completed/failed/incomplete/cancelled 集合。 // Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -1943,7 +1947,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e flusher, ok := c.Writer.(http.Flusher) if ok { // SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。 - errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" + errorCode := "" + if code != "" { + errorCode = `,"code":` + strconv.Quote(code) + } + errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil { _ = c.Error(err) } @@ -1953,7 +1961,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e } // Normal case: return JSON response with proper status code - h.errorResponse(c, status, errType, message) + h.errorResponseWithCode(c, status, errType, code, message) } // ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。 @@ -2045,12 +2053,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { // errorResponse 返回Claude API格式的错误响应 func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { + h.errorResponseWithCode(c, status, errType, "", message) +} + +func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) { + errorObject := gin.H{"type": errType, "message": message} + if code != "" { + errorObject["code"] = code + } c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": message, - }, + "type": "error", + "error": errorObject, }) } diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 04a61cb16e..a170877315 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr // generic response.failed. service.MarkOpsStreamError(c, code, message, status) if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) { - writeResponsesFailedSSE(c, code, message) + writeResponsesFailedSSE(c, code, "", message) } return } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..771169016b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -2004,7 +2004,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil) } -type openAISlotErrorWriter func(status int, errType, message string) +type openAISlotErrorWriter func(status int, errType, code, message string) // acquireOpenAIAccountSlot centralizes scheduler selection admission. The // optional error writer lets non-Responses endpoints retain their wire format @@ -2020,13 +2020,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( writeError openAISlotErrorWriter, ) (func(), openAISlotAcquireResult) { if writeError == nil { - writeError = func(status int, errType, message string) { - h.handleStreamingAwareError(c, status, errType, message, *streamStarted) + writeError = func(status int, errType, code, message string) { + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false) } } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2056,7 +2056,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2067,8 +2067,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } if fastAcquired { @@ -2098,7 +2098,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later") + writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later") return nil, openAISlotAcquireFailed } @@ -2121,8 +2121,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } @@ -3172,14 +3172,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream if acquired { return release, true } - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false) return nil, false } // handleConcurrencyError handles concurrency-related acquire errors. func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false) } func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) { @@ -3359,7 +3359,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode( // 通用 `event: error` 帧不被识别为终止事件,会导致 // "stream closed before response.completed"。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -3526,7 +3526,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType // 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, errType, message, status) - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, "", message) { return } } @@ -3844,7 +3844,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe // 写 JSON(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden) - if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) { + if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) { h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key) return true } diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index 8ba6095cfb..f60e6c7834 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -56,7 +56,7 @@ type responsesFailedEvent struct { // 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误; // 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏, // 应当让请求处理函数 return,由上层关闭连接。 -func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { +func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool { flusher, ok := c.Writer.(http.Flusher) if !ok { return false @@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { Status: "failed", Output: []any{}, Error: responsesFailedError{ - Code: mapResponsesErrorCode(errType), + Code: mapResponsesErrorCode(errType, code), Message: message, }, }, @@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string { // mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。 // 无明确映射时原样返回,保证至少可读。 -func mapResponsesErrorCode(errType string) string { +func mapResponsesErrorCode(errType, code string) string { + if code != "" { + return code + } switch errType { case "rate_limit_error": return "rate_limit_exceeded" diff --git a/backend/internal/handler/stream_error_event_test.go b/backend/internal/handler/stream_error_event_test.go index 187deda19d..94089e9fae 100644 --- a/backend/internal/handler/stream_error_event_test.go +++ b/backend/internal/handler/stream_error_event_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // Regression for the production incident on 2026-05-24 around 9:13 CST: @@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"]) } +func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"]) +} + +func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true, false) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, "event: error\n")) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String()) +} + +func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false) + + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists()) +} + // 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。 func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) { c, w := newGinContextForEndpoint(t, EndpointResponses) @@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed( assert.Equal(t, "upstream gone", errObj["message"]) } +func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String()) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`)) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String()) +} + +func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, "/responses") + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayQueueFullCode, errObj["code"]) +} + // Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format // (Anthropic spec accepts a type:"error" stream event). func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) { @@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) { {"custom_thing", "custom_thing"}, } for _, tc := range cases { - assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in) + assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in) } + assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode)) } From 78fc7b171ec1a802a8d267d0dcc159539ddb0094 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 15:16:18 +0800 Subject: [PATCH 09/53] test: skip live token comparison on network errors --- .../service/openai_gateway_count_tokens_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_count_tokens_test.go b/backend/internal/service/openai_gateway_count_tokens_test.go index 6aa8825c9e..37bfa91e00 100644 --- a/backend/internal/service/openai_gateway_count_tokens_test.go +++ b/backend/internal/service/openai_gateway_count_tokens_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "os" @@ -350,9 +352,13 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request) if err != nil { - // Live-API comparison only; invalid/expired local keys should skip, not fail CI. - if strings.Contains(err.Error(), "status=401") || strings.Contains(err.Error(), "invalid_api_key") { - t.Skipf("OPENAI_API_KEY rejected by OpenAI: %v", err) + // This is an optional live-API comparison. Credential and transient + // network failures must not make the deterministic unit suite fail. + var netErr net.Error + if strings.Contains(err.Error(), "status=401") || + strings.Contains(err.Error(), "invalid_api_key") || + errors.As(err, &netErr) { + t.Skipf("OpenAI live comparison unavailable: %v", err) } require.NoError(t, err) } From 28dde982cb5526d873204a50289928357c10f64f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 17:29:04 +0800 Subject: [PATCH 10/53] fix(openai): normalize automation heartbeat bootstrap --- .../openai_automation_bootstrap_test.go | 43 +++++++++++++-- .../handler/openai_gateway_handler.go | 52 ++++++++++++++++++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 7bb7fd68b1..698a3bbd5e 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) { + output := `wiki` + got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, "")) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) tests := []struct { @@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { name: "real call context", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`), }, - { - name: "heartbeat output", - body: codexAutomationBootstrapBody(t, `wiki`, ""), - }, { name: "mismatched memory id", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""), @@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "arbitrary tool output", output: `wiki`}, + {name: "root attribute", output: `wiki`}, + {name: "namespaced root", output: `wiki`}, + {name: "extra child", output: `wikiok`}, + {name: "nested id content", output: `wiki`}, + {name: "padded id", output: ` wiki `}, + {name: "unsafe id", output: `../wiki`}, + {name: "comment", output: `wiki`}, + {name: "trailing content", output: `wikiextra`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := codexAutomationBootstrapBody(t, tt.output, "") + got, changed := normalizeCodexAutomationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..c895b90c53 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1756,7 +1756,7 @@ func isCodexAutomationCandidate(item map[string]any) bool { return false } output, ok := item["output"].(string) - return ok && validCodexAutomationBootstrap(output) + return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output)) } func stringField(item map[string]any, key string) string { @@ -1834,6 +1834,56 @@ func validCodexAutomationLastRun(value string) bool { return err == nil && runAt.UnixMilli() == epochMillis } +func validCodexAutomationHeartbeat(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, automationIDSeen bool + var automationID bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + id := automationID.String() + return rootSeen && automationIDSeen && depth == 0 && + strings.TrimSpace(id) == id && validCodexAutomationID(id) + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 { + return false + } + if depth == 1 { + if rootSeen || current.Name.Local != "heartbeat" { + return false + } + rootSeen = true + } else if automationIDSeen || current.Name.Local != "automation_id" { + return false + } + automationIDSeen = depth == 2 + case xml.EndElement: + if current.Name.Space != "" { + return false + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = automationID.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment, xml.ProcInst, xml.Directive: + return false + } + } +} + func validCodexDelegationEnvelope(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, sourceSeen, inputSeen bool From 3e60c3b86b4849bdd283f92cb88b5a7c1a79d456 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Wed, 2 Sep 2026 18:52:05 +0900 Subject: [PATCH 11/53] fix: add compact admin account list DTO --- .../internal/handler/admin/account_handler.go | 81 +++++++++++++--- .../admin/account_handler_list_test.go | 92 ++++++++++++++++++ backend/internal/handler/dto/mappers.go | 41 ++++++++ backend/internal/handler/dto/types.go | 96 +++++++++++++++++++ 4 files changed, 297 insertions(+), 13 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 599a04a34e..c3978fb76d 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -199,6 +199,19 @@ type AccountWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +// AccountListItemWithConcurrency is the compact account-list envelope used +// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account, +// so groups/account_groups never appear in the list payload. +type AccountListItemWithConcurrency struct { + *dto.AccountListItem + CurrentConcurrency int `json:"current_concurrency"` + SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` + SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` + CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` + ActiveSessions *int `json:"active_sessions,omitempty"` + CurrentRPM *int `json:"current_rpm,omitempty"` +} + type AccountSchedulerScore struct { BaseScore float64 `json:"base_score"` StickyScore float64 `json:"sticky_score"` @@ -223,6 +236,17 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d return out } +func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account { + out := dto.AccountFromServiceShallow(account) + if out != nil && account != nil { + out.Proxy = dto.ProxyFromService(account.Proxy) + } + if h != nil && h.ollamaCloudUsage != nil && out != nil { + h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage) + } + return out +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: h.accountResponseFromService(account), @@ -650,8 +674,12 @@ func (h *AccountHandler) List(c *gin.Context) { result := make([]AccountWithConcurrency, len(accounts)) for i := range accounts { acc := &accounts[i] + accountResponse := h.accountResponseFromService(acc) + if lite { + accountResponse = h.accountListResponseFromService(acc) + } item := AccountWithConcurrency{ - Account: h.accountResponseFromService(acc), + Account: accountResponse, CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], @@ -683,7 +711,34 @@ func (h *AccountHandler) List(c *gin.Context) { h.enrichShadowParents(c.Request.Context(), result) - etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite) + if lite { + compact := make([]AccountListItemWithConcurrency, len(result)) + for i := range result { + item := result[i] + compact[i] = AccountListItemWithConcurrency{ + AccountListItem: dto.AccountListItemFromAccount(item.Account), + CurrentConcurrency: item.CurrentConcurrency, + SchedulerScore: item.SchedulerScore, + SchedulerScores: item.SchedulerScores, + CurrentWindowCost: item.CurrentWindowCost, + ActiveSessions: item.ActiveSessions, + CurrentRPM: item.CurrentRPM, + } + } + etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true) + if etag != "" { + c.Header("ETag", etag) + c.Header("Vary", "If-None-Match") + if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } + } + response.Paginated(c, compact, total, page, pageSize) + return + } + + etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false) if etag != "" { c.Header("ETag", etag) c.Header("Vary", "If-None-Match") @@ -696,23 +751,23 @@ func (h *AccountHandler) List(c *gin.Context) { response.Paginated(c, result, total, page, pageSize) } -func buildAccountsListETag( - items []AccountWithConcurrency, +func buildAccountsListETag[T any]( + items []T, total int64, page, pageSize int, platform, accountType, status, search string, lite bool, ) string { payload := struct { - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Platform string `json:"platform"` - AccountType string `json:"type"` - Status string `json:"status"` - Search string `json:"search"` - Lite bool `json:"lite"` - Items []AccountWithConcurrency `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Platform string `json:"platform"` + AccountType string `json:"type"` + Status string `json:"status"` + Search string `json:"search"` + Lite bool `json:"lite"` + Items []T `json:"items"` }{ Total: total, Page: page, diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 6a10bb4dcc..d92a2e1328 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -1,9 +1,12 @@ package admin import ( + "bytes" + "compress/gzip" "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -13,6 +16,95 @@ import ( "github.com/stretchr/testify/require" ) +func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(77) + adminSvc.accounts = []service.Account{{ + ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)}, + Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive, + Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}, + AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}}, + CreatedAt: now, UpdatedAt: now, + }} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.NotEmpty(t, rec.Header().Get("ETag")) + + var litePayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload)) + require.Len(t, litePayload.Data.Items, 1) + liteItem := litePayload.Data.Items[0] + require.Equal(t, float64(501), liteItem["id"]) + require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"]) + require.Equal(t, true, liteItem["schedulable"]) + require.NotContains(t, liteItem, "groups") + require.NotContains(t, liteItem, "account_groups") + require.NotContains(t, liteItem, "access_token") + + // The ETag must represent the same compact body and return 304 on refresh. + rec304 := httptest.NewRecorder() + req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + req304.Header.Set("If-None-Match", rec.Header().Get("ETag")) + router.ServeHTTP(rec304, req304) + require.Equal(t, http.StatusNotModified, rec304.Code) + + // Omitting lite preserves the legacy full response shape. + recFull := httptest.NewRecorder() + reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil) + router.ServeHTTP(recFull, reqFull) + require.Equal(t, http.StatusOK, recFull.Code) + var fullPayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload)) + require.Contains(t, fullPayload.Data.Items[0], "groups") + require.Contains(t, fullPayload.Data.Items[0], "account_groups") +} + +func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + accounts := make([]service.Account, 20) + for i := range accounts { + id := int64(600 + i) + groupID := int64(800 + i) + accounts[i] = service.Account{ + ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true, + Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}, + AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}}, + CreatedAt: now, UpdatedAt: now, + } + } + adminSvc.accounts = accounts + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Less(t, rec.Body.Len(), 80*1024) + + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + _, err := zw.Write(rec.Body.Bytes()) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.Less(t, compressed.Len(), 15*1024) +} + func setupAccountListRouter() (*gin.Engine, *stubAdminService) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..46ccc1d666 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -447,6 +447,47 @@ func AccountFromService(a *service.Account) *Account { return out } +// AccountListItemFromAccount projects a full account response into the +// compact shape used by the paginated admin account list. Keeping this +// projection separate from Account preserves the existing detail API. +func AccountListItemFromAccount(a *Account) *AccountListItem { + if a == nil { + return nil + } + return &AccountListItem{ + ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, + Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, + OllamaCloudUsage: a.OllamaCloudUsage, + ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, + Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, + AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, + Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt, + OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil, + TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart, + SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus, + WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve, + MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM, + RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode, + EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID, + EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled, + CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled, + CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed, + QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit, + QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode, + QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode, + QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour, + QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt, + QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled, + QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled, + QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled, + QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID, + QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType, + ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt, + ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs, + } +} + func timeToUnixSeconds(value *time.Time) *int64 { if value == nil { return nil diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..34ab54e9de 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -322,6 +322,102 @@ type Account struct { Groups []*Group `json:"groups,omitempty"` } +// AccountListItem is the compact representation returned by the admin account +// list when the lite query parameter is enabled. It contains the fields used +// by the account table and runtime indicators, but intentionally omits the +// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id +// for the complete Account DTO when editing or inspecting an account. +type AccountListItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + + Credentials map[string]any `json:"credentials,omitempty"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra,omitempty"` + OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + + ProxyID *int64 `json:"proxy_id"` + ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` + ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Schedulable bool `json:"schedulable"` + + RateLimitedAt *time.Time `json:"rate_limited_at"` + RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` + OverloadUntil *time.Time `json:"overload_until"` + + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"` + TempUnschedulableReason string `json:"temp_unschedulable_reason"` + + SessionWindowStart *time.Time `json:"session_window_start"` + SessionWindowEnd *time.Time `json:"session_window_end"` + SessionWindowStatus string `json:"session_window_status"` + + WindowCostLimit *float64 `json:"window_cost_limit,omitempty"` + WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"` + MaxSessions *int `json:"max_sessions,omitempty"` + SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"` + BaseRPM *int `json:"base_rpm,omitempty"` + RPMStrategy *string `json:"rpm_strategy,omitempty"` + RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"` + UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"` + EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"` + TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"` + EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"` + CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"` + CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` + CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"` + CustomBaseURL *string `json:"custom_base_url,omitempty"` + + QuotaLimit *float64 `json:"quota_limit,omitempty"` + QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"` + QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"` + QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` + QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"` + + QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"` + QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"` + QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"` + QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"` + QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"` + QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"` + QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"` + QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"` + + QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"` + QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"` + QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"` + QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"` + QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"` + QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"` + + ParentAccountID *int64 `json:"parent_account_id,omitempty"` + QuotaDimension string `json:"quota_dimension,omitempty"` + ParentEmail string `json:"parent_email,omitempty"` + ParentPlanType string `json:"parent_plan_type,omitempty"` + ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"` + ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"` + ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"` + + Proxy *Proxy `json:"proxy,omitempty"` + GroupIDs []int64 `json:"group_ids,omitempty"` +} + type AccountGroup struct { AccountID int64 `json:"account_id"` GroupID int64 `json:"group_id"` From 994192ef4f22baf546c032f557bfb6d14b1dcaf4 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Wed, 2 Sep 2026 18:53:12 +0900 Subject: [PATCH 12/53] fix: type admin account list responses --- frontend/src/api/admin/accounts.ts | 9 +++++---- frontend/src/types/index.ts | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8de2ed4996..087cfaec41 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -6,6 +6,7 @@ import { apiClient } from '../client' import type { Account, + AccountListItem, CreateAccountRequest, UpdateAccountRequest, PaginatedResponse, @@ -53,8 +54,8 @@ export async function list( options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/accounts', { +): Promise> { + const { data } = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -68,7 +69,7 @@ export async function list( export interface AccountListWithEtagResult { notModified: boolean etag: string | null - data: PaginatedResponse | null + data: PaginatedResponse | null } export interface AccountUpstreamBillingRatesWithEtagResult { @@ -135,7 +136,7 @@ export async function listWithEtag( headers['If-None-Match'] = options.etag } - const response = await apiClient.get>('/admin/accounts', { + const response = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..b0435fc3a8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1277,6 +1277,10 @@ export interface Account { parent_chatgpt_account_id?: string } +// The admin account list may return this compact shape when lite=1. Detail +// operations still use Account from /admin/accounts/:id. +export type AccountListItem = Omit + export interface AccountSchedulerGroupScore { group_id?: number | null group_name?: string From db15e0090aace48e62f952c6a294f38048cf6288 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Wed, 2 Sep 2026 19:40:09 +0900 Subject: [PATCH 13/53] fix: keep account table on compact list responses --- frontend/src/views/admin/AccountsView.vue | 61 +++-- .../admin/__tests__/AccountsView.lite.spec.ts | 250 ++++++++++++++++++ 2 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index f93300dc6d..d156b389e9 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -304,7 +304,7 @@ />