From 57c76584a6c15a26b3f6a54ca0ed5e7aa77ea97d Mon Sep 17 00:00:00 2001 From: Long Li Date: Sun, 30 Aug 2026 11:40:39 +0900 Subject: [PATCH 01/56] fix(codex): advertise priority tier for fast models --- .../service/openai_codex_models_service.go | 29 +++++++++- .../openai_codex_models_service_test.go | 56 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b55549e753..3f164af38a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -309,6 +309,12 @@ type configuredCodexTruncationPolicy struct { Limit int64 `json:"limit"` } +type configuredCodexServiceTier struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + type configuredCodexModelMessages struct { InstructionsTemplate string `json:"instructions_template"` InstructionsVariables any `json:"instructions_variables"` @@ -336,7 +342,7 @@ type configuredCodexModelDescriptor struct { SupportedInAPI bool `json:"supported_in_api"` Priority int `json:"priority"` AdditionalSpeedTiers []string `json:"additional_speed_tiers"` - ServiceTiers []any `json:"service_tiers"` + ServiceTiers []configuredCodexServiceTier `json:"service_tiers"` DefaultServiceTier any `json:"default_service_tier"` AvailabilityNUX any `json:"availability_nux"` Upgrade any `json:"upgrade"` @@ -392,7 +398,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr SupportedInAPI: true, Priority: configuredCodexModelPriority, AdditionalSpeedTiers: []string{}, - ServiceTiers: []any{}, + ServiceTiers: []configuredCodexServiceTier{}, ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)}, SupportsReasoningSummaryParameter: true, DefaultReasoningSummary: "auto", @@ -448,6 +454,15 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.DisplayName = openaiCodexDisplayName(modelID) descriptor.Description = "OpenAI GPT coding model routed through Sub2API." descriptor.SupportsParallelToolCalls = true + if configuredCodexSupportsPriorityServiceTier(modelID) { + descriptor.ServiceTiers = []configuredCodexServiceTier{ + { + ID: "priority", + Name: "Fast", + Description: "Priority processing for lower latency.", + }, + } + } if isOpenAICodexReasoningGPTModel(modelID) { defaultReasoningLevel := "medium" if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { @@ -471,6 +486,16 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr return descriptor } +func configuredCodexSupportsPriorityServiceTier(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + for _, family := range []string{"gpt-5.4", "gpt-5.5", "gpt-5.6"} { + if normalized == family || strings.HasPrefix(normalized, family+"-") { + return true + } + } + return false +} + func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { levels := []configuredCodexReasoningLevel{ {Effort: "low", Description: "Fast responses with lighter reasoning"}, diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 77a65cfb38..5c98825a52 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -181,7 +181,7 @@ func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slu require.Equal(t, true, model["supported_in_api"]) require.NotNil(t, model["priority"]) require.Equal(t, []any{}, model["additional_speed_tiers"]) - require.Equal(t, []any{}, model["service_tiers"]) + require.IsType(t, []any{}, model["service_tiers"]) require.Contains(t, model, "default_service_tier") require.Contains(t, model, "availability_nux") require.Contains(t, model, "upgrade") @@ -403,6 +403,51 @@ func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) { require.NotEqual(t, "none", firstLevel["effort"]) } +// Scenario: 支持 Fast 的 GPT 型号在目录中声明 priority service tier。 +func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.6-sol", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 3) + + for _, model := range models { + require.Equal(t, []any{ + map[string]any{ + "id": "priority", + "name": "Fast", + "description": "Priority processing for lower latency.", + }, + }, model["service_tiers"]) + require.Nil(t, model["default_service_tier"]) + } +} + +// Scenario: 未明确支持 Fast 的型号不推测 service tier。 +func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "gpt-4o", + "claude-opus-4-6", + "deepseek-v4-pro", + "company-coding-model", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 4) + + for _, model := range models { + require.Equal(t, []any{}, model["service_tiers"]) + require.Nil(t, model["default_service_tier"]) + } +} + // Scenario: 专用图片生成模型不进入 Codex 主模型目录。 func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) { t.Parallel() @@ -1745,7 +1790,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * svc := &OpenAIGatewayService{} manifest := &CodexModelsManifest{ - Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), + Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), } account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") @@ -1755,6 +1800,13 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6") require.Equal(t, "Provider supplied", models[0]["description"]) require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + require.Equal(t, []any{ + map[string]any{ + "id": "provider-priority", + "name": "Provider Fast", + "description": "Provider supplied tier.", + }, + }, models[0]["service_tiers"]) require.Equal(t, []any{"text"}, models[0]["input_modalities"]) require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) modelMessages, ok := models[0]["model_messages"].(map[string]any) From 9eabd2a5b7b1f25e8bfaccd8716392819ddbf416 Mon Sep 17 00:00:00 2001 From: qiujun Date: Sun, 30 Aug 2026 22:56:28 +0800 Subject: [PATCH 02/56] fix(billing): apply model pricing policies to account stats cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveAccountStatsCost 的优先级 3 维护了第二份"单价 × token 数"实现,每加一个 定价特性都要手工镜像:service tier 与长上下文分别由 9261dd77、25e6688a 补齐, 而 b5827cfd 引入的 DeepSeek 官方峰谷一直没有生效,高峰时段账号成本最多低估一半。 改为复用 CalculateCostWithServiceTier。channelPricing 仍为 nil,优先级 3 的语义 不变:只取模型定价文件、不引入渠道自定义定价;低谷时段结果与原算式完全一致。 Co-Authored-By: Claude Opus 5 --- .../internal/service/account_stats_pricing.go | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 8d5bc144fc..af82bc15d6 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -62,29 +62,17 @@ func resolveAccountStatsCost( } // tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。 +// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, +// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 +// 语义:只取模型定价文件,不引入渠道自定义定价。 func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 { - pricing, err := billingService.GetModelPricing(model) - if err != nil || pricing == nil { + breakdown, err := billingService.CalculateCostWithServiceTier( + model, tokens, 1, normalizeBillingServiceTier(serviceTier), + ) + if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } - normalizedTier := normalizeBillingServiceTier(serviceTier) - if normalizedTier == "priority" || normalizedTier == "fast" || normalizedTier == "flex" || - billingService.shouldApplySessionLongContextPricing(tokens, pricing) { - breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier) - if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { - return nil - } - return &breakdown.TotalCost - } - cost := float64(tokens.InputTokens)*pricing.InputPricePerToken + - float64(tokens.OutputTokens)*pricing.OutputPricePerToken + - float64(tokens.CacheCreationTokens)*pricing.CacheCreationPricePerToken + - float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken + - float64(tokens.ImageOutputTokens)*pricing.ImageOutputPricePerToken - if cost <= 0 { - return nil - } - return &cost + return &breakdown.TotalCost } // tryCustomRules 遍历自定义规则,按数组顺序先命中为准。 From e7c029875c2cbc7bb00b748651a5df9768e9bd24 Mon Sep 17 00:00:00 2001 From: qiujun Date: Mon, 31 Aug 2026 12:33:06 +0800 Subject: [PATCH 03/56] =?UTF-8?q?test(billing):=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=8C=89=20output=20=E5=AD=90=E9=9B=86?= =?UTF-8?q?=E8=AE=A1=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一条改用统一定价管线后,TestTryModelFilePricing_WithImageOutput 挂在 0.3 vs 0.28:原算式把 ImageOutputTokens 当 OutputTokens 之外的额外量叠加,等于按文本 output 价和图片价各收一次,测试固化了这个重复计费。 ImageOutputTokens 本就是 OutputTokens 的子集(Gemini 取自 candidatesTokensDetails 的 IMAGE 档,OpenAI 取自 output_tokens_details),统一管线先扣除再按图片单价计, 未配图片价时回退到 output 价、总额不变,正是这个语义。期望改为 0.28。 影响:涉及图片输出的模型,账号统计成本会降低"图片 token × 文本 output 单价"。 Co-Authored-By: Claude Opus 5 --- backend/internal/service/account_stats_pricing_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 1bd28896fc..84ca0897ec 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -594,8 +594,9 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { } result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") require.NotNil(t, result) - // 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3 - require.InDelta(t, 0.3, *result, 1e-12) + // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 + // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 + require.InDelta(t, 0.28, *result, 1e-12) } func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { From 1be69e56aa6ebe061016f9fa63aaaea5c7eaa9e5 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 31 Aug 2026 12:57:06 +0800 Subject: [PATCH 04/56] fix(openai): accept delegation bootstrap without call id --- .../openai_delegation_bootstrap_test.go | 176 +++++++++++++ .../handler/openai_gateway_handler.go | 232 ++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 backend/internal/handler/openai_delegation_bootstrap_test.go diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go new file mode 100644 index 0000000000..9e30752aa6 --- /dev/null +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -0,0 +1,176 @@ +package handler + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const delegationEnvelope = `thread-1do the work` + +func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) { + tests := []struct { + name string + namespace string + tool string + callID string + }{ + {name: "app create missing call id", namespace: "codex_app", tool: "create_thread"}, + {name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`}, + {name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`}, + {name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`) + 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, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { + tests := []struct { + name string + item string + extra string + }{ + {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"}`}, + {name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`}, + {name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { + tests := []struct { + name string + value string + changed bool + }{ + {name: "absent", changed: true}, + {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "null", value: `,"previous_response_id":null`}, + {name: "number", value: `,"previous_response_id":42`}, + {name: "boolean", value: `,"previous_response_id":false`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.Equal(t, tt.changed, changed) + if !tt.changed { + require.Equal(t, body, got) + } + }) + } +} + +func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "top-level previous response id", + body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate discriminator", + body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate call id", + body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate output", + body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) { + body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw) +} + +func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) { + malformed := []string{ + `thread-1`, + `work`, + `thread-1 `, + `prefixthread-1work`, + `thread-1worksuffix`, + `thread-1workx`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + } + for _, envelope := range malformed { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed, envelope) + require.Equal(t, body, got) + } +} + +func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String()) + require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String()) + require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String()) + + again, changedAgain := normalizeCodexDelegationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + +func mustJSON(t *testing.T, value string) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e57cc759e6..40763b1976 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1,10 +1,13 @@ package handler import ( + "bytes" "context" "encoding/json" + "encoding/xml" "errors" "fmt" + "io" "net/http" "runtime/debug" "strconv" @@ -435,6 +438,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { body = cappedBody } + if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed { + body = normalizedBody + reqLog.Info("openai.codex_delegation_bootstrap_normalized", + zap.String("normalization", "call_output_to_user_message"), + ) + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { @@ -1581,6 +1590,229 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, return false } +func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { + if !hasUniqueJSONMembers(body) { + return body, false + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var request map[string]any + if err := decoder.Decode(&request); err != nil { + return body, false + } + if previousResponseID, exists := request["previous_response_id"]; exists { + value, ok := previousResponseID.(string) + if !ok || strings.TrimSpace(value) != "" { + return body, false + } + } + input, ok := request["input"].([]any) + if !ok { + 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. + 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) { + callIDValue, exists := item["call_id"] + callID, isString := callIDValue.(string) + if exists && (!isString || strings.TrimSpace(callID) != "") { + return body, false + } + if !isCodexDelegationCandidate(item) { + return body, false + } + } + } + + changed := false + for i, raw := range input { + item, ok := raw.(map[string]any) + if !ok || !isCodexDelegationCandidate(item) { + continue + } + output, ok := item["output"].(string) + if !ok || !validCodexDelegationEnvelope(output) { + continue + } + input[i] = map[string]any{ + "type": "message", + "role": "user", + "content": []any{map[string]any{ + "type": "input_text", + "text": output, + }}, + } + changed = true + } + if !changed { + return body, false + } + normalized, err := json.Marshal(request) + if err != nil { + return body, false + } + return normalized, true +} + +func hasUniqueJSONMembers(body []byte) bool { + decoder := json.NewDecoder(bytes.NewReader(body)) + if !consumeUniqueJSONValue(decoder) { + return false + } + _, err := decoder.Token() + return err == io.EOF +} + +func consumeUniqueJSONValue(decoder *json.Decoder) bool { + token, err := decoder.Token() + if err != nil { + return false + } + delim, ok := token.(json.Delim) + if !ok { + return true + } + + switch delim { + case '{': + members := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return false + } + key, ok := keyToken.(string) + if !ok { + return false + } + if _, duplicate := members[key]; duplicate { + return false + } + members[key] = struct{}{} + if !consumeUniqueJSONValue(decoder) { + return false + } + } + end, err := decoder.Token() + return err == nil && end == json.Delim('}') + case '[': + for decoder.More() { + if !consumeUniqueJSONValue(decoder) { + return false + } + } + end, err := decoder.Token() + return err == nil && end == json.Delim(']') + default: + return false + } +} + +func isResponsesCallOutputType(typ string) bool { + return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output" +} + +func isCodexDelegationCandidate(item map[string]any) bool { + if stringField(item, "type") != "function_call_output" || + !isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) { + return false + } + output, ok := item["output"].(string) + return ok && validCodexDelegationEnvelope(output) +} + +func stringField(item map[string]any, key string) string { + value, _ := item[key].(string) + return value +} + +func isCodexDelegationTool(namespace, name string) bool { + return (namespace == "codex_app" || namespace == "codex_tui") && + (name == "create_thread" || name == "send_message_to_thread") +} + +func validCodexDelegationEnvelope(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, sourceSeen, inputSeen bool + var childName string + var childText bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + return rootSeen && depth == 0 && sourceSeen && inputSeen + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 { + return false + } + if depth == 1 { + if rootSeen { + return false + } + rootSeen = true + continue + } + if current.Name.Local != "source_thread_id" && current.Name.Local != "input" { + return false + } + childName = current.Name.Local + childText.Reset() + case xml.EndElement: + if current.Name.Space != "" { + return false + } + if depth == 2 { + if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" { + return false + } + if childName == "source_thread_id" { + if sourceSeen { + return false + } + sourceSeen = true + } else { + if inputSeen { + return false + } + inputSeen = true + } + childName = "" + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = childText.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment: + return false + case xml.ProcInst, xml.Directive: + return false + } + } +} + func (h *OpenAIGatewayHandler) acquireResponsesUserSlot( c *gin.Context, userID int64, From 863667ce6743884176bae8635a02da9182f7be86 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Mon, 31 Aug 2026 16:25:13 +0800 Subject: [PATCH 05/56] fix: retry transient database startup errors --- backend/internal/repository/ent.go | 73 ++++++++++- .../repository/ent_init_retry_test.go | 122 ++++++++++++++++++ deploy/DOCKER.md | 14 ++ deploy/README.md | 22 ++++ deploy/docker-compose.yml | 10 +- 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 backend/internal/repository/ent_init_retry_test.go diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index 3abb528e98..6094b9ec61 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -5,7 +5,10 @@ package repository import ( "context" "database/sql" + "errors" "fmt" + "log/slog" + "strings" "time" "github.com/Wei-Shaw/sub2api/ent" @@ -18,6 +21,72 @@ import ( "github.com/lib/pq" ) +const ( + maxDatabaseInitializationAttempts = 8 + databaseInitializationRetryBase = time.Second + databaseInitializationRetryMax = 30 * time.Second +) + +// initializeDatabaseWithRetry retries only errors that indicate PostgreSQL is +// temporarily unavailable during startup. Permanent configuration, migration, +// and data errors are returned immediately so they remain visible to operators. +func initializeDatabaseWithRetry(ctx context.Context, initialize func(context.Context) error) error { + return initializeDatabaseWithRetryWithWait(ctx, initialize, waitForDatabaseInitializationRetry) +} + +func initializeDatabaseWithRetryWithWait( + ctx context.Context, + initialize func(context.Context) error, + wait func(context.Context, time.Duration) error, +) error { + var lastErr error + for attempt := 1; attempt <= maxDatabaseInitializationAttempts; attempt++ { + if err := initialize(ctx); err == nil { + return nil + } else { + lastErr = err + if !isTransientDatabaseInitializationError(err) || attempt == maxDatabaseInitializationAttempts { + return err + } + } + + delay := databaseInitializationRetryBase * time.Duration(1<<(attempt-1)) + if delay > databaseInitializationRetryMax { + delay = databaseInitializationRetryMax + } + slog.Warn("database initialization temporarily unavailable; retrying", + "attempt", attempt, + "max_attempts", maxDatabaseInitializationAttempts, + "retry_in", delay, + "error", lastErr, + ) + if err := wait(ctx, delay); err != nil { + return err + } + } + return lastErr +} + +func waitForDatabaseInitializationRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func isTransientDatabaseInitializationError(err error) bool { + var pqErr *pq.Error + if !errors.As(err, &pqErr) { + return false + } + code := string(pqErr.Code) + return code == "57P03" || strings.HasPrefix(code, "08") +} + // InitEnt 初始化 Ent ORM 客户端并返回客户端实例和底层的 *sql.DB。 // // 该函数执行以下操作: @@ -69,7 +138,9 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { // 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。 migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() - if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil { + if err := initializeDatabaseWithRetry(migrationCtx, func(ctx context.Context) error { + return applyMigrationsFS(ctx, drv.DB(), migrations.FS) + }); err != nil { _ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露 return nil, nil, err } diff --git a/backend/internal/repository/ent_init_retry_test.go b/backend/internal/repository/ent_init_retry_test.go new file mode 100644 index 0000000000..e0c6d4d116 --- /dev/null +++ b/backend/internal/repository/ent_init_retry_test.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func transientDatabaseError(code string) error { + return &pq.Error{Code: pq.ErrorCode(code), Message: "database is temporarily unavailable"} +} + +func TestIsTransientDatabaseInitializationError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "postgres is starting", err: transientDatabaseError("57P03"), want: true}, + {name: "connection failure", err: fmt.Errorf("wrapped: %w", transientDatabaseError("08006")), want: true}, + {name: "authentication failure", err: transientDatabaseError("28P01"), want: false}, + {name: "migration error", err: errors.New("migration checksum mismatch"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isTransientDatabaseInitializationError(tt.err)) + }) + } +} + +func TestInitializeDatabaseWithRetryEventuallySucceeds(t *testing.T) { + attempts := 0 + var delays []time.Duration + err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error { + attempts++ + if attempts <= 3 { + return transientDatabaseError("57P03") + } + return nil + }, func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + return nil + }) + + require.NoError(t, err) + require.Equal(t, 4, attempts) + require.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, delays) +} + +func TestInitializeDatabaseWithRetryFailsFastForPermanentError(t *testing.T) { + attempts := 0 + waitCalled := false + permanentErr := transientDatabaseError("28P01") + err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error { + attempts++ + return permanentErr + }, func(_ context.Context, _ time.Duration) error { + waitCalled = true + return nil + }) + + require.ErrorIs(t, err, permanentErr) + require.Equal(t, 1, attempts) + require.False(t, waitCalled) +} + +func TestInitializeDatabaseWithRetryStopsWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + attempts := 0 + err := initializeDatabaseWithRetryWithWait(ctx, func(context.Context) error { + attempts++ + return transientDatabaseError("57P03") + }, func(ctx context.Context, _ time.Duration) error { + cancel() + return ctx.Err() + }) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, attempts) +} + +func TestInitializeDatabaseWithRetryReturnsLastErrorAfterLimit(t *testing.T) { + attempts := 0 + lastErr := transientDatabaseError("08006") + var delays []time.Duration + err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error { + attempts++ + return lastErr + }, func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + return nil + }) + + require.ErrorIs(t, err, lastErr) + require.Equal(t, maxDatabaseInitializationAttempts, attempts) + require.Len(t, delays, maxDatabaseInitializationAttempts-1) + require.Equal(t, databaseInitializationRetryMax, delays[len(delays)-1]) +} + +func TestInitializeDatabaseWithRetryAllowsIdempotentMigrationRetry(t *testing.T) { + applied := make(map[string]bool) + attempts := 0 + err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error { + attempts++ + if !applied["001_init.sql"] { + applied["001_init.sql"] = true + return transientDatabaseError("57P03") + } + return nil + }, func(_ context.Context, _ time.Duration) error { return nil }) + + require.NoError(t, err) + require.Equal(t, 2, attempts) + require.True(t, applied["001_init.sql"]) +} diff --git a/deploy/DOCKER.md b/deploy/DOCKER.md index 156b6c97a9..87d1f83a22 100644 --- a/deploy/DOCKER.md +++ b/deploy/DOCKER.md @@ -49,6 +49,20 @@ volumes: redis_data: ``` +## Startup and Database Recovery + +Sub2API runs database migrations while starting. PostgreSQL may still be +recovering briefly after a host or Docker daemon restart. The application +retries transient PostgreSQL startup and connection errors with bounded +exponential backoff, then continues startup when the database is ready. +Permanent errors such as invalid credentials, migration checksum mismatches, +SQL errors, and incompatible data fail immediately. + +The Compose deployment also checks PostgreSQL readiness with both `pg_isready` +and a simple SQL query. `depends_on: condition: service_healthy` helps order a +fresh Compose start, but application-level retries are still required when +Docker restores existing containers after a host restart. + ## Environment Variables | Variable | Description | Required | Default | diff --git a/deploy/README.md b/deploy/README.md index e87f33fa25..b4f8d094f4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -146,6 +146,28 @@ When using Docker Compose with `AUTO_SETUP=true`: docker compose logs sub2api | grep "admin password" ``` +### Startup and Database Recovery + +Sub2API applies database migrations during application startup. PostgreSQL can +remain in its recovery/startup phase briefly after a host or Docker daemon +restart. The application retries transient PostgreSQL startup and connection +errors with bounded exponential backoff, then starts automatically when the +database becomes ready. Authentication errors, migration checksum mismatches, +SQL errors, and other permanent configuration or data errors fail immediately. + +The Compose example also uses a PostgreSQL health check that verifies both +server readiness and a simple SQL query. `depends_on: condition: service_healthy` +controls dependency ordering for a fresh Compose start, but it is not a +replacement for application-level retries when Docker restores existing +containers after a host restart. + +For systemd deployments, keep `Restart=always` and `RestartSec` configured in +`sub2api.service`; the application retry covers transient database startup, +while systemd remains the supervisor for permanent process exits. For +Kubernetes, use a PostgreSQL readiness probe and retain the Sub2API startup +retry behavior; configure the application liveness probe separately so a +database recovery period is not treated as a permanent process failure. + ### Database Migration Notes (PostgreSQL) - Migrations are applied in lexicographic order (e.g. `001_...sql`, `002_...sql`). diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f15b40590d..2f2917cfe4 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -221,7 +221,9 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 30s + # The application also retries transient database startup errors. This + # grace period keeps Compose from declaring it unhealthy during bootstrap. + start_period: 120s # =========================================================================== # PostgreSQL Database @@ -260,12 +262,12 @@ services: test: [ "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}", + "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api} && psql -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api} -Atqc 'SELECT 1' >/dev/null", ] interval: 10s timeout: 5s - retries: 5 - start_period: 10s + retries: 18 + start_period: 60s # 注意:不暴露端口到宿主机,应用通过内部网络连接 # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] From 1dc0a09009ecfa412763e4ab38673a03692a68f3 Mon Sep 17 00:00:00 2001 From: li Date: Mon, 31 Aug 2026 16:53:57 +0800 Subject: [PATCH 06/56] =?UTF-8?q?fix(openai):=20ctx=5Fpool=20WS=20ingress?= =?UTF-8?q?=20=E4=B8=8B=E5=8F=91=E5=89=8D=E6=94=B9=E5=86=99=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=AE=B9=E9=87=8F=E9=99=8D=E8=BD=BD=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游容量降载(server_is_overloaded / slow_down)被 ctx_pool 的 ingress 直写路径 原样转发给客户端。Codex CLI 按闭集判定,这两个码属致命集,客户端打印 "Selected model is at capacity" 并直接终止会话,不退避重试。 仓库已有 sanitizeOpenAICapacityShedErrorCodeForClient 处理这件事,且注释写明了 Codex 的闭集判定与"账号状态判定使用改写前原始 payload"的前提。它此前只在 HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge (openai_ws_http_bridge.go)两条路径被调用,ctx_pool ingress 直写路径是唯一漏掉 的一条——同一账号同一时刻切到 http_bridge 就能正常退避重试。 改写结果写入独立的 clientMessage,不动 upstreamMessage:写出点之后的 markOpenAIWSClientVisibleFailure 与 handleOpenAIWSTerminalTransientFailure 仍按原始 payload 判定账号状态,与 http_bridge 的写法一致。 新增用例覆盖改写生效与改写范围两侧(非容量类错误码必须原样下发)。 --- .../service/openai_ws_forwarder_ingress.go | 19 +- .../openai_ws_ingress_capacity_shed_test.go | 184 ++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_ws_ingress_capacity_shed_test.go diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5f5976c7d0..b6af8fc06c 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -1125,7 +1125,24 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } replayCollector.AddEvent(eventType, upstreamMessage) - if err := writeClientMessage(upstreamMessage); err != nil { + // 客户端写出副本改写容量降载码:Codex 对 error/response.failed 中的 + // server_is_overloaded / slow_down 判致命并终止会话,改写后走客户端 + // 内置退避重试。HTTP/SSE(openai_gateway_response_handling.go)与 + // http_bridge(openai_ws_http_bridge.go)两条路径早已这么做, + // ctx_pool 的 ingress 直写路径是唯一漏掉的一条 —— 同一个上游降载 + // 事件在这里会让会话就地终止,切到 http_bridge 却能正常退避重试。 + // + // 必须写进独立变量而不是原地改 upstreamMessage:下面的 + // markOpenAIWSClientVisibleFailure 与 handleOpenAIWSTerminalTransientFailure + // 仍要按未改写的原始 payload 判定账号状态,这正是 + // sanitizeOpenAICapacityShedErrorCodeForClient 注释里写明的前提。 + clientMessage := upstreamMessage + if eventType == "error" || eventType == "response.failed" { + if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(clientMessage); changed { + clientMessage = rewritten + } + } + if err := writeClientMessage(clientMessage); err != nil { if isOpenAIWSClientDisconnectError(err) { clientDisconnected = true closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) diff --git a/backend/internal/service/openai_ws_ingress_capacity_shed_test.go b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go new file mode 100644 index 0000000000..d37b7f2cda --- /dev/null +++ b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// openAIWSIngressCapacityShedRepo 补齐 SetError,避免非容量类错误(如 +// workspace_suspended)走到账号状态副作用时打空指针。 +type openAIWSIngressCapacityShedRepo struct { + stubOpenAIAccountRepo +} + +func (r *openAIWSIngressCapacityShedRepo) SetError(context.Context, int64, string) error { return nil } + +func (r *openAIWSIngressCapacityShedRepo) SetRateLimited(context.Context, int64, time.Time) error { + return nil +} + +func (r *openAIWSIngressCapacityShedRepo) UpdateExtra(context.Context, int64, map[string]any) error { + return nil +} + +// ctx_pool 的 ingress 直写路径把 error / response.failed 交给 WS 客户端前,必须和 +// HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge +// (openai_ws_http_bridge.go)两条路径一样,把容量降载码改写为可重试的 +// server_error:Codex 按闭集判定,server_is_overloaded / slow_down 属致命集, +// 客户端会打印 "Selected model is at capacity" 并直接终止会话而不是退避重试。 +// +// 第二个用例锁住改写范围:非容量类错误码必须原样下发,客户端依赖原码各自处理。 +func TestProxyResponsesWebSocketFromClient_RewritesCapacityShedCodeForClient(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + upstreamEvents [][]byte + wantContains []string + wantAbsent []string + }{ + { + name: "capacity_shed_error_and_failed_are_rewritten", + upstreamEvents: [][]byte{ + []byte(`{"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}`), + []byte(`{"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`), + }, + wantContains: []string{ + `"code":"server_error"`, + "Our servers are currently overloaded", + }, + wantAbsent: []string{"server_is_overloaded"}, + }, + { + name: "non_capacity_error_code_is_passed_through", + upstreamEvents: [][]byte{ + []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"workspace_suspended","message":"workspace is suspended"}}`), + []byte(`{"type":"response.failed","response":{"id":"resp_suspended","status":"failed","error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`), + }, + wantContains: []string{ + `"code":"workspace_suspended"`, + "workspace is suspended", + }, + wantAbsent: []string{"server_error"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + events := make([][]byte, 0, len(tt.upstreamEvents)) + for _, event := range tt.upstreamEvents { + events = append(events, append([]byte(nil), event...)) + } + captureConn := &openAIWSCaptureConn{events: events} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + + account := Account{ + ID: 5401, + Name: "openai-ingress-capacity-shed", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + repo := &openAIWSIngressCapacityShedRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}} + svc := &OpenAIGatewayService{ + accountRepo: repo, + rateLimitService: &RateLimitService{accountRepo: repo}, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + cfg: cfg, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + + serverDone := make(chan struct{}) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(serverDone) + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := r.Clone(r.Context()) + req.Header = req.Header.Clone() + req.Header.Set("User-Agent", "unit-test-agent/1.0") + ginCtx.Request = req + + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + msgType, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil || (msgType != coderws.MessageText && msgType != coderws.MessageBinary) { + return + } + _ = svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, &account, "sk-test", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + + var frames []string + for len(frames) < len(tt.upstreamEvents) { + readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, message, readErr := clientConn.Read(readCtx) + cancel() + if readErr != nil { + break + } + frames = append(frames, string(message)) + } + // 本轮已终止,主动断开客户端让 ingress 退出 turn 循环。 + _ = clientConn.CloseNow() + + require.NotEmpty(t, frames, "客户端应至少收到一个下发事件") + joined := strings.Join(frames, "\n") + for _, want := range tt.wantContains { + require.Contains(t, joined, want, "客户端收到的事件:\n%s", joined) + } + for _, absent := range tt.wantAbsent { + require.NotContains(t, joined, absent, "客户端收到的事件:\n%s", joined) + } + + select { + case <-serverDone: + case <-time.After(5 * time.Second): + t.Fatal("等待 ingress websocket 结束超时") + } + }) + } +} From b6a7b8b7dfb3afad5af43ff7664215826f871e4c Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Mon, 31 Aug 2026 17:10:31 +0800 Subject: [PATCH 07/56] fix(repository): allow eight transient database retries --- backend/internal/repository/ent.go | 41 +++++++++---------- .../repository/ent_init_retry_test.go | 4 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index 6094b9ec61..165fea4baf 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -22,9 +22,9 @@ import ( ) const ( - maxDatabaseInitializationAttempts = 8 - databaseInitializationRetryBase = time.Second - databaseInitializationRetryMax = 30 * time.Second + maxDatabaseInitializationRetries = 8 + databaseInitializationRetryBase = time.Second + databaseInitializationRetryMax = 30 * time.Second ) // initializeDatabaseWithRetry retries only errors that indicate PostgreSQL is @@ -39,32 +39,29 @@ func initializeDatabaseWithRetryWithWait( initialize func(context.Context) error, wait func(context.Context, time.Duration) error, ) error { - var lastErr error - for attempt := 1; attempt <= maxDatabaseInitializationAttempts; attempt++ { + for attempt := 1; ; attempt++ { if err := initialize(ctx); err == nil { return nil } else { - lastErr = err - if !isTransientDatabaseInitializationError(err) || attempt == maxDatabaseInitializationAttempts { + if !isTransientDatabaseInitializationError(err) || attempt > maxDatabaseInitializationRetries { + return err + } + + delay := databaseInitializationRetryBase * time.Duration(1<<(attempt-1)) + if delay > databaseInitializationRetryMax { + delay = databaseInitializationRetryMax + } + slog.Warn("database initialization temporarily unavailable; retrying", + "retry", attempt, + "max_retries", maxDatabaseInitializationRetries, + "retry_in", delay, + "error", err, + ) + if err := wait(ctx, delay); err != nil { return err } } - - delay := databaseInitializationRetryBase * time.Duration(1<<(attempt-1)) - if delay > databaseInitializationRetryMax { - delay = databaseInitializationRetryMax - } - slog.Warn("database initialization temporarily unavailable; retrying", - "attempt", attempt, - "max_attempts", maxDatabaseInitializationAttempts, - "retry_in", delay, - "error", lastErr, - ) - if err := wait(ctx, delay); err != nil { - return err - } } - return lastErr } func waitForDatabaseInitializationRetry(ctx context.Context, delay time.Duration) error { diff --git a/backend/internal/repository/ent_init_retry_test.go b/backend/internal/repository/ent_init_retry_test.go index e0c6d4d116..e3bde3d24d 100644 --- a/backend/internal/repository/ent_init_retry_test.go +++ b/backend/internal/repository/ent_init_retry_test.go @@ -99,8 +99,8 @@ func TestInitializeDatabaseWithRetryReturnsLastErrorAfterLimit(t *testing.T) { }) require.ErrorIs(t, err, lastErr) - require.Equal(t, maxDatabaseInitializationAttempts, attempts) - require.Len(t, delays, maxDatabaseInitializationAttempts-1) + require.Equal(t, maxDatabaseInitializationRetries+1, attempts) + require.Len(t, delays, maxDatabaseInitializationRetries) require.Equal(t, databaseInitializationRetryMax, delays[len(delays)-1]) } From ba345f105277f612d55ffb93cb3cdba05582ab40 Mon Sep 17 00:00:00 2001 From: Jarl Date: Mon, 31 Aug 2026 20:26:29 +0800 Subject: [PATCH 08/56] fix(codex): ignore persistently disabled catalog accounts --- .../internal/handler/gateway_models_test.go | 7 ++ .../openai_codex_models_handler_test.go | 4 + .../openai_codex_model_metadata_test.go | 30 +++-- .../service/openai_codex_models_service.go | 27 ++++- .../openai_codex_models_service_test.go | 108 +++++++++++++++--- 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 8059611faa..386954a17e 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -72,6 +72,13 @@ func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID return s.ListSchedulableByGroupID(ctx, groupID) } +func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) { + if groupID == nil { + return nil, nil + } + return s.ListSchedulableByGroupID(ctx, *groupID) +} + func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler { return &GatewayHandler{ gatewayService: service.NewGatewayService( diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 9eabd3e5b2..fb66f8ee13 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -53,6 +53,10 @@ func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) return append([]service.Account(nil), r.accounts...), nil } +func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) { + return append([]service.Account(nil), r.accounts...), nil +} + type codexModelsFailoverHTTPUpstream struct { service.HTTPUpstream mu sync.Mutex diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 5161617d7b..b058fdc0df 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -198,8 +198,9 @@ func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithout } } -// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection. -func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) { +// Scenario: accounts that leave the current scheduling pool only because of +// transient state still participate in capability intersection. +func TestBuildCodexModelsManifestForGroupIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 741 @@ -213,19 +214,19 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t true, nil, ) - unschedulable := newCodexCatalogMappedAccount( + transientlyUnschedulable := newCodexCatalogMappedAccount( 42, "glm-5.3", "GLM 5.3", []string{"low", "medium", "high"}, []string{"text"}, 272_000, - false, + true, map[string]any{"exclusive-model": "exclusive-upstream"}, ) svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {schedulable}}, - catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}}, }} body, err := svc.BuildCodexModelsManifestForGroup( @@ -241,8 +242,8 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t require.EqualValues(t, 272_000, models[0]["context_window"]) } -// Scenario: deleting an account can widen the advertised contract. -func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) { +// Scenario: a persistently disabled account cannot narrow the advertised contract. +func TestBuildCodexModelsManifestForGroupIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 742 @@ -256,9 +257,20 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove true, nil, ) + disabled := newCodexCatalogMappedAccount( + 42, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + nil, + ) svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {remaining}}, catalog: map[int64][]Account{groupID: {remaining}}, + all: map[int64][]Account{groupID: {remaining, disabled}}, }} body, err := svc.BuildCodexModelsManifestForGroup( @@ -271,7 +283,7 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove require.EqualValues(t, 1_000_000, models[0]["context_window"]) } -func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) { +func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenAvailabilityLookupFails(t *testing.T) { t.Parallel() const groupID int64 = 743 @@ -286,7 +298,7 @@ func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFa true, nil, )}, - listByGroupErr: errors.New("group listing unavailable"), + availabilityErr: errors.New("group listing unavailable"), } svc := &GatewayService{accountRepo: repo} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b55549e753..82489b639a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -208,11 +208,12 @@ func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, // loadCodexGroupCatalogAccounts separates picker membership from capability // intersection. visible accounts are currently schedulable and decide which -// public aliases appear. catalog accounts are all non-deleted active group -// members; their snapshots keep advertised capabilities from widening when a -// mapped account is only temporarily unschedulable. If ListByGroup fails, the -// catalog falls back to the schedulable set so a listing error does not fail -// the client request. +// public aliases appear. catalog accounts are persistently enabled group +// members; the availability query ignores transient rate-limit, overload, and +// temporary-unschedulable state so those conditions cannot widen advertised +// capabilities. Persistently disabled accounts are excluded because routing +// cannot select them. If the availability query fails, the catalog falls back +// to the schedulable set so a listing error does not fail the client request. func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) { if repo == nil { return nil, nil, nil @@ -222,7 +223,21 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, return nil, nil, err } catalog = visible - groupAccounts, listErr := repo.ListByGroup(ctx, groupID) + groupAccounts, listErr := repo.ListModelAvailabilityCandidates( + ctx, + &groupID, + []string{ + PlatformAnthropic, + PlatformOpenAI, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, + }, + false, + ) if listErr != nil { return visible, catalog, nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 77a65cfb38..62d2924258 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -39,17 +39,23 @@ func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Con return append([]Account(nil), accounts...), nil } -func (r codexModelsVisibilityAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { - accounts := r.byGroup[groupID] +func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) { + if groupID == nil { + return nil, nil + } + accounts := r.byGroup[*groupID] return append([]Account(nil), accounts...), nil } type countingCodexModelsAccountRepo struct { AccountRepository - accounts []Account - err error - listByGroupErr error - calls atomic.Int32 + accounts []Account + err error + availabilityErr error + groupID *int64 + platforms []string + includeGrouped bool + calls atomic.Int32 } func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { @@ -60,9 +66,15 @@ func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Cont return append([]Account(nil), r.accounts...), nil } -func (r *countingCodexModelsAccountRepo) ListByGroup(_ context.Context, _ int64) ([]Account, error) { - if r.listByGroupErr != nil { - return nil, r.listByGroupErr +func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) { + if groupID != nil { + value := *groupID + r.groupID = &value + } + r.platforms = append([]string(nil), platforms...) + r.includeGrouped = includeGrouped + if r.availabilityErr != nil { + return nil, r.availabilityErr } return append([]Account(nil), r.accounts...), nil } @@ -71,6 +83,7 @@ type splitCodexModelsAccountRepo struct { AccountRepository schedulable map[int64][]Account catalog map[int64][]Account + all map[int64][]Account } func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { @@ -78,7 +91,18 @@ func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, } func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { - return append([]Account(nil), r.catalog[groupID]...), nil + accounts := r.all[groupID] + if accounts == nil { + accounts = r.catalog[groupID] + } + return append([]Account(nil), accounts...), nil +} + +func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) { + if groupID == nil { + return nil, nil + } + return append([]Account(nil), r.catalog[*groupID]...), nil } func newCodexCatalogMappedAccount( @@ -814,6 +838,13 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { ) require.NoError(t, err) require.Equal(t, int32(1), repo.calls.Load()) + require.NotNil(t, repo.groupID) + require.Equal(t, groupID, *repo.groupID) + require.False(t, repo.includeGrouped) + require.Contains(t, repo.platforms, PlatformOpenAI) + require.Contains(t, repo.platforms, PlatformGrok) + require.Contains(t, repo.platforms, PlatformDeepseek) + require.NotContains(t, repo.platforms, PlatformComposite) } func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) { @@ -1003,8 +1034,9 @@ func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWil require.NotContains(t, string(manifest.Body), "gpt-*") } -// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。 -func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) { +// Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集, +// 且不发布其独有模型。持久 schedulable 仍为 true。 +func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 79 @@ -1018,19 +1050,19 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc true, nil, ) - unschedulable := newCodexCatalogMappedAccount( + transientlyUnschedulable := newCodexCatalogMappedAccount( 42, "glm-5.3", "GLM 5.3", []string{"low", "medium", "high"}, []string{"text"}, 272_000, - false, + true, map[string]any{"exclusive-model": "exclusive-upstream"}, ) svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {schedulable}}, - catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}}, }} manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( @@ -1049,6 +1081,52 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc require.EqualValues(t, 272_000, models[0]["context_window"]) } +// Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。 +func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) { + t.Parallel() + + const groupID int64 = 80 + enabled := newCodexCatalogMappedAccount( + 51, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + disabled := newCodexCatalogMappedAccount( + 52, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + nil, + ) + svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {enabled}}, + catalog: map[int64][]Account{groupID: {enabled}}, + all: map[int64][]Account{groupID: {enabled, disabled}}, + }} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + ) + require.NoError(t, err) + require.True(t, configured) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "my-coder", models[0]["slug"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + // Scenario: 没有管理员模型配置时保留现有上游发现路径。 func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) { t.Parallel() From e21b849a926f5f30683ac9a421589f1cf37a4c51 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 31 Aug 2026 20:58:49 +0800 Subject: [PATCH 09/56] fix(openai): avoid synthesized instructions for API keys --- backend/internal/service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_service_hotpath_test.go | 8 +++----- backend/internal/service/openai_oauth_passthrough_test.go | 1 + 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 304af59909..23a41d29c6 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -360,7 +360,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeDeepSeekResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 381812faec..3b7e80bc78 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -103,7 +102,7 @@ func TestOpenAIRequestView_HasPatches(t *testing.T) { require.False(t, view.HasPatches()) } -func TestOpenAIGatewayService_Forward_HTTPPatchPathKeepsLargeInputRaw(t *testing.T) { +func TestOpenAIGatewayService_Forward_APIKeyMissingInstructionsKeepsLargeInputRaw(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ resp: &http.Response{ @@ -139,10 +138,9 @@ func TestOpenAIGatewayService_Forward_HTTPPatchPathKeepsLargeInputRaw(t *testing require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, upstream.lastReq) - // 合成路径默认 instructions 现按模型填入真实 Codex base prompt(此处 inbound model=gpt-5)。 - encodedInstr, _ := json.Marshal(defaultCodexSynthInstructions("gpt-5")) - expectedBody := fmt.Sprintf(`{"model":"gpt-5","stream":false,"reasoning":{"effort":"none"},"instructions":%s,"input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`, string(encodedInstr)) + expectedBody := `{"model":"gpt-5","stream":false,"reasoning":{"effort":"none"},"input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}` require.JSONEq(t, expectedBody, string(upstream.lastBody)) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.lastBody, "input.0.content.0.nonce").Raw) } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index fa750fa5b5..121a768790 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -2444,6 +2444,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd require.Equal(t, "flex", *result.ServiceTier) require.NotNil(t, upstream.lastReq) require.Equal(t, originalBody, upstream.lastBody) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer sk-api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) From 6d5f0278480889b6e7018e9ae53ddc12e9b22c2a Mon Sep 17 00:00:00 2001 From: Ray <19251440+swjturay@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:25:33 +0800 Subject: [PATCH 10/56] fix(openai-ws): recycle stale idle pool connections --- backend/internal/service/openai_ws_pool.go | 18 +++++++++++-- .../internal/service/openai_ws_pool_test.go | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 2c62b6b4c0..5a04392399 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -18,8 +18,11 @@ import ( ) const ( - openAIWSConnMaxAge = 60 * time.Minute - openAIWSConnHealthCheckIdle = 90 * time.Second + openAIWSConnMaxAge = 60 * time.Minute + openAIWSConnHealthCheckIdle = 90 * time.Second + // coder/websocket cannot consume pong frames without a reader. Recycle + // unsupported idle sockets before the upstream keepalive window expires. + openAIWSConnIdleRecycleAfter = 90 * time.Second openAIWSConnHealthCheckTO = 2 * time.Second openAIWSConnPrewarmExtraDelay = 2 * time.Second openAIWSAcquireCleanupInterval = 3 * time.Second @@ -1353,6 +1356,17 @@ func (p *openAIWSConnPool) cleanupAccountLocked(ap *openAIWSAccountPool, now tim if p.isConnPinnedLocked(ap, id) { continue } + if !conn.isLeased() && conn.waiters.Load() == 0 && + !conn.supportsIdlePingWithoutReader() && + conn.idleDuration(now) >= openAIWSConnIdleRecycleAfter { + delete(ap.conns, id) + if len(ap.pinnedConns) > 0 { + delete(ap.pinnedConns, id) + } + evicted = append(evicted, conn) + p.metrics.scaleDownTotal.Add(1) + continue + } if maxAge > 0 && !conn.isLeased() && conn.age(now) > maxAge { delete(ap.conns, id) if len(ap.pinnedConns) > 0 { diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index ada18af8d3..1a4480dccb 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -1307,6 +1307,32 @@ func TestOpenAIWSConnPool_BackgroundCleanupSweep_WithoutAcquire(t *testing.T) { require.False(t, exists, "后台清理应在无新 acquire 时也回收过期连接") } +func TestOpenAIWSConnPool_RecyclesUnsupportedIdlePingConnection(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + pool := &openAIWSConnPool{cfg: cfg} + + accountID := int64(303) + ap := &openAIWSAccountPool{conns: make(map[string]*openAIWSConn)} + conn := newOpenAIWSConn("stale_unsupported_idle_ping", accountID, &openAIWSIdlePingUnsupportedConn{}, nil) + conn.lastUsedNano.Store(time.Now().Add(-openAIWSConnIdleRecycleAfter - time.Second).UnixNano()) + ap.conns[conn.id] = conn + pool.accounts.Store(accountID, ap) + + pool.runBackgroundCleanupSweep(time.Now()) + + ap.mu.Lock() + _, exists := ap.conns[conn.id] + ap.mu.Unlock() + require.False(t, exists, "不支持无 reader idle ping 的陈旧连接应被主动回收") + select { + case <-conn.closedCh: + default: + t.Fatal("被回收的陈旧连接应已关闭") + } +} + func TestOpenAIWSConnPool_BackgroundWorkerGuardBranches(t *testing.T) { var nilPool *openAIWSConnPool require.NotPanics(t, func() { From cc6a8e517448d9b1e971be27ab799d1454400761 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 31 Aug 2026 21:29:13 +0800 Subject: [PATCH 11/56] chore: update sponsors --- README.md | 5 +++++ README_CN.md | 5 +++++ assets/partners/logos/axisnow.jpg | Bin 0 -> 5784 bytes 3 files changed, 10 insertions(+) create mode 100644 assets/partners/logos/axisnow.jpg diff --git a/README.md b/README.md index 5d0c4f433a..509dcaaa03 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ Please read the following carefully before using this project: Thanks to APIMart for sponsoring this project! APIMart is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — sign up here to get started. + +AxisNow +Thanks to AxisNow for sponsoring this project! AxisNow is building a cloud-agnostic edge control layer. Use AxisNow to protect and accelerate your websites and APIs, delivering optimal access experiences for both mainland China and global users, while extending acceleration and security capabilities to native/mobile apps through client SDKs — all on your own controllable, composable edge network. + + ## Overview diff --git a/README_CN.md b/README_CN.md index faa0200c58..015d3c9dc1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -175,6 +175,11 @@ 感谢 APIMart 赞助了本项目!APIMart 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过此注册链接注册即可开始使用。 + +AxisNow +感谢 AxisNow 赞助了本项目!AxisNow 正在打造一个云无关的边缘控制层。使用 AxisNow 可以保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — 这些都在您可控的、自由组合的边缘网络。 + + ## 项目概述 diff --git a/assets/partners/logos/axisnow.jpg b/assets/partners/logos/axisnow.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59d0686dacb74f607703c92f5cc05fbdaa2962b3 GIT binary patch literal 5784 zcmb7{1yI%Bw#QGxK{}3DX3zfZz1E)J{;gS``L4U!yA=SQvb>T!00;yCfcFpJZUG<*fT5wGqd{Tl z=;#<2um@N~*jSjDSY!l*xI|RsG}Kh&l$5lLTr9No91N6{tWVfDxOw>b`Ds{$#f5mq zxcK;a|2hPSfq{XAiA9QyP0CA0Nyq#Dw!1C>J`CUl)CB|S0U&%J7$11o1E9G7NHFl; z0RJ*58af0F`fHQu-V@n0s{dcJTTt<4M#iv74?q^M(WGF%6^q%YI8rc0{cSc zt>;xsVjbYg?I> zboX5VCu%`>V6bpUGhvAZ<#nUQFdsISyl*tT7?HnAWVNK(IEh1ox_YjL9$d)iD35qo zlZaj!3wRi#Of^(={gTYn96ax>%Jzm@_XGo5upLWDefuYVk|{E!yOw97Hb|;)vAE!S zHLj3e9U|OCZ0rnE>5=gyZ_Qkm9a(d}ZiP{fbJ}6?`=#5HUP=I?b-S8*nBY79gXmLf z8spL8#Jy{rKwr1#)nTrF$E$7ypiXlVJ%szvv|LTwXa20H4MmMf06CNe8yp7ml#SU@i?UJYpt-Y%qCpR zs&k4;Weq-f7~l>NWO`eJI53-5nYq*%VJP?{Td3xc)x5m@W@kWZ=(QuR>|#h?PE%4c zQo!$KuPF(iu%kx>UPtJoTx|FaW*QPp8NIw_Lbf~ebydv?IZT7Sq;Y(C`0FZn_?`J zITGu!_0#NEX-xZ%Z8-2~DHrDY-m*pb2H|7#UtYSDR_F3u(YLcLz@v9$Wp>n>9`5Co zu+;F)5)i|Xwd?iemQT{l&1M+$Jbe-vhbFI@umwg=EFDc6-t5XkS{Cz1lN&Z}kTWUa z4o%&=&grA!;63v@_(EwO;+==?;Fy`|`}kO&?1V z&i0kXf~V|d+5|>1dsNx{r?RM|IX=0|7+>ylnr&yUZY9=~dvyyT<8qEu*-MFz=if@(0IusVnXZam=7 z()l#?HK?agtE{R!J8%R+vR`hns$)rn;~p>T z6-!uu3>a1HVauqm&y&)JVV_b$3@15P1`Uf77kg?cw}nUiz|WqGT&plwxyc!P(0T-t zbVHotH*)K@D0&L$g`lF!Fs`IhBa`;Qk|H#RPaa-ki0P_!LlDbh(w_+RAG^%n0jT~0 z75=~R>reeS{V9cevPlBT|BD0YpAO>pH3kHM!RQYlQ0Si%?%x3T5CS?LX)QcLT6$jR zP9g>wZA&YcfShX0S8-$9d=`PLP=1~G+<#OZnk48p?MntLc_zQLNX3+xoE-k_aJj-T z-_miOZpN;6K^6eK9C}gp^8wrjx{Q)CeKwTV8q~#=1qqQ`|lu==GNU z=|y}*?oxhz2#JHq_zx6+4MnZ1zEJN{9Vn`%I|=P~cm8Z}^eN}jOA&g*oS`twA@+Vp z1{eIT!A4ECxR5lpnA2p+Iq6UJTU+wS<26deE56NYhld|}SZkXAlZVIiIhVV#1PZ2H zTF(?oW!`7Z`|MYoR!(OUI3^zMh;75Ix?88&@tacb0K>!%*|X>77{eg)8~f9pzJnKe zJS^L02BdqEj_nDAH=hLFUZH;63L=)`nu;s8wtjcDZorlk=UcweXHAET?g@+QqqT%H z_>`MD;^dtP>?`R0E|piJ;G_DGZ6jX8=QA?ELzthpFBfKOR?iVa+%aGF*h}>#HW4e8 zMee+T$+c4H?bMKEPPm?^Y6)~$gF%+eiF(N3Sz_$Bjzft7yng7ng;q6ErtZ1&>UJ*= z>6SyP$vXh{Z@VfXlp+cBRAETt3o$t>zsA058vIWen;u^=jW0#phi!yxw{Fdm-xu+~ zsy$p??1r`xPXvdWlP~y9g6x(|H@^05&MxqNE0816DXLcyJ7vK9Y3~hG?SMkQb-$Kd zOJ_?OwP|H-z^^qJUZF=urB9w!el5QTd zV=|&`Nk5dVcgd^AZy!wfVs5xGcOWoqnOLf<-YU#PHF{aJ`EsP&ObfCi(14OIJ2g7o z6Rh(bnhSmJQ1|CfHc=Oe<7<}G&T<{UB6{usjwm8g3Vg>KUHqe|X6CnhVBb9nvhP{- z3asbT%RGjpp&_Oz0 zU}+$ti!ODm25l1UmpUfoXlvQ3%M@I0ih3yN27T@FMr0r7x9DotrfJPjBg6acQjGmV zZ^beuvIJPZPSfii3|hZL%hb?+^`@SLUR=+8Az&i`pDUncnNF_py*>0Xn!Y1B{vDvN zK8&K%mu5U!Xoin! z(kkgiSG;AF`^VA&r)yK3YILt@qjkh>ZmmN6Z!5uKI#eWky6JaMF(nq!#EUY{HwIj! zechUMJRI?7CPps=B+J?R$Sj}rdlsZUaiEmrh+~Xo{kn)PrxIe5Ac|HERL4N&S zbkIF*awA95)thz`xrP{IaiWA$k{Jnil|6P4*wpReh}tih@l~On9Z_ualweZnZ9+|r zKui~T$+s-EV~;;gd>I<5p|0;UPIfe^V9QN2y{O=wOt7~VF%OepkJF72Y_l!sqB}$` zO){Wdc$Q4w=^qN%h$xD3c13BIr|MY3CkscB6TLf!W_wM70xreBVROVu4X3#>Tk*0r zQ8iVgFKay>XuC$C4b(-yD~_@?6M2UdR=(44A;$X9gqKPIUn)@#vt~Ikoc=`_>%%em zUJ5opwt_3`zS{mG@q%@EBjW=<7$JPr(%x*6Y3sX@gZ=VP$$Ol##IF0sHD4*-GM>(7J4IEB>s^=QXj~Wz;W*rusCB7Z|FScO zCLAO9HXmi;a&e^l6+Bh4fY#%R@*MIs-yB6ME#coQ}Sr+$uG?vas>^LE=6{S@x>8Q;Jb0K^5S8r`fvplX`iQLQ04->ho z?3K@-{Lsp9mY58j|4P&pu3|oLQA+R|{4HTW{2*e7$Y)rb!ze3(Rs7SH!StBvb&(p7 zTKMKH-(cE8crqmjq+vMnFQ9jwVc8YJJ_`4%`)Zt-T4mMfvZfCAAeT{{rIy2WA1>7m!=HM4OuVR0Z& z!YH{+$Z*eB!K@w&*Tc_E+MJASnPL$I<5Tw@10ZRwV_ z<55Ojoa(BWH%#lR2%nstlsn00uH*_UcYkm9;7k9M`o5v4cbhA}m6*8*p2K&FSqUG4 zQ8-mrNvv9F@HmM&x$X_V)}K z@+>+RKMEX+TzX^YW|Olwx{*lT^;=VR0w!&@jq-c0CQPs0gzKd*=KDRjhUlczTwrc(4pF6&s}Eh^}lM$u8}_jh%U3F;N+We*AE=otOxCh>_)W zOT$;DQ`UfYzQ0OS={SBn&K53HSIKGka}sY>-QgxT-~Q+6fr<|K2t|W<%eC9Q;fO8( z@A>#`1jx$r2B|JUFP$=aRQ;?9@i~8}2`UlY{;Il+7Fk_10YVr0QT&zC>_>q(nHxjO zUO7;6R1&t>m((xj9YadN0r?X2=1si>5trqqrC~oBW>K$I$xTOTL>13x=fXm6EEr#z z2?r%1Zy7EhUDqbD1+5_hwfe&8YrcD6$d{-eryb^smWcyq0YtpLnEYsXa%=pm0|INl zrjJ^sM5%EsiWNIHT}SLGfdM_dyHYMM)=56G6-cdAtwCehLG-4|+ImSN6>2*oE@cRA zPq^rK)~gj_XX!UV$XYXjn@7uIKiJ+ut00 zTMDq^j|Grs1^7b%0IVt?BOn5h7eg5*#ZTV>kd?v<0096nZXf_J5WsyOBisEa@P8X{ z|BHpnG}w$Nou9Jm-Ko+MMc6%@b~m)qiml?+#!TxLoVW zLq00R(#!Zz5bm}bcAIF=W`pI3UN!lKD1HovZBWxJ)xhA;!)hSV)G}h!q zGOh=C{Bl)KDvBk8XZ=d$5eLG}lo2INfMW_<5%Lk$?9dfm2paxY!v1msZayL7i}yR` zLRzu!2z{$)8?T<@B#HubY!F#$nLP``jfmq=R2l>>eHgELZHs|FKzeP(jE?paiq_wI zZcMSN&!wQO!X5xiBg}81dkL}1sIE8LVdl+Ytaa|c&M#()Q=nQolctNpmB^9C1e$VV z^`WYL*{1?+@~xX=z1i8DUUH5#wA!~IPY0JN3OndW4Rg}X zryG{?hTJ0+c!Sd#ogq34y%$3-;Seb;{Do?;C4t?G@nF`cqH)~{j4N+rz9tq<|7MlJ zdL}mZE7D&cXhEt#l<*F=K}(vXs>vKd$L`Mo>7gMl@J$?l!qnuezmY+EiaNwOgkK`5 zsf4e#4EH`Rp!4??FLLp`FIX%fDLtu$%!V3$oYupy8b8~)Z!CrbVMc`K^7+IieODX5KfPDL)M0lQ|Rx~>!$OANkhUwAE z_>{=`-CRnbUFRexU0xi8>nPD@U#b}lhHht}st@J2e4>_C@aNQ=+W8@OA^D=pd* z{=Zyj-27!Q7JeYetlrJrJhr1DY9Z`q!yP+S!qMYj^FhgTQ}izTO=yvzPoGFAY$&}- zqoxDlOe@%^vulyX>Lp-o*cI%t?_rS0pITEXd2pcf&{tt$CvUMvF%=0ypKa=hl?G!6 zE(KERh&zo#)C|qFzEAwDh-1YWMcyX-`oOJuM*J{@l|sQ+Tf<)OoENhkTnVg7uWP$P zcX9SsE%Gp7cT^I4G0CL%li~L)nma67fBZUYE6^%&-(ySyH4v*Fi*3K(cp;h^KJtp-v-umYNtirg@KVWP=hwFr z;iSm&_to2fpKgBBhA@C=FLRt3Mg}r71$BI4`TYnB>oL@4N1T-_^&6;O5#{FggcMdDigx)b7EG)g%V?KG@3BZLIKkp7`GJ|uYp zDMt6U@ECogh)zjqi9*ARC{6|wL^7Aqugalm;6m@jS)Nz6yA3H0facpqF4*f!$t6I( znZ$YKVjHG~1TaD)_l}?cv{C}w9C=xe2iqOwDVI^2;6nLr-=SB&@mDBW-tTx9uTVvhd7mWs@OWo{>hhDVXei^K?G1bJ7 zraSWkFA(hDE)vsgjV&QPAFHpX@M05cjTLG#-vNM|%%Trk@*dsD?8nE^40N0%9{02s zw^US~C>2NU@!;^z5oCoeG$GXexmMUr4Rw=TIeK=JUWb!#aK=>C`w(*i^L?cdyYtEz zL)TL7i{BB`3b{uIgPN(YH?r&I$yi=fpr5qIIi#v7z_t>df{d(w*t~W!sT|6UCEyx& nSECYTQ*3shcuJ1l?$_g4%mho%iZ~-_Ab9>2B{^{ixSRVMI#~*T literal 0 HcmV?d00001 From e2624fb65ca421e18d6fb8fc2986074733311b9a Mon Sep 17 00:00:00 2001 From: Brisbanehuang <202969697+Brisbanehuang@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:42:58 -0400 Subject: [PATCH 12/56] fix(codex): preserve known image input capabilities --- .../openai_codex_models_handler_test.go | 4 +- .../service/openai_codex_models_service.go | 36 +++++-- .../openai_codex_models_service_test.go | 100 +++++++++++++++++- 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 9eabd3e5b2..686c6c13c5 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -620,8 +620,8 @@ func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest. t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"]) } modalities, ok := model["input_modalities"].([]any) - if !ok || len(modalities) != 1 || modalities[0] != "text" { - t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"]) + if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" { + t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"]) } } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b55549e753..6acb7d242c 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1041,6 +1041,11 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) } switch account.Platform { case PlatformOpenAI: + if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok { + if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + return stringSliceContains(modalities, "image") + } + } if !isOpenAICodexImageInputModel(upstreamModel) { return false } @@ -1050,8 +1055,9 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) if !account.IsOpenAIApiKey() { return false } - baseURL := strings.TrimSpace(account.GetCredential("base_url")) - return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) + // Compatible model lists often omit modalities. Preserve the known GPT + // fallback unless a synced snapshot above explicitly narrows it. + return true case PlatformGrok: if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) { return false @@ -1715,7 +1721,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont upstreamBody := body convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { - convertedBody := convertOpenAIModelListToCodexManifest(body) + convertedBody := convertOpenAIModelListToCodexManifestForAccount(body, request.credentialAccount) convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body) body = convertedBody } @@ -1734,7 +1740,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont body, err = completeAPIKeyCodexModelsManifestMetadata( body, false, - request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()), + request.credentialAccount, ) if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -1855,6 +1861,10 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { // standard list shape, or yield no usable model IDs are returned unchanged so // envelope validation reports the original payload. func convertOpenAIModelListToCodexManifest(body []byte) []byte { + return convertOpenAIModelListToCodexManifestForAccount(body, nil) +} + +func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Account) []byte { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil { return body @@ -1883,7 +1893,13 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { if len(modelIDs) == 0 { return body } - converted, err := BuildCodexModelsManifest(modelIDs) + imageInputModels := make(map[string]bool, len(modelIDs)) + for _, modelID := range modelIDs { + if accountCodexModelSupportsImageInput(account, modelID) { + imageInputModels[modelID] = true + } + } + converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, nil, nil) if err != nil { return body } @@ -1904,7 +1920,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife if len(manifest.upstreamSourceBody) > 0 { body = append([]byte(nil), manifest.upstreamSourceBody...) if manifest.convertedFromOpenAIModelList { - body = convertOpenAIModelListToCodexManifest(body) + body = convertOpenAIModelListToCodexManifestForAccount(body, account) } } var err error @@ -1915,7 +1931,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife body, err = completeAPIKeyCodexModelsManifestMetadata( body, true, - isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()), + account, ) if err != nil { return err @@ -2034,7 +2050,7 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit return updated, nil } -func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) { +func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, account *Account) ([]byte, error) { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { return nil, fmt.Errorf("decode JSON object: %w", err) @@ -2044,6 +2060,7 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia return nil, fmt.Errorf("decode top-level models array: %w", err) } + officialOpenAI := account != nil && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()) changed := false if officialOpenAI { filtered := make([]json.RawMessage, 0, len(models)) @@ -2084,6 +2101,9 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia } descriptor := newConfiguredCodexModelDescriptor(slug) + if accountCodexModelSupportsImageInput(account, slug) { + descriptor.InputModalities = []string{"text", "image"} + } if forceOfficialImage { descriptor.InputModalities = []string{"text", "image"} descriptor.SupportsImageDetailOriginal = true diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 77a65cfb38..dadf89d78e 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -492,7 +492,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageI require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) } -func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) { +func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) { t.Parallel() tests := []struct { @@ -602,6 +602,15 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, }}, + modalities: []any{"text", "image"}, + }, + { + name: "custom OpenAI-compatible host with unknown model", + model: "company-coding-model", + accounts: []Account{{ + ID: 29, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, + }}, modalities: []any{"text"}, }, } @@ -628,6 +637,67 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti } } +func TestBuildCodexModelsManifestForGroupPrefersSyncedOpenAIImageCapabilities(t *testing.T) { + t.Parallel() + + newAccount := func(id int64, modalities []string) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, + } + if modalities != nil { + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-5.6-sol": {ID: "gpt-5.6-sol", InputModalities: modalities}, + }}) + } + return account + } + + tests := []struct { + name string + accounts []Account + modalities []any + }{ + { + name: "explicit text-only snapshot narrows local fallback", + accounts: []Account{ + newAccount(30, nil), + newAccount(31, []string{"text"}), + }, + modalities: []any{"text"}, + }, + { + name: "explicit multimodal snapshot preserves local fallback", + accounts: []Account{ + newAccount(32, nil), + newAccount(33, []string{"text", "image"}), + }, + modalities: []any{"text", "image"}, + }, + } + + for i, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + groupID := int64(760 + i) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: tt.accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-5.6-sol"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tt.modalities, models[0]["input_modalities"]) + }) + } +} + func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) { t.Parallel() @@ -1723,6 +1793,8 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin require.Len(t, models, 2) requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6") requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex") + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.Equal(t, []any{"text", "image"}, models[1]["input_modalities"]) require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) require.Equal(t, `W/"openai-list"`, manifest.upstreamETag) } @@ -1769,6 +1841,32 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"]) } +func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[ + {"slug":"gpt-5.6-sol"}, + {"slug":"company-coding-model"}, + {"slug":"gpt-4o","input_modalities":["text"]} + ]}`)} + account := newCodexModelsAPIKeyTestAccount("https://openai-compatible.example.test/v1") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 3) + + bySlug := make(map[string]map[string]any, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + bySlug[slug] = model + } + require.Equal(t, []any{"text", "image"}, bySlug["gpt-5.6-sol"]["input_modalities"]) + require.Equal(t, []any{"text"}, bySlug["company-coding-model"]["input_modalities"]) + require.Equal(t, []any{"text"}, bySlug["gpt-4o"]["input_modalities"]) +} + // Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。 func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) { t.Parallel() From 530fb20f25ddf9ac7212e2e0143afacf87d5e612 Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 28 Aug 2026 23:47:48 +0800 Subject: [PATCH 13/56] =?UTF-8?q?refactor(billing):=20=E9=95=BF=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E9=98=B6=E6=A2=AF=E8=AE=A1=E4=BB=B7=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=B7=E6=A0=BC=E7=9B=AE=E5=BD=95=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 GPT/Gemini 系列写死的阶梯规则,阶梯一律由目录数据驱动: - 解析层折算 LiteLLM 的 *_above_XXXk_tokens 绝对价字段为阈值+倍率 (阈值取自字段名,倍率 = above/基础价,cache 侧跟随输入倍率, xAI 按 provider 用达到即进高档语义);显式 long_context_* 字段 按"字段存在"优先,显式 threshold=0 或 multiplier=1 可关闭阶梯 - 删除 OpenAI 272K 常量与 applyModelSpecificPricingPolicy 的长上下文 强补分支、fallbackPrices/静态兜底条目的 LongContext 字段、Gemini 旧 边际规则全链路(CalculateCostWithLongContext / RecordUsageWithLongContext / 阶梯表 marginal 基准);保留 fastRatio 与 GPT-5.6 cache_write 1.25x 防御分支、grok 静态兜底阶梯 - 渠道配置定价区间时完全以渠道区间为准,不再叠加目录阶梯 - 长上下文倍率 ≤0 在应用点按 1 计,防止显式字段残缺把分项乘 0 免费 - 目录重载时对比新旧数据,原有阶梯丢失打 WARN 哨兵告警 行为变化:Gemini 由边际 2x(仅 /v1beta、全模型)改为整单换档(数据 驱动、仅带 above_200k 的 pro 系),渠道平价之上叠加目录阶梯;Claude sonnet-4/4.5 随目录数据获得 200K 整单阶梯(与官方定价一致);模型广场 官方参考价列与实付列同源展示目录阶梯。 --- backend/internal/config/config.go | 2 +- backend/internal/handler/endpoint.go | 2 +- .../internal/handler/gemini_v1beta_handler.go | 44 ++-- .../service/billing_context_schedule.go | 52 ++--- .../service/billing_context_schedule_test.go | 82 ++++---- backend/internal/service/billing_service.go | 175 ++++------------ .../internal/service/billing_service_test.go | 156 +++----------- .../service/billing_token_cost_request.go | 52 +---- .../billing_token_cost_request_test.go | 107 ++++++---- .../service/gateway_record_usage_test.go | 39 ---- .../internal/service/gateway_usage_billing.go | 108 ++-------- .../service/model_plaza_service_test.go | 16 +- .../openai_gateway_record_usage_test.go | 18 +- backend/internal/service/pricing_service.go | 146 +++++++++++-- .../internal/service/pricing_service_test.go | 196 +++++++++++++++++- .../service/pricing_stub_helpers_test.go | 33 +++ deploy/config.example.yaml | 4 +- 17 files changed, 599 insertions(+), 633 deletions(-) create mode 100644 backend/internal/service/pricing_stub_helpers_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c856cbf61..390c1bba18 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2279,7 +2279,7 @@ func setDefaults() { viper.SetDefault("rate_limit.overload_cooldown_minutes", 10) viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10) - // Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移) + // Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据 viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json") viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256") viper.SetDefault("pricing.data_dir", "./data") diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index d13bbee47a..b45b101d89 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc { // ────────────────────────────────────────────────────────── // Context helpers — used by handlers before building -// RecordUsageInput / RecordUsageLongContextInput. +// RecordUsageInput. // ────────────────────────────────────────────────────────── // GetInboundEndpoint returns the canonical inbound endpoint stored by diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 07840b7ea4..fc01d51725 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) - // 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。 - var longContextThreshold int - var longContextMultiplier float64 - if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil { - longContextThreshold = rule.Threshold - longContextMultiplier = rule.Multiplier - } + // 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。 h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { - if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ - Result: result, - QuotaPlatform: quotaPlatform, - APIKey: apiKey, - User: apiKey.User, - Account: account, - Subscription: subscription, - PricingAt: pricingAt, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - RequestPayloadHash: requestPayloadHash, - LongContextThreshold: longContextThreshold, - LongContextMultiplier: longContextMultiplier, - ForceCacheBilling: forceCacheBilling, - APIKeyService: h.apiKeyService, - SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: result, + QuotaPlatform: quotaPlatform, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + PricingAt: pricingAt, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + ForceCacheBilling: forceCacheBilling, + APIKeyService: h.apiKeyService, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { logger.L().With( zap.String("component", "handler.gemini_v1beta.models"), diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index 05f2a181b1..7773022504 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -9,15 +9,12 @@ import ( "time" ) -// ContextPricingBasis 阶梯的计价基准。 +// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的 +// Gemini 边际口径("marginal")已随平台旧规则一并移除。 type ContextPricingBasis string -const ( - // ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 - ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" - // ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。 - ContextPricingBasisMarginal ContextPricingBasis = "marginal" -) +// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 +const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" // ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 // nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 @@ -73,8 +70,8 @@ const contextProbeDelta = 1000 // ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。 // // 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价, -// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。 -// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值), +// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。 +// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值), // 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等 // 规则变更无需同步到这里;相邻同价段会合并。 // @@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso return nil, nil } - var legacy *LegacyLongContextRule - if in.Group != nil { - legacy = s.LegacyLongContextRule(in.Platform) - } - if !legacyLongContextApplies(resolved, in.Group, legacy) { - legacy = nil - } - req := TokenCostRequest{ - Ctx: ctx, - Model: in.Model, - Group: in.Group, - RateMultiplier: 1, - Resolver: resolver, - Resolved: resolved, - LegacyLongContext: legacy, + Ctx: ctx, + Model: in.Model, + Group: in.Group, + RateMultiplier: 1, + Resolver: resolver, + Resolved: resolved, } probe := func(tokens UsageTokens) (*CostBreakdown, error) { r := req @@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso return s.CalculateTokenCostForRequest(r) } - plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy) + plan := s.contextPricingBreakpoints(resolver, resolved, in.Model) segments := buildContextSegments(plan.bounds) tiers := make([]ContextPricingTier, 0, len(segments)) @@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso tiers = mergeEqualContextTiers(tiers) applyContextTierLabels(tiers, plan) - basis := ContextPricingBasisWholeRequest - if legacy != nil { - basis = ContextPricingBasisMarginal - } - return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil + return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil } // resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。 @@ -210,14 +194,8 @@ type contextBreakpointPlan struct { } // contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。 -func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan { +func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan { plan := contextBreakpointPlan{} - if legacy != nil { - plan.bounds = []int{legacy.Threshold} - plan.thresholdBound = legacy.Threshold - plan.threshold = legacy.Threshold - return plan - } if !resolved.longContextPricingEnabled { return plan } diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index b13606da9f..77a0195cf4 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) { require.InDelta(t, *want, *got, 1e-15, field) } +// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。 +func mustCatalogFromJSON(body string) *PricingService { + s := &PricingService{} + data, err := s.parsePricingData([]byte(body)) + if err != nil { + panic(err) + } + s.pricingData = data + return s +} + +func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) } + func scheduleScenarios() []scheduleScenario { p := testPtrFloat64 return []scheduleScenario{ { name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, - group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) @@ -92,7 +105,7 @@ func scheduleScenarios() []scheduleScenario { }, { name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, - group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) @@ -100,7 +113,7 @@ func scheduleScenarios() []scheduleScenario { }, { name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI, - group: nil, wantBasis: ContextPricingBasisWholeRequest, + group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) @@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario { group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), }}}, + catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -219,46 +233,55 @@ func scheduleScenarios() []scheduleScenario { }, }, { - name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal, + name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) - requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6)) }, }, { - name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) }, }, { - name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), + name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), channel: []ChannelModelPricing{{ Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6), }}, wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { - require.Len(t, s.Tiers, 1) + require.Len(t, s.Tiers, 2) requirePrice(t, p(3e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(6e-6), s.Tiers[1].Input, "input") }, }, { - name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, - group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) }, }, + { + name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, + group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + }, + }, { name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite, group: enabledGroup(PlatformComposite), channel: []ChannelModelPricing{{ Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), }}, + catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -283,9 +306,11 @@ func scheduleScenarios() []scheduleScenario { { name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, group: enabledGroup(PlatformOpenAI), - catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ - "gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6}, - }), + catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}}`), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -398,22 +423,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier { } // expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用: -// 整单基准取所在档单价 × 全量;边际基准逐段累加。 +// 整单基准取所在档单价 × 全量。 func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 { - if s.Basis == ContextPricingBasisMarginal { - total := 0.0 - for _, tier := range s.Tiers { - if contextTokens <= tier.MinTokens { - break - } - upper := contextTokens - if tier.MaxTokens != nil && *tier.MaxTokens < upper { - upper = *tier.MaxTokens - } - total += float64(upper-tier.MinTokens) * tierPrice(tier, kind) - } - return total - } return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind) } @@ -443,17 +454,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) { pricingInput.GroupID = &gid } resolved := resolver.Resolve(ctx, pricingInput) - var legacy *LegacyLongContextRule - if sc.group != nil { - legacy = bs.LegacyLongContextRule(sc.platform) - } - if !legacyLongContextApplies(resolved, sc.group, legacy) { - legacy = nil - } cost := func(tokens UsageTokens) float64 { bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy, + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) return bd.ActualCost diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 7002fc09cc..ed4856e961 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -116,12 +116,6 @@ type ModelPricing struct { ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } -const ( - openAIGPT54LongContextInputThreshold = 272000 - openAIGPT54LongContextInputMultiplier = 2.0 - openAIGPT54LongContextOutputMultiplier = 1.5 -) - func normalizeBillingServiceTier(serviceTier string) string { return strings.ToLower(strings.TrimSpace(serviceTier)) } @@ -395,9 +389,6 @@ func (s *BillingService) initFallbackPricing() { CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } // OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。 // Source: https://platform.openai.com/docs/pricing @@ -405,24 +396,18 @@ func (s *BillingService) initFallbackPricing() { InputPricePerToken: 5e-6, OutputPricePerToken: 30e-6, // 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。 - CacheCreationPricePerToken: 5e-6, - CacheReadPricePerToken: 0.5e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, }, 2.5) // GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。 s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{ InputPricePerToken: 30e-6, OutputPricePerToken: 180e-6, // 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。 - CacheCreationPricePerToken: 30e-6, - CacheReadPricePerToken: 30e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + CacheCreationPricePerToken: 30e-6, + CacheReadPricePerToken: 30e-6, + SupportsCacheBreakdown: false, } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 @@ -435,9 +420,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 12.5e-6, CacheReadPricePerToken: 0.5e-6, CacheReadPricePerTokenPriority: 1e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{ InputPricePerToken: 2e-6, @@ -448,9 +430,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 5e-6, CacheReadPricePerToken: 0.2e-6, CacheReadPricePerTokenPriority: 0.4e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{ InputPricePerToken: 0.2e-6, @@ -461,9 +440,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 0.5e-6, CacheReadPricePerToken: 0.02e-6, CacheReadPricePerTokenPriority: 0.04e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{ @@ -1125,10 +1101,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { CacheCreation1hPrice: price1h, SupportsCacheBreakdown: enableBreakdown, LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold, - LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, - LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, - ImageInputPricePerToken: litellmPricing.InputCostPerImageToken, - ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, + // xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。 + LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"), + LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, + LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, + ImageInputPricePerToken: litellmPricing.InputCostPerImageToken, + ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, }), nil } } @@ -1375,15 +1353,18 @@ func (s *BillingService) computeTokenBreakdown( var baselineCost *CostBreakdown if longContextPricingEligible { baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false) - inputPrice *= pricing.LongContextInputMultiplier - outputPrice *= pricing.LongContextOutputMultiplier + // 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧), + // 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。 + longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier) + inputPrice *= longCtxInputMultiplier + outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier) // 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率; // 否则 cache hit 越多,少计的费用越多(见 #2293)。 - cacheReadPrice *= pricing.LongContextInputMultiplier + cacheReadPrice *= longCtxInputMultiplier // 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h) // 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里 // 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。 - cacheCreationMultiplier = pricing.LongContextInputMultiplier + cacheCreationMultiplier = longCtxInputMultiplier } bd := &CostBreakdown{} @@ -1568,10 +1549,13 @@ func (s *BillingService) calculateCostInternalWithPolicy( return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil } -// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、 -// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡 -// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的 -// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。 +// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价 +// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority +// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文 +// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段) +// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部) +// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx +// 关闭强制,保留运营者配置。 func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing { return s.applyModelSpecificPricingPolicyEx(model, pricing, true) } @@ -1607,16 +1591,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) - usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized) - if !isGPT56 && !usesLegacyLongContextPricing { - return pricing - } - needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) && - (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 { + if !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing @@ -1628,17 +1606,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25 } } - if isGPT56 || usesLegacyLongContextPricing { - if cloned.LongContextInputThreshold <= 0 { - cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold - } - if cloned.LongContextInputMultiplier <= 0 { - cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier - } - if cloned.LongContextOutputMultiplier <= 0 { - cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier - } - } if fastRatio > 0 { enforceOpenAIFastPricingRatio(&cloned, fastRatio) } @@ -1678,6 +1645,14 @@ func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) { } } +// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。 +func longContextMultiplierOrOne(m float64) float64 { + if m <= 0 { + return 1 + } + return m +} + func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool { if pricing == nil || pricing.LongContextInputThreshold <= 0 { return false @@ -1692,10 +1667,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens return totalInputTokens > pricing.LongContextInputThreshold } -func usesOpenAILegacyLongContextPricing(normalized string) bool { - return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro" -} - // CalculateCostWithConfig 使用配置中的默认倍率计算费用 func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) { multiplier := s.cfg.Default.RateMultiplier @@ -1705,82 +1676,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken return s.CalculateCost(model, tokens, multiplier) } -// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费 -// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费 -// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍) -// -// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0 -// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k) -// 范围内正常计费,范围外 × 2 计费 -func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { - // 未启用长上下文计费,直接走正常计费 - if threshold <= 0 || extraMultiplier <= 1 { - return s.CalculateCost(model, tokens, rateMultiplier) - } - - // 计算总输入 token(缓存读取 + 新输入) - total := tokens.CacheReadTokens + tokens.InputTokens - if total <= threshold { - return s.CalculateCost(model, tokens, rateMultiplier) - } - - // 拆分成范围内和范围外 - var inRangeCacheTokens, inRangeInputTokens int - var outRangeCacheTokens, outRangeInputTokens int - - if tokens.CacheReadTokens >= threshold { - // 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入 - inRangeCacheTokens = threshold - inRangeInputTokens = 0 - outRangeCacheTokens = tokens.CacheReadTokens - threshold - outRangeInputTokens = tokens.InputTokens - } else { - // 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入 - inRangeCacheTokens = tokens.CacheReadTokens - inRangeInputTokens = threshold - tokens.CacheReadTokens - outRangeCacheTokens = 0 - outRangeInputTokens = tokens.InputTokens - inRangeInputTokens - } - - // 范围内部分:正常计费 - inRangeTokens := UsageTokens{ - InputTokens: inRangeInputTokens, - OutputTokens: tokens.OutputTokens, // 输出只算一次 - CacheCreationTokens: tokens.CacheCreationTokens, - CacheReadTokens: inRangeCacheTokens, - CacheCreation5mTokens: tokens.CacheCreation5mTokens, - CacheCreation1hTokens: tokens.CacheCreation1hTokens, - ImageOutputTokens: tokens.ImageOutputTokens, - } - inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier) - if err != nil { - return nil, err - } - - // 范围外部分:× extraMultiplier 计费 - outRangeTokens := UsageTokens{ - InputTokens: outRangeInputTokens, - CacheReadTokens: outRangeCacheTokens, - } - outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier) - if err != nil { - return inRangeCost, fmt.Errorf("out-range cost: %w", err) - } - - // 合并成本 - return &CostBreakdown{ - InputCost: inRangeCost.InputCost + outRangeCost.InputCost, - ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost, - OutputCost: inRangeCost.OutputCost, - ImageOutputCost: inRangeCost.ImageOutputCost, - CacheCreationCost: inRangeCost.CacheCreationCost, - CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost, - TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost, - ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost, - LongContextBillingApplied: outRangeCost.ActualCost > 0, - }, nil -} - // ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配) func (s *BillingService) ListSupportedModels() []string { models := make([]string, 0) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 99b8ebd8d9..8bb351c559 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -33,6 +33,11 @@ func newTestBillingService() *BillingService { return NewBillingService(&config.Config{}, nil) } +func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService { + t.Helper() + return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) +} + func TestCalculateCost_BasicComputation(t *testing.T) { svc := newTestBillingService() @@ -200,9 +205,21 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) { require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12) require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) + // 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。 + require.Zero(t, pricing.LongContextInputThreshold) + require.Zero(t, pricing.LongContextInputMultiplier) + require.Zero(t, pricing.LongContextOutputMultiplier) +} + +func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) { + svc := newTestBillingServiceWithOpenAILadderCatalog(t) + + pricing, err := svc.GetModelPricing("gpt-5.4") + require.NoError(t, err) require.Equal(t, 272000, pricing.LongContextInputThreshold) require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于") } func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { @@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { cacheRead float64 longContext int }{ - {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000}, - {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0}, + {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0}, {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, } @@ -247,7 +264,7 @@ func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) { } func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) tokens := UsageTokens{ InputTokens: 300000, @@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t } func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) cost, err := svc.calculateCostWithServiceTierPolicy( "gpt-5.4-2026-03-05", @@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t } func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) tokens := UsageTokens{ InputTokens: 300000, @@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) { // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。 // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。 func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值 tokens := UsageTokens{ @@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes // 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。 func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文 tokens := UsageTokens{ @@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test // 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此 // 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。 func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值 tokens := UsageTokens{ @@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t // 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。 func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文 tokens := UsageTokens{ @@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) { require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15) require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)") } -func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{ - InputTokens: 50000, - OutputTokens: 1000, - CacheReadTokens: 100000, - } - // 总输入 150k < 200k 阈值,应走正常计费 - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) { - svc := newTestBillingService() - - // 缓存 210k + 输入 10k = 220k > 200k 阈值 - // 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入 - tokens := UsageTokens{ - InputTokens: 10000, - OutputTokens: 1000, - CacheReadTokens: 210000, - } - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - // 范围内:200k cache + 0 input + 1k output - inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{ - InputTokens: 0, - OutputTokens: 1000, - CacheReadTokens: 200000, - }, 1.0) - - // 范围外:10k cache + 10k input,倍率 2.0 - outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{ - InputTokens: 10000, - CacheReadTokens: 10000, - }, 2.0) - - require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) { - svc := newTestBillingService() - - // 缓存 100k + 输入 150k = 250k > 200k 阈值 - // 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入 - tokens := UsageTokens{ - InputTokens: 150000, - OutputTokens: 1000, - CacheReadTokens: 100000, - } - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - require.True(t, cost.ActualCost > 0, "费用应大于 0") - - // 正常费用不含长上下文 - normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用") -} - -func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) { - svc := newTestBillingService() - tokens := UsageTokens{InputTokens: 300000} - - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0) - - require.NoError(t, err) - require.Zero(t, cost.ActualCost) - require.False(t, cost.LongContextBillingApplied) -} - -func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0} - - // threshold <= 0 应禁用长上下文计费 - cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0) - require.NoError(t, err) - - cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{InputTokens: 300000} - - // extraMultiplier <= 1 应禁用长上下文计费 - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0) - require.NoError(t, err) - - normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10) -} - func TestCalculateImageCost(t *testing.T) { svc := newTestBillingService() @@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) { require.Contains(t, err.Error(), "not initialized") } -func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { - // 使用空的 fallback prices 让 GetModelPricing 失败 - svc := &BillingService{ - cfg: &config.Config{}, - fallbackPrices: make(map[string]*ModelPricing), - } - - tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0} - _, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0) - require.Error(t, err) - require.Contains(t, err.Error(), "pricing not found") -} - func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index efd8aa6606..6a59be16a5 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -5,33 +5,6 @@ import ( "time" ) -// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。 -// -// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier, -// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。 -// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用; -// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。 -type LegacyLongContextRule struct { - Threshold int - Multiplier float64 -} - -const ( - geminiLegacyLongContextThreshold = 200000 - geminiLegacyLongContextMultiplier = 2.0 -) - -// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。 -func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule { - if platform == PlatformGemini { - return &LegacyLongContextRule{ - Threshold: geminiLegacyLongContextThreshold, - Multiplier: geminiLegacyLongContextMultiplier, - } - } - return nil -} - // TokenCostRequest 通用网关 token 计费请求。 type TokenCostRequest struct { Ctx context.Context @@ -44,27 +17,12 @@ type TokenCostRequest struct { Resolver *ModelPricingResolver // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 Resolved *ResolvedPricing - // LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。 - LegacyLongContext *LegacyLongContextRule -} - -// legacyLongContextApplies 判定请求是否走旧长上下文规则: -// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。 -func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool { - if rule == nil || rule.Threshold <= 0 { - return false - } - if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { - return false - } - return group == nil || group.LongContextPricingEnabled } // CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用: -// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中); -// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费; -// 3. 否则有解析器与分组 → 统一计费(内置目录定价); -// 4. 否则按模型目录直接计费。 +// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费 +// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动); +// 2. 否则按模型目录直接计费。 // // 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。 func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) { @@ -72,10 +30,6 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } - if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) { - return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier, - req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier) - } if req.Resolver != nil && req.Group != nil { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 8392b8cf49..ccb5b73642 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -28,6 +28,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo return bs, NewModelPricingResolver(cs, bs) } +// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。 func geminiCatalogStub() *PricingService { return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ "gemini-2.5-pro": { @@ -39,22 +40,29 @@ func geminiCatalogStub() *PricingService { }) } -func TestLegacyLongContextRule_OnlyGemini(t *testing.T) { - bs := NewBillingService(&config.Config{}, nil) - rule := bs.LegacyLongContextRule(PlatformGemini) - require.NotNil(t, rule) - require.Equal(t, 200000, rule.Threshold) - require.InDelta(t, 2.0, rule.Multiplier, 1e-12) - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} { - require.Nil(t, bs.LegacyLongContextRule(platform), platform) - } +// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价, +// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。 +const geminiLadderCatalogJSON = `{ + "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 6.25e-07} +}` + +func geminiLadderCatalogStub(t *testing.T) *PricingService { + t.Helper() + return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON) } -func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) { +// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致, +// 超阈值整单按渠道价 × 目录倍率。 +func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) { bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6), - }}, geminiCatalogStub()) + }}, geminiLadderCatalogStub(t)) group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} gid := group.ID resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) @@ -63,23 +71,41 @@ func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testin tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini), + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) - want, err := bs.CalculateCostUnified(CostInput{ - Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, - RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, - }) - require.NoError(t, err) - require.Equal(t, want, got) - // 渠道平价 10e-6 × 300K,旧规则未叠加 - require.InDelta(t, 3.0, got.InputCost, 1e-9) + require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9) + require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9) + require.True(t, got.LongContextBillingApplied) } -func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) { - bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) +// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。 +func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, + Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}}, + }}, geminiLadderCatalogStub(t)) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceChannel, resolved.Source) + require.NotEmpty(t, resolved.Intervals) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9) + require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9) + require.False(t, got.LongContextBillingApplied) +} + +// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。 +func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t)) tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} - rule := bs.LegacyLongContextRule(PlatformGemini) for _, enabled := range []bool{true, false} { group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled} @@ -89,32 +115,39 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: rule, + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) if enabled { - want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier) - require.NoError(t, err) - require.Equal(t, want, got) - // 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。 - // 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。 - require.InDelta(t, 0.51, got.ActualCost, 1e-9) + // 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015 + require.InDelta(t, 0.765, got.ActualCost, 1e-9) require.True(t, got.LongContextBillingApplied) } else { - want, err := bs.CalculateCostUnified(CostInput{ - Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, - RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, - }) - require.NoError(t, err) - require.Equal(t, want, got) require.InDelta(t, 0.385, got.ActualCost, 1e-9) require.False(t, got.LongContextBillingApplied) } } } +// 目录条目没有阶梯字段时,开关开启也不产生阶梯。 +func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 0.385, got.ActualCost, 1e-9) + require.False(t, got.LongContextBillingApplied) +} + func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) { - bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil) + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true} gid := group.ID tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 0be0477029..c14bf9c27e 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi require.Equal(t, 1, quotaSvc.quotaCalls) } -func TestGatewayServiceRecordUsageWithLongContext_BillingUsesDetachedContext(t *testing.T) { - usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded} - userRepo := &openAIRecordUsageUserRepoStub{} - subRepo := &openAIRecordUsageSubRepoStub{} - quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{} - svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) - - reqCtx, cancel := context.WithCancel(context.Background()) - cancel() - - err := svc.RecordUsageWithLongContext(reqCtx, &RecordUsageLongContextInput{ - Result: &ForwardResult{ - RequestID: "gateway_long_context_detached_ctx", - Usage: ClaudeUsage{ - InputTokens: 12, - OutputTokens: 8, - }, - Model: "claude-sonnet-4", - Duration: time.Second, - }, - APIKey: &APIKey{ - ID: 502, - Quota: 100, - }, - User: &User{ID: 602}, - Account: &Account{ID: 702}, - LongContextThreshold: 200000, - LongContextMultiplier: 2, - APIKeyService: quotaSvc, - }) - - require.NoError(t, err) - require.Equal(t, 1, usageRepo.calls) - require.Equal(t, 1, userRepo.deductCalls) - require.NoError(t, userRepo.lastCtxErr) - require.Equal(t, 1, quotaSvc.quotaCalls) - require.NoError(t, quotaSvc.lastQuotaCtxErr) -} - func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 4d47720f05..069758baae 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage } } -// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。 -type recordUsageOpts struct { - // 长上下文计费(仅 Gemini 路径需要) - LongContextThreshold int - LongContextMultiplier float64 -} - // RecordUsage 记录使用量并扣费(或更新订阅用量) func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error { return s.recordUsageCore(ctx, &recordUsageCoreInput{ @@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu APIKeyService: input.APIKeyService, QuotaPlatform: input.QuotaPlatform, ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{}) -} - -// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费) -type RecordUsageLongContextInput struct { - Result *ForwardResult - APIKey *APIKey - User *User - Account *Account - Subscription *UserSubscription // 可选:订阅信息 - PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义 - InboundEndpoint string // 入站端点(客户端请求路径) - UpstreamEndpoint string // 上游端点(标准化后的上游路径) - UserAgent string // 请求的 User-Agent - IPAddress string // 请求的客户端 IP 地址 - SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联 - RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 - LongContextThreshold int // 长上下文阈值(如 200000) - LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) - ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) - APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选) - QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) - - ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) -} - -// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini) -func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error { - return s.recordUsageCore(ctx, &recordUsageCoreInput{ - Result: input.Result, - APIKey: input.APIKey, - User: input.User, - Account: input.Account, - Subscription: input.Subscription, - PricingAt: input.PricingAt, - InboundEndpoint: input.InboundEndpoint, - UpstreamEndpoint: input.UpstreamEndpoint, - UserAgent: input.UserAgent, - IPAddress: input.IPAddress, - SessionID: input.SessionID, - RequestPayloadHash: input.RequestPayloadHash, - ForceCacheBilling: input.ForceCacheBilling, - APIKeyService: input.APIKeyService, - QuotaPlatform: input.QuotaPlatform, - ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{ - LongContextThreshold: input.LongContextThreshold, - LongContextMultiplier: input.LongContextMultiplier, }) } @@ -767,9 +712,8 @@ func logResponseModelBillingApplied(component string, account *Account, requestI slog.Info("billing.response_model_applied", attrs...) } -// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。 -// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。 -func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error { +// recordUsageCore 是 RecordUsage 的核心实现。 +func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error { result := input.Result apiKey := input.APIKey user := input.User @@ -839,7 +783,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts) + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 @@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts) + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt) baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, @@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 创建使用日志 accountRateMultiplier := account.BillingRateMultiplier() usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, - requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) + requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost) // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) if apiKey.GroupID != nil { @@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage return nil } -// calculateRecordUsageCost 根据请求类型和选项计算费用。 +// calculateRecordUsageCost 根据请求类型计算费用。 func (s *GatewayService) calculateRecordUsageCost( ctx context.Context, result *ForwardResult, @@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost( multiplier float64, imageMultiplier float64, pricingAt time.Time, - opts *recordUsageOpts, ) *CostBreakdown { // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 if result.ImageCount > 0 { if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) } return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } @@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost( } // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 - tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) + tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) if result.SearchCount > 0 { price := groupSearchPricePer1kFromAPIKey(apiKey) if price != nil && *price == 0 { @@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost( return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } -// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价) +// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价) // 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。 func (s *GatewayService) calculateTokenCost( ctx context.Context, @@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost( billingModel string, multiplier float64, pricingAt time.Time, - opts *recordUsageOpts, ) *CostBreakdown { tokens := UsageTokens{ InputTokens: result.Usage.InputTokens, @@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost( gid := apiKey.Group.ID resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) } - var legacy *LegacyLongContextRule - if opts.LongContextThreshold > 0 { - legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier} - } 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, - LegacyLongContext: legacy, + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) @@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost( return cost } -// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。 -func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule { - if s == nil || s.billingService == nil { - return nil - } - return s.billingService.LegacyLongContextRule(platform) -} - // buildRecordUsageLog 构建使用日志并设置计费模式。 func (s *GatewayService) buildRecordUsageLog( ctx context.Context, @@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog( billingType int8, cacheTTLOverridden bool, cost *CostBreakdown, - opts *recordUsageOpts, ) *UsageLog { durationMs := int(result.Duration.Milliseconds()) requestID := resolveUsageBillingRequestID(ctx, result.RequestID) diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index d54aca70ad..4a588ecb0f 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -362,7 +362,8 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true}, {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false}, } - svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil) + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, + newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) @@ -395,26 +396,27 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { } } -func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) { +func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) { channels := []Channel{{ ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}}, }} groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}} - svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub()) + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, + newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)) out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) m := out[0].Models[0] - require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis) + require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis) require.Len(t, m.Pricing.Intervals, 2) require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel) require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel) require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15) - require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) - // 官方参考不套用站内旧规则 + require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) + // 官方参考价与实付同源:都来自目录数据的阶梯字段 require.NotNil(t, m.OfficialPricing) - require.Empty(t, m.OfficialPricing.Intervals) + require.Len(t, m.OfficialPricing.Intervals, 2) } func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b65f55c71c..459e668d95 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1182,11 +1182,21 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGrou require.Equal(t, 1, userRepo.deductCalls) } +// swapInOpenAILadderCatalog 给测试服务换上带 above_272k 阶梯字段的目录: +// 静态兜底价已不带阶梯,长上下文相关测试需要目录数据。 +func swapInOpenAILadderCatalog(t *testing.T, svc *OpenAIGatewayService) { + t.Helper() + cfg := &config.Config{} + cfg.Default.RateMultiplier = 1.1 + svc.billingService = NewBillingService(cfg, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) +} + func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ @@ -1227,6 +1237,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group on account off", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true), @@ -1242,6 +1253,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group off account on", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false), @@ -1260,6 +1272,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group on account on", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true), @@ -1352,6 +1365,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett &openAIRecordUsageSubRepoStub{}, nil, ) + swapInOpenAILadderCatalog(t, svc) svc.accountRepo = accountRepo parentID := int64(4016) @@ -2784,7 +2798,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun 0.15, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2824,7 +2837,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2857,7 +2869,6 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2947,7 +2958,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 6ae24dd39e..a1abcbad16 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -10,6 +11,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -22,18 +24,19 @@ import ( ) var ( - openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`) - openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`) + openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`) + openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`) + // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名 + // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。 + // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。 + aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ - InputCostPerToken: 2.5e-06, // $2.5 per MTok - OutputCostPerToken: 1.5e-05, // $15 per MTok - CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok - LongContextInputTokenThreshold: 272000, - LongContextInputCostMultiplier: 2.0, - LongContextOutputCostMultiplier: 1.5, - LiteLLMProvider: "openai", - Mode: "chat", - SupportsPromptCaching: true, + InputCostPerToken: 2.5e-06, // $2.5 per MTok + OutputCostPerToken: 1.5e-05, // $15 per MTok + CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, @@ -44,9 +47,6 @@ var ( CacheCreationInputTokenCostPriority: 1.25e-05, CacheReadInputTokenCost: 5e-07, CacheReadInputTokenCostPriority: 1e-06, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -61,9 +61,6 @@ var ( CacheCreationInputTokenCostPriority: 5e-06, CacheReadInputTokenCost: 2e-07, CacheReadInputTokenCostPriority: 4e-07, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -78,9 +75,6 @@ var ( CacheCreationInputTokenCostPriority: 5e-07, CacheReadInputTokenCost: 2e-08, CacheReadInputTokenCostPriority: 4e-08, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -408,6 +402,7 @@ func (s *PricingService) downloadPricingData() error { // 更新内存数据 s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, data) s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash @@ -500,6 +495,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel pricing.InputCostPerImageToken = *entry.InputCostPerImageToken } + hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil || + entry.LongContextInputCostMultiplier != nil || + entry.LongContextOutputCostMultiplier != nil + if !hasExplicitLongContext { + deriveLongContextFromAboveTierFields(rawEntry, pricing) + } + result[modelName] = pricing } @@ -514,6 +516,81 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel return result, nil } +// deriveLongContextFromAboveTierFields 把 LiteLLM 目录的 *_above_XXXk_tokens 绝对价字段 +// 折算成 long_context_* 阈值+倍率(sub2api 计费机制的内部表达):阈值取自字段名, +// 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由 +// 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该 +// 模型的阶梯。多个阈值并存时取最小阈值。 +// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算; +// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率, +// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。 +func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) { + if pricing == nil || + pricing.LongContextInputTokenThreshold > 0 || + pricing.LongContextInputCostMultiplier > 0 || + pricing.LongContextOutputCostMultiplier > 0 { + return + } + if !bytes.Contains(rawEntry, []byte("_above_")) { + return + } + var fields map[string]any + if err := json.Unmarshal(rawEntry, &fields); err != nil { + return + } + type tierPrices struct{ input, output float64 } + tiers := make(map[int]*tierPrices) + for key, value := range fields { + m := aboveTierPricePattern.FindStringSubmatch(key) + if m == nil { + continue + } + price, ok := value.(float64) + if !ok || price <= 0 { + continue + } + thousands, err := strconv.Atoi(m[2]) + if err != nil || thousands <= 0 { + continue + } + threshold := thousands * 1000 + tp := tiers[threshold] + if tp == nil { + tp = &tierPrices{} + tiers[threshold] = tp + } + if m[1] == "input" { + tp.input = price + } else { + tp.output = price + } + } + if len(tiers) == 0 { + return + } + threshold := 0 + for t := range tiers { + if threshold == 0 || t < threshold { + threshold = t + } + } + tp := tiers[threshold] + inputMultiplier, outputMultiplier := 1.0, 1.0 + if tp.input > 0 && pricing.InputCostPerToken > 0 { + inputMultiplier = tp.input / pricing.InputCostPerToken + } + if tp.output > 0 && pricing.OutputCostPerToken > 0 { + outputMultiplier = tp.output / pricing.OutputCostPerToken + } + // above 价不高于基础价时视为无附加费,不生成阶梯。 + if inputMultiplier <= 1 && outputMultiplier <= 1 { + return + } + pricing.LongContextInputTokenThreshold = threshold + pricing.LongContextInputCostMultiplier = inputMultiplier + pricing.LongContextOutputCostMultiplier = outputMultiplier +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -533,6 +610,7 @@ func (s *PricingService) loadPricingData(filePath string) error { hashStr := hex.EncodeToString(hash[:]) s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, pricingData) s.pricingData = pricingData s.localHash = hashStr @@ -579,6 +657,34 @@ func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelP return data } +// warnDroppedLongContextLadders 对比新旧目录数据:原本带长上下文阶梯的条目在新数据里 +// 丢失阈值时打 WARN。阶梯已完全数据驱动(无代码兜底),数据源一次误提交就会把阶梯 +// 静默变成基础价少收(07-14~08-21 漏收事故的形态),这里是唯一的哨兵。 +// 调用方需持有 s.mu 写锁。 +func warnDroppedLongContextLadders(old, next map[string]*LiteLLMModelPricing) { + if len(old) == 0 { + return + } + var dropped []string + for name, prev := range old { + if prev == nil || prev.LongContextInputTokenThreshold <= 0 { + continue + } + if cur, ok := next[name]; ok && (cur == nil || cur.LongContextInputTokenThreshold <= 0) { + dropped = append(dropped, name) + } + } + if len(dropped) == 0 { + return + } + sort.Strings(dropped) + total := len(dropped) + if total > 20 { + dropped = append(dropped[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Long-context ladder dropped for %d model(s) after reload: %s (verify catalog/override data if unintended)", total, strings.Join(dropped, ", ")) +} + // useFallbackPricing 使用回退价格文件 func (s *PricingService) useFallbackPricing() error { fallbackFile := s.cfg.Pricing.FallbackFile diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 1a933aec10..96cc63ba55 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -109,9 +109,8 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing. require.NoError(t, err) require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12) - require.Equal(t, 272000, pricing.LongContextInputThreshold) - require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) - require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + // 阶梯由目录数据驱动:条目无 above/long_context 字段时不再由策略强补。 + require.Zero(t, pricing.LongContextInputThreshold) tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100} standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "") @@ -129,6 +128,32 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing. } } +// gpt56LadderCatalogJSON 三个 5.6 模型的目录条目:above_272k 绝对价 + priority 平价, +// cache_write 缺失由策略按 1.25 倍输入价补齐。 +const gpt56LadderCatalogJSON = `{ + "gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1e-05, + "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 6e-05, + "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 1e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "gpt-5.6-terra": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "input_cost_per_token_priority": 4e-06, + "output_cost_per_token": 1.2e-05, "output_cost_per_token_priority": 2.4e-05, + "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token_above_272k_tokens": 4e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "cache_read_input_token_cost_above_272k_tokens": 4e-07}, + "gpt-5.6-luna": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-07, "input_cost_per_token_priority": 4e-07, + "output_cost_per_token": 1.2e-06, "output_cost_per_token_priority": 2.4e-06, + "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_priority": 4e-08, + "input_cost_per_token_above_272k_tokens": 4e-07, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "cache_read_input_token_cost_above_272k_tokens": 4e-08} +}` + func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) { models := []struct { name string @@ -157,7 +182,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi for _, model := range models { for _, tier := range tiers { t.Run(model.name+"/"+tier.name, func(t *testing.T) { - svc := NewBillingService(&config.Config{}, nil) + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON)) serviceTier := "" if tier.name != "standard" { serviceTier = tier.name @@ -174,7 +199,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi } func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) { - svc := NewBillingService(&config.Config{}, nil) + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON)) tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10} cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1) @@ -284,9 +309,8 @@ func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cach require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12) require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12) - require.Equal(t, 272000, pricing.LongContextInputThreshold) - require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) - require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + // 静态兜底只兜基础价;阶梯由目录数据(above_272k 折算或显式字段)驱动。 + require.Zero(t, pricing.LongContextInputThreshold) } func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) { @@ -436,9 +460,8 @@ func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T) require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12) require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12) - require.Equal(t, 272000, got.LongContextInputTokenThreshold) - require.InDelta(t, 2.0, got.LongContextInputCostMultiplier, 1e-12) - require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12) + // 静态兜底只兜基础价,不携带长上下文阶梯(阶梯由目录数据驱动)。 + require.Zero(t, got.LongContextInputTokenThreshold) } func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { @@ -719,3 +742,154 @@ func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) { require.NotNil(t, got) require.Empty(t, got) } + +// --- above_XXXk 绝对价字段折算为阈值+倍率 --- + +func TestParsePricingData_DerivesLongContextFromAboveTierFields(t *testing.T) { + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gpt-above": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "output_cost_per_token_above_272k_tokens_flex": 2.25e-05}, + "gemini-above": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05}, + "explicit-wins": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "long_context_input_cost_multiplier": 1, + "long_context_output_cost_multiplier": 1, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05}, + "no-surcharge": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "cache-only-above": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "multi-threshold": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06, + "input_cost_per_token_above_128k_tokens": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06} + }`)) + require.NoError(t, err) + + openai := data["gpt-above"] + require.Equal(t, 272000, openai.LongContextInputTokenThreshold, "阈值取自字段名(_flex 变体不参与)") + require.InDelta(t, 2.0, openai.LongContextInputCostMultiplier, 1e-12) + require.InDelta(t, 1.5, openai.LongContextOutputCostMultiplier, 1e-12) + + gemini := data["gemini-above"] + require.Equal(t, 200000, gemini.LongContextInputTokenThreshold) + require.InDelta(t, 2.0, gemini.LongContextInputCostMultiplier, 1e-12) + require.InDelta(t, 1.5, gemini.LongContextOutputCostMultiplier, 1e-12) + + explicit := data["explicit-wins"] + require.Zero(t, explicit.LongContextInputTokenThreshold, "显式 long_context_* 字段优先,不做折算") + require.InDelta(t, 1.0, explicit.LongContextInputCostMultiplier, 1e-12) + + require.Zero(t, data["no-surcharge"].LongContextInputTokenThreshold, "above 价不高于基础价视为无附加费") + require.Zero(t, data["cache-only-above"].LongContextInputTokenThreshold, "仅 cache 侧 above 字段不构成阶梯") + require.Equal(t, 128000, data["multi-threshold"].LongContextInputTokenThreshold, "多阈值取最小") +} + +func TestGetModelPricing_XAIThresholdInclusive(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "grok-4.5": {"litellm_provider": "xai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05} + }`)) + pricing, err := svc.GetModelPricing("grok-4.5") + require.NoError(t, err) + require.Equal(t, 200000, pricing.LongContextInputThreshold) + require.True(t, pricing.LongContextThresholdInclusive, "xAI 阈值语义为达到即进高档") +} + +// F3:显式 long_context 字段以"字段存在"为准——显式 0 也能压住 above 折算,关闭阶梯。 +func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) { + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gpt-5.5": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "long_context_input_token_threshold": 0, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05} + }`)) + require.NoError(t, err) + require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold) + require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier) +} + +// F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。 +func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} + + t.Run("only input multiplier", func(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "partial-in": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0} + }`)) + cost, err := svc.CalculateCost("partial-in", tokens, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*2e-6*2, cost.InputCost, 1e-10) + require.InDelta(t, 1000*1e-5, cost.OutputCost, 1e-10, "缺失的 output 倍率按 1 计,不得为 0") + require.InDelta(t, 10000*2e-7*2, cost.CacheReadCost, 1e-10) + }) + + t.Run("only output multiplier", func(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "partial-out": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_output_cost_multiplier": 1.5} + }`)) + cost, err := svc.CalculateCost("partial-out", tokens, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*2e-6, cost.InputCost, 1e-10, "缺失的 input 倍率按 1 计,不得为 0") + require.InDelta(t, 1000*1e-5*1.5, cost.OutputCost, 1e-10) + require.InDelta(t, 10000*2e-7, cost.CacheReadCost, 1e-10, "cache_read 跟随 input 倍率,同样按 1 计") + }) +} + +// 行为声明:目录带 above_200k 的 Claude sonnet 条目同样获得数据驱动的整单阶梯 +// (与 Anthropic 官方 1M 长上下文定价一致),受分组长上下文开关约束。 +func TestCalculateCost_ClaudeSonnetCatalogLadderIsDataDriven(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "claude-sonnet-4-5": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_read_input_token_cost_above_200k_tokens": 6e-07} + }`)) + + pricing, err := svc.GetModelPricing("claude-sonnet-4-5") + require.NoError(t, err) + require.Equal(t, 200000, pricing.LongContextInputThreshold) + require.False(t, pricing.LongContextThresholdInclusive, "anthropic 为严格大于") + + over := UsageTokens{InputTokens: 250000, OutputTokens: 1000} + cost, err := svc.CalculateCost("claude-sonnet-4-5", over, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 250000*3e-6*2, cost.InputCost, 1e-10) + require.InDelta(t, 1000*1.5e-5*1.5, cost.OutputCost, 1e-10) + + under := UsageTokens{InputTokens: 200000, OutputTokens: 1000} + cost, err = svc.CalculateCost("claude-sonnet-4-5", under, 1.0) + require.NoError(t, err) + require.False(t, cost.LongContextBillingApplied, "恰好 200000 不进高档(严格大于)") +} diff --git a/backend/internal/service/pricing_stub_helpers_test.go b/backend/internal/service/pricing_stub_helpers_test.go new file mode 100644 index 0000000000..66bef43797 --- /dev/null +++ b/backend/internal/service/pricing_stub_helpers_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// openAILadderCatalogJSON 镜像真实同步目录的形态:长上下文用 above_272k 绝对价字段表达, +// 由解析层折算成阈值+倍率。静态 Go 兜底价不再携带阶梯,阶梯计费一律走目录数据。 +const openAILadderCatalogJSON = `{ + "gpt-5.4": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 2.5e-07, "cache_creation_input_token_cost": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "cache_read_input_token_cost_above_272k_tokens": 5e-07}, + "gpt-5.5-pro": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 3e-05, "output_cost_per_token": 1.8e-04, + "input_cost_per_token_above_272k_tokens": 6e-05, + "output_cost_per_token_above_272k_tokens": 2.7e-04} +}` + +// newStubPricingServiceFromJSON 用与生产一致的解析路径(含 above_XXXk 阶梯折算) +// 从原始目录 JSON 构造目录 stub。无 build tag:带 unit 标签与默认构建的测试文件都会用到。 +func newStubPricingServiceFromJSON(t *testing.T, body string) *PricingService { + t.Helper() + s := &PricingService{} + data, err := s.parsePricingData([]byte(body)) + require.NoError(t, err) + s.pricingData = data + return s +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 033cb29908..bd31c3421e 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1054,8 +1054,8 @@ plugins: # 定价数据源(可选) # ============================================================================= pricing: - # URL to fetch model pricing data (default: pinned model-price-repo commit) - # 获取模型定价数据的 URL(默认:固定 commit 的 model-price-repo) + # URL to fetch model pricing data (default: model-price-repo main branch) + # 获取模型定价数据的 URL(默认:model-price-repo main 分支) remote_url: "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/refs/heads/main//model_prices_and_context_window.json" # Hash verification URL (optional) # 哈希校验 URL(可选) From 593fc93659a94b0097c35dc792ca05c6dce73dda Mon Sep 17 00:00:00 2001 From: feeeei Date: Sun, 30 Aug 2026 16:55:28 +0800 Subject: [PATCH 14/56] =?UTF-8?q?feat(pricing):=20=E6=96=B0=E5=A2=9E=20ove?= =?UTF-8?q?rride=5Ffile=20=E4=BB=B7=E6=A0=BC=E7=9B=AE=E5=BD=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 pricing.override_file 配置(默认关闭):稀疏补丁文件按 JSON 字段 浅合并覆盖目录/回退数据,作为最高优先级数据源,用于本地自定义官方 目录条目(如显式 long_context_input_token_threshold=0 关闭某模型阶梯) 而无需自建价格镜像: - applyPricingOverrides 挂在 parsePricingData 解析入口,只修补已存在 条目(补丁字段覆盖同名字段,值为 null 删除字段);目录、回退与灾备 路径都经该入口,覆盖语义三处一致 - mergeOverrideOnlyModels 在回退合并后把目录/回退都没有的模型作为 独立条目并入(复用主解析路径,须自带价格字段通过有效性过滤); 不在主解析时抢先建条目,避免纯补丁挡住回退完整条目、其余分项价 静默变 0 - 最终未生效的条目(模型名拼错、纯补丁落在不存在的模型上)打 WARN 哨兵;override 文件缺失/损坏仅跳过合并,不影响目录加载 - 生效时机为重启或下次目录下载,与 fallback_file 一致;fallback_file 只补缺失模型的语义不变 --- backend/internal/config/config.go | 3 + backend/internal/service/pricing_service.go | 122 +++++++++++ .../service/pricing_service_override_test.go | 201 ++++++++++++++++++ deploy/config.example.yaml | 8 + 4 files changed, 334 insertions(+) create mode 100644 backend/internal/service/pricing_service_override_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 390c1bba18..5d04ab5306 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -667,6 +667,8 @@ type PricingConfig struct { DataDir string `mapstructure:"data_dir"` // 回退文件路径 FallbackFile string `mapstructure:"fallback_file"` + // 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高 + OverrideFile string `mapstructure:"override_file"` // 更新间隔(小时) UpdateIntervalHours int `mapstructure:"update_interval_hours"` // 哈希校验间隔(分钟) @@ -2284,6 +2286,7 @@ func setDefaults() { viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256") viper.SetDefault("pricing.data_dir", "./data") viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json") + viper.SetDefault("pricing.override_file", "") viper.SetDefault("pricing.update_interval_hours", 24) viper.SetDefault("pricing.hash_check_interval_minutes", 10) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index a1abcbad16..f3b9339cdc 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -382,6 +383,7 @@ func (s *PricingService) downloadPricingData() error { return fmt.Errorf("parse pricing data: %w", err) } data = s.mergeFallbackPricingData(data) + data = s.mergeOverrideOnlyModels(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() @@ -419,6 +421,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if err := json.Unmarshal(body, &rawData); err != nil { return nil, fmt.Errorf("parse raw JSON: %w", err) } + rawData = s.applyPricingOverrides(rawData) result := make(map[string]*LiteLLMModelPricing) skipped := 0 @@ -591,6 +594,124 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit pricing.LongContextOutputCostMultiplier = outputMultiplier } +// applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退 +// 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补 +// 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后 +// 统一并入——若在此处抢先建条目,纯 override 条目会挡住回退文件中同名完整条目的合并。 +func (s *PricingService) applyPricingOverrides(rawData map[string]json.RawMessage) map[string]json.RawMessage { + overrides := s.loadPricingOverrideEntries() + if len(overrides) == 0 { + return rawData + } + for name, patch := range overrides { + base, ok := rawData[name] + if !ok { + continue + } + merged, valid := mergePricingOverrideEntry(base, patch) + if !valid { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override entry %q skipped: not a JSON object", name) + continue + } + rawData[name] = merged + } + return rawData +} + +// loadPricingOverrideEntries 读取 override 文件的原始条目。未配置返回 nil; +// 读取或解析失败打日志并跳过,不影响目录加载。 +func (s *PricingService) loadPricingOverrideEntries() map[string]json.RawMessage { + if s == nil || s.cfg == nil { + return nil + } + path := strings.TrimSpace(s.cfg.Pricing.OverrideFile) + if path == "" { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err) + return nil + } + var entries map[string]json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err) + return nil + } + return entries +} + +// mergePricingOverrideEntry 在 JSON 字段层浅合并:patch 字段覆盖 base 同名字段, +// 值为 null 的 patch 字段从结果中删除,base 为空时结果即 patch 本身。 +// patch 不是 JSON 对象时返回 ok=false。 +func mergePricingOverrideEntry(base, patch json.RawMessage) (json.RawMessage, bool) { + var patchFields map[string]any + if err := json.Unmarshal(patch, &patchFields); err != nil || patchFields == nil { + return nil, false + } + merged := make(map[string]any, len(patchFields)) + if len(base) > 0 { + // base 非对象时忽略,仅以 patch 为准。 + if err := json.Unmarshal(base, &merged); err != nil { + merged = make(map[string]any, len(patchFields)) + } + } + for k, v := range patchFields { + if v == nil { + delete(merged, k) + continue + } + merged[k] = v + } + out, err := json.Marshal(merged) + if err != nil { + return nil, false + } + return out, true +} + +// mergeOverrideOnlyModels 把 override 中目录/回退两层都不存在的模型作为独立条目并入 +// (条目须自带价格字段才能通过有效性过滤),并对最终仍未生效的条目打 WARN: +// 模型名拼错、或纯补丁条目落在不存在的模型上时会被静默丢弃,让"已改价/已关阶梯" +// 的运营预期与实际计费脱节,这里是唯一的哨兵。 +func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing { + overrides := s.loadPricingOverrideEntries() + if len(overrides) == 0 { + return data + } + if data == nil { + data = make(map[string]*LiteLLMModelPricing) + } + leftover := make(map[string]json.RawMessage) + for name, patch := range overrides { + if _, ok := data[name]; !ok { + leftover[name] = patch + } + } + if len(leftover) == 0 { + return data + } + // 复用主解析路径(含 above_XXXk 折算与有效性过滤);applyPricingOverrides + // 对已存在条目做的自我修补是幂等的,不会二次改值。 + if body, err := json.Marshal(leftover); err == nil { + if parsed, err := s.parsePricingData(body); err == nil { + maps.Copy(data, parsed) + } + } + var missing []string + for name := range leftover { + if _, ok := data[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return data + } + sort.Strings(missing) + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override had no effect for %d model(s): %s (unknown model name, or patch-only entry without price fields)", len(missing), strings.Join(missing, ", ")) + return data +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -604,6 +725,7 @@ func (s *PricingService) loadPricingData(filePath string) error { return fmt.Errorf("parse pricing data: %w", err) } pricingData = s.mergeFallbackPricingData(pricingData) + pricingData = s.mergeOverrideOnlyModels(pricingData) // 计算哈希 hash := sha256.Sum256(data) diff --git a/backend/internal/service/pricing_service_override_test.go b/backend/internal/service/pricing_service_override_test.go new file mode 100644 index 0000000000..ab0dcf205c --- /dev/null +++ b/backend/internal/service/pricing_service_override_test.go @@ -0,0 +1,201 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// gpt55OverrideCatalogJSON 镜像真实目录形态:长上下文以 above_272k 绝对价字段表达。 +const gpt55OverrideCatalogJSON = `{ + "gpt-5.5": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1.25e-05, + "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 7.5e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "gpt-5.4": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05} +}` + +func newPricingServiceWithOverride(t *testing.T, overrideJSON string) *PricingService { + t.Helper() + path := filepath.Join(t.TempDir(), "overrides.json") + require.NoError(t, os.WriteFile(path, []byte(overrideJSON), 0644)) + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = path + return svc +} + +// override 的旗舰用例:显式 threshold=0 压住 above 折算,把目录条目的阶梯关成标准价。 +func TestPricingOverride_ExplicitZeroThresholdDisablesCatalogLadder(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {"long_context_input_token_threshold": 0}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + + patched := data["gpt-5.5"] + require.NotNil(t, patched) + require.Zero(t, patched.LongContextInputTokenThreshold) + require.Zero(t, patched.LongContextInputCostMultiplier) + require.InDelta(t, 5e-6, patched.InputCostPerToken, 1e-12, "补丁不得影响基础价") + require.InDelta(t, 3e-5, patched.OutputCostPerToken, 1e-12) + require.Equal(t, 272000, data["gpt-5.4"].LongContextInputTokenThreshold, "未覆盖的模型保持目录阶梯") + + svc.pricingData = data + billing := NewBillingService(&config.Config{}, svc) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} + cost, err := billing.CalculateCost("gpt-5.5", tokens, 1) + require.NoError(t, err) + require.False(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*5e-6, cost.InputCost, 1e-10) + require.InDelta(t, 1000*3e-5, cost.OutputCost, 1e-10) + require.InDelta(t, 10000*5e-7, cost.CacheReadCost, 1e-10) +} + +func TestPricingOverride_FieldLevelMergeKeepsOtherFields(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.4": {"input_cost_per_token": 3e-06}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + + patched := data["gpt-5.4"] + require.InDelta(t, 3e-6, patched.InputCostPerToken, 1e-12) + require.InDelta(t, 1.5e-5, patched.OutputCostPerToken, 1e-12, "未覆盖字段保持目录值") + require.Equal(t, "openai", patched.LiteLLMProvider) + require.Equal(t, 272000, patched.LongContextInputTokenThreshold, "above 折算仍生效") + // 折算发生在合并之后:above 价不变、基础价被补丁改小,倍率随之变化。 + require.InDelta(t, 5.0/3.0, patched.LongContextInputCostMultiplier, 1e-9) +} + +func TestPricingOverride_NullFieldValueRemovesField(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": { + "input_cost_per_token_above_272k_tokens": null, + "output_cost_per_token_above_272k_tokens": null, + "cache_read_input_token_cost_above_272k_tokens": null}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold, "above 字段删除后不再折算阶梯") + require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12) +} + +// 完整加载管线:纯补丁不得抢在回退合并前建条目(否则回退完整条目被跳过、 +// 其余分项价变 0 少收);目录/回退都没有的模型作为独立条目并入。 +func TestPricingOverride_LoadPipelineAddsNewModelAndPatchesFallbackOnly(t *testing.T) { + dir := t.TempDir() + catalogPath := filepath.Join(dir, "catalog.json") + require.NoError(t, os.WriteFile(catalogPath, []byte(`{ + "remote-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06} + }`), 0644)) + fallbackPath := filepath.Join(dir, "fallback.json") + require.NoError(t, os.WriteFile(fallbackPath, []byte(`{ + "fallback-only-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 8e-06, + "cache_read_input_token_cost": 4e-07} + }`), 0644)) + overridePath := filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(overridePath, []byte(`{ + "fallback-only-model": {"input_cost_per_token": 9e-06}, + "override-new-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 1e-05} + }`), 0644)) + + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.FallbackFile = fallbackPath + svc.cfg.Pricing.OverrideFile = overridePath + require.NoError(t, svc.loadPricingData(catalogPath)) + + patched := svc.pricingData["fallback-only-model"] + require.NotNil(t, patched) + require.InDelta(t, 9e-6, patched.InputCostPerToken, 1e-12) + require.InDelta(t, 8e-6, patched.OutputCostPerToken, 1e-12, "回退条目的其余字段必须保留") + require.InDelta(t, 4e-7, patched.CacheReadInputTokenCost, 1e-12) + + added := svc.pricingData["override-new-model"] + require.NotNil(t, added) + require.InDelta(t, 5e-6, added.InputCostPerToken, 1e-12) + require.InDelta(t, 1e-5, added.OutputCostPerToken, 1e-12) + + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) +} + +// 拼错模型名(或纯补丁落在不存在的模型上)会被有效性过滤丢弃,必须有哨兵 WARN。 +func TestPricingOverride_IneffectiveEntryWarns(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + dir := t.TempDir() + catalogPath := filepath.Join(dir, "catalog.json") + require.NoError(t, os.WriteFile(catalogPath, []byte(`{ + "remote-model": {"litellm_provider": "test", "mode": "chat", "input_cost_per_token": 1e-06} + }`), 0644)) + overridePath := filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(overridePath, []byte(`{ + "typo-model": {"long_context_input_token_threshold": 0} + }`), 0644)) + + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = overridePath + require.NoError(t, svc.loadPricingData(catalogPath)) + + require.NotContains(t, svc.pricingData, "typo-model") + require.True(t, logSink.ContainsMessageAtLevel("override had no effect for 1 model(s): typo-model", "warn")) +} + +func TestPricingOverride_NonObjectEntryKeepsCatalogEntry(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": "oops"}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold, "非法补丁忽略,目录条目原样保留") + require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12) +} + +func TestPricingOverride_MissingOrInvalidFileIsIgnored(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = filepath.Join(t.TempDir(), "absent.json") + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold) + }) + + t.Run("invalid json", func(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{invalid`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold) + }) +} + +// 对真实出厂目录快照关闭 gpt-5.5 阶梯:计费视角阈值归零、基础价不变, +// 其他模型(gpt-5.4)的目录阶梯不受影响。 +func TestPricingOverride_DisablesGPT55LadderOnDefaultCatalog(t *testing.T) { + body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + svc := newPricingServiceWithOverride(t, `{ + "gpt-5.5": {"long_context_input_token_threshold": 0}, + "gpt-5.5-2026-04-23": {"long_context_input_token_threshold": 0} + }`) + data, err := svc.parsePricingData(body) + require.NoError(t, err) + svc.pricingData = data + billing := NewBillingService(&config.Config{}, svc) + + for _, model := range []string{"gpt-5.5", "gpt-5.5-2026-04-23"} { + pricing, err := billing.GetModelPricing(model) + require.NoError(t, err) + require.Zero(t, pricing.LongContextInputThreshold, model) + require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12, model) + } + + pricing, err := billing.GetModelPricing("gpt-5.4") + require.NoError(t, err) + require.Equal(t, 272000, pricing.LongContextInputThreshold, "其他模型的目录阶梯不受影响") +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index bd31c3421e..c8798ee171 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1066,6 +1066,14 @@ pricing: # Fallback pricing file # 备用定价文件 fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" + # Override patch file (optional, highest priority): entries are shallow-merged + # field-by-field over catalog/fallback data; a null field value removes that field. + # Model names must match catalog keys exactly. Edits take effect on restart or + # the next catalog download. + # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, + # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 + # 修改后重启或下次目录下载时生效。 + # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) update_interval_hours: 24 From e2cfaa46e79241f34e52bf4781f7710cd511c5d2 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 31 Aug 2026 20:07:49 +0800 Subject: [PATCH 15/56] =?UTF-8?q?fix(pricing):=20=E7=9B=AE=E5=BD=95=20cach?= =?UTF-8?q?e=20=E4=BE=A7=E5=AD=97=E6=AE=B5=E5=A5=91=E7=BA=A6=E5=93=A8?= =?UTF-8?q?=E5=85=B5=E4=B8=8E=20Gemini=20pro=20=E7=BC=93=E5=AD=98=E5=86=99?= =?UTF-8?q?=E5=85=A5=E4=BB=B7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差, 解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价: - orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧 above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打 WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价 缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在 即不算孤儿,与计费回落一致 - isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档 折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格 版本的形态),显式 long_context_* 字段不告警 - deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档 须恰为基础价 × 输入倍率 - 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准 输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带 priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值 测试: - 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧 阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存 写入价 = 输入价) - 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生 cache_creation token(Gemini usageMetadata 无该类别) - 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值 计入缓存写入 token;阶梯表场景与夹具同步为现行目录值 --- .../pkg/antigravity/usage_contract_test.go | 40 ++++++ .../service/billing_context_schedule_test.go | 5 +- .../billing_token_cost_request_test.go | 46 ++++++- .../gemini_messages_compat_service_test.go | 5 + backend/internal/service/pricing_service.go | 94 +++++++++++++- .../internal/service/pricing_service_test.go | 120 ++++++++++++++++++ .../model_prices_and_context_window.json | 26 +++- 7 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 backend/internal/pkg/antigravity/usage_contract_test.go diff --git a/backend/internal/pkg/antigravity/usage_contract_test.go b/backend/internal/pkg/antigravity/usage_contract_test.go new file mode 100644 index 0000000000..832bffbfb2 --- /dev/null +++ b/backend/internal/pkg/antigravity/usage_contract_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package antigravity + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别: +// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入 +// cache_read_input_tokens 并从 input_tokens 中扣除。 +func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) { + const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` + + `"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}` + + t.Run("non-stream", func(t *testing.T) { + _, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview") + require.NoError(t, err) + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 25, usage.OutputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) + + t.Run("stream", func(t *testing.T) { + p := NewStreamingProcessor("gemini-3.1-pro-preview") + out := p.ProcessLine(`data: {"response":` + geminiBody + `}`) + require.True(t, strings.Contains(string(out), `"message_start"`)) + require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项") + _, usage := p.Finish() + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) +} diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 77a0195cf4..dfe43f1265 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -237,8 +237,9 @@ func scheduleScenarios() []scheduleScenario { group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) - requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) - requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6)) + // 缓存写入按标准输入价,与 input 一同整单换档 + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) }, }, { diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index ccb5b73642..9d282de061 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -40,15 +40,18 @@ func geminiCatalogStub() *PricingService { }) } -// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价, -// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。 +// geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的 +// 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档 +// 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。 const geminiLadderCatalogJSON = `{ "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "cache_read_input_token_cost_above_200k_tokens": 6.25e-07} + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06} }` func geminiLadderCatalogStub(t *testing.T) *PricingService { @@ -129,6 +132,41 @@ func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing } } +// 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档, +// 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。 +func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t)) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + + calc := func(tokens UsageTokens) *CostBreakdown { + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + return got + } + + // 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。 + // 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。 + above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000}) + require.True(t, above.LongContextBillingApplied) + require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9) + require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9) + require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9) + + // 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计 + below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000}) + require.False(t, below.LongContextBillingApplied) + require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9) + require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9) +} + // 目录条目没有阶梯字段时,开关开启也不产生阶梯。 func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) { bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index e9ee74e3be..c6f5a46d08 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -898,6 +898,11 @@ func TestExtractGeminiUsage(t *testing.T) { if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens { t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens) } + // Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入 + // 的 token 类别:cache_creation_input_tokens 恒为 0,计费侧不会产生缓存创建分项。 + if got.CacheCreationInputTokens != 0 { + t.Errorf("CacheCreationInputTokens: 期望 0,实际 %d", got.CacheCreationInputTokens) + } }) } } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f3b9339cdc..826f6a7803 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -30,7 +30,12 @@ var ( // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名 // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。 // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。 - aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + // cacheTierPricePattern 匹配 cache 侧的长上下文绝对价字段名 + // (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、 + // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。 + // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。 + cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2.5e-06, // $2.5 per MTok OutputCostPerToken: 1.5e-05, // $15 per MTok @@ -425,6 +430,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel result := make(map[string]*LiteLLMModelPricing) skipped := 0 + var orphanCacheTiers, lopsidedLadders []string for modelName, rawEntry := range rawData { // 跳过 sample_spec 等文档条目 @@ -503,6 +509,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel entry.LongContextOutputCostMultiplier != nil if !hasExplicitLongContext { deriveLongContextFromAboveTierFields(rawEntry, pricing) + if isLopsidedLongContextLadder(pricing) { + lopsidedLadders = append(lopsidedLadders, fmt.Sprintf("%s(input x%.2f, output x%.2f)", modelName, + pricing.LongContextInputCostMultiplier, pricing.LongContextOutputCostMultiplier)) + } + } + if orphans := orphanCacheTierFields(rawEntry); len(orphans) > 0 { + orphanCacheTiers = append(orphanCacheTiers, modelName+"("+strings.Join(orphans, ",")+")") } result[modelName] = pricing @@ -511,6 +524,8 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if skipped > 0 { logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped) } + warnOrphanCacheTierFields(orphanCacheTiers) + warnLopsidedLongContextLadders(lopsidedLadders) if len(result) == 0 { return nil, fmt.Errorf("no valid pricing entries found") @@ -524,9 +539,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel // 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由 // 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该 // 模型的阶梯。多个阈值并存时取最小阈值。 -// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算; -// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率, -// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。 +// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算: +// 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段 +// 无法参与计费,由 orphanCacheTierFields 哨兵告警。 func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) { if pricing == nil || pricing.LongContextInputTokenThreshold > 0 || @@ -594,6 +609,77 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit pricing.LongContextOutputCostMultiplier = outputMultiplier } +// isLopsidedLongContextLadder 判断折算出的阶梯是否只有一侧带附加费。官方阶梯(OpenAI、 +// Google、Anthropic、xAI)都同时抬高 input 与 output;单侧附加费意味着条目的基础价与 +// above 档来自不同价格版本(如基础价被手工 pin、above 档随上游更新),折算出的倍率失真。 +func isLopsidedLongContextLadder(pricing *LiteLLMModelPricing) bool { + if pricing == nil || pricing.LongContextInputTokenThreshold <= 0 { + return false + } + return (pricing.LongContextInputCostMultiplier > 1) != (pricing.LongContextOutputCostMultiplier > 1) +} + +// warnLopsidedLongContextLadders 对单侧附加费的折算阶梯打 WARN:应成组修正该条目的 +// 基础价与 above 档(目录或 pricing.override_file)。 +func warnLopsidedLongContextLadders(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) derive a one-sided long-context ladder (surcharge on only input or only output); base prices and above-tier prices likely come from different price versions: %s", total, strings.Join(entries, ", ")) +} + +// orphanCacheTierFields 返回条目中没有对应基础价的 cache 侧 above 档字段名。 +// cache 侧 above 档不参与计费取值,计费按"基础价 × 输入倍率";基础价缺失或为 0 时, +// 该缓存分项在整个阶梯上都按 0 计。计费对变体有回落:服务档变体(_priority/_flex) +// 缺自身基础价时用标准基础价,1h 缓存写入缺 above_1hr 价时全部按 5m 价——因此沿 +// 回落链任一基础价存在即不算孤儿。 +func orphanCacheTierFields(rawEntry json.RawMessage) []string { + if !bytes.Contains(rawEntry, []byte("_above_")) { + return nil + } + var fields map[string]any + if err := json.Unmarshal(rawEntry, &fields); err != nil { + return nil + } + positive := func(key string) bool { + price, ok := fields[key].(float64) + return ok && price > 0 + } + var orphans []string + for key := range fields { + m := cacheTierPricePattern.FindStringSubmatch(key) + if m == nil || !positive(key) { + continue + } + stem, hourly, tier := m[1], m[2], m[3] + if positive(stem+hourly+tier) || positive(stem+hourly) || positive(stem+tier) || positive(stem) { + continue + } + orphans = append(orphans, key) + } + sort.Strings(orphans) + return orphans +} + +// warnOrphanCacheTierFields 对带 cache 侧 above 档却没有基础价的条目打 WARN: +// 该缓存分项按 0 计费,目录或 pricing.override_file 补上基础价即可消除。 +func warnOrphanCacheTierFields(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) carry cache above-tier prices without a base cache price; that cache item bills at $0 until the catalog/override supplies the base: %s", total, strings.Join(entries, ", ")) +} + // applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退 // 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补 // 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 96cc63ba55..a02c74fe72 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -827,6 +827,126 @@ func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) { require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier) } +// cache 侧 above 档随输入倍率计费、不单独折算;缺基础价的 cache above 字段无法参与计费, +// 该缓存分项按 0 计,属于数据契约违规,必须有哨兵 WARN。服务档变体缺基础价时回落 +// 标准基础价,不算孤儿。 +func TestParsePricingData_WarnsOrphanCacheTierFields(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gemini-orphan": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07}, + "gemini-complete": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}, + "priority-variant-without-own-base": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06}, + "priority-variant-orphan": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05}, + "hourly-tier-with-5m-base": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05}, + "hourly-tier-orphan": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05} + }`)) + require.NoError(t, err) + + require.Equal(t, 200000, data["gemini-orphan"].LongContextInputTokenThreshold, "孤儿 cache 字段不影响 input/output 阶梯折算") + require.Zero(t, data["gemini-orphan"].CacheCreationInputTokenCost) + require.InDelta(t, 1.25e-6, data["gemini-complete"].CacheCreationInputTokenCost, 1e-12) + + require.True(t, logSink.ContainsMessageAtLevel("gemini-orphan(cache_creation_input_token_cost_above_200k_tokens)", "warn")) + require.True(t, logSink.ContainsMessage("priority-variant-orphan(cache_creation_input_token_cost_above_272k_tokens_priority)")) + require.True(t, logSink.ContainsMessage("hourly-tier-orphan(cache_creation_input_token_cost_above_1hr_above_200k_tokens)")) + require.False(t, logSink.ContainsMessage("gemini-complete")) + require.False(t, logSink.ContainsMessage("priority-variant-without-own-base")) + require.False(t, logSink.ContainsMessage("hourly-tier-with-5m-base"), "1h 档缺 above_1hr 基础价时计费回落 5m 价,不算孤儿") +} + +// 基础价与 above 档来自不同价格版本时(如基础价被手工 pin、above 档随上游更新)会折算出 +// 只有一侧带附加费的阶梯,必须有哨兵 WARN;显式 long_context_* 字段是部署方意图,不告警。 +func TestParsePricingData_WarnsLopsidedLongContextLadder(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "mixed-versions": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "consistent": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "explicit-input-only": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2} + }`)) + require.NoError(t, err) + + require.Equal(t, 272000, data["mixed-versions"].LongContextInputTokenThreshold, "单侧阶梯仍按折算结果计费,只告警不丢弃") + require.InDelta(t, 1.6, data["mixed-versions"].LongContextInputCostMultiplier, 1e-12) + require.True(t, logSink.ContainsMessageAtLevel("mixed-versions(input x1.60, output x1.00)", "warn")) + require.False(t, logSink.ContainsMessage("consistent")) + require.False(t, logSink.ContainsMessage("explicit-input-only")) +} + +// 出厂回退快照必须满足数据契约:没有孤儿 cache above 字段、没有单侧阶梯,且 Gemini pro 系的 +// 缓存写入基础价等于标准输入价(含 priority 变体)。快照是随目录同步刷新的文本,这里防止刷新时静默回退。 +func TestDefaultCatalogSnapshot_CacheTierContract(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + var rawEntries map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &rawEntries)) + for name, raw := range rawEntries { + require.Empty(t, orphanCacheTierFields(raw), "快照条目 %s 带孤儿 cache above 字段", name) + } + + svc := &PricingService{} + data, err := svc.parsePricingData(body) + require.NoError(t, err) + require.False(t, logSink.ContainsMessage("carry cache above-tier prices"), "快照不应触发孤儿 cache 字段哨兵") + require.False(t, logSink.ContainsMessage("one-sided long-context ladder"), "快照不应触发单侧阶梯哨兵") + for _, model := range []string{ + "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview", + "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3.1-pro-preview-customtools", + } { + pricing := data[model] + require.NotNil(t, pricing, model) + require.Positive(t, pricing.InputCostPerToken, model) + require.InDelta(t, pricing.InputCostPerToken, pricing.CacheCreationInputTokenCost, 1e-15, "%s 缓存写入基础价应等于标准输入价", model) + require.Equal(t, 200000, pricing.LongContextInputTokenThreshold, model) + if pricing.InputCostPerTokenPriority > 0 { + require.InDelta(t, pricing.InputCostPerTokenPriority, pricing.CacheCreationInputTokenCostPriority, 1e-15, "%s priority 缓存写入价应等于 priority 输入价", model) + } + } +} + // F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。 func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) { tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 94790cdba1..7fea808561 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -1414,7 +1414,8 @@ ] }, "gemini-2.5-pro": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06, "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, @@ -1702,7 +1703,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2031,7 +2035,10 @@ "supports_web_search": true }, "gemini-3.1-pro-high": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2095,7 +2102,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-low": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2159,7 +2169,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2223,7 +2236,8 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06, From aa679efe41f73bd8ca375d48c9d8956b0907098e Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:08 +0900 Subject: [PATCH 16/56] Add the group Fast migration --- .../migrations_schema_integration_test.go | 3 ++- .../migrations/232_group_force_openai_fast.sql | 5 +++++ .../group_force_openai_fast_migration_test.go | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/232_group_force_openai_fast.sql create mode 100644 backend/migrations/group_force_openai_fast_migration_test.go diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index d90cc4f4b5..4d32cce370 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -55,8 +55,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true) requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due") - // groups: OpenAI Live 默认关闭,管理员显式开启后才可访问。 + // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。 requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) + requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false) // api_keys: key length should be 128 requireColumn(t, tx, "api_keys", "key", "character varying", 128, false) diff --git a/backend/migrations/232_group_force_openai_fast.sql b/backend/migrations/232_group_force_openai_fast.sql new file mode 100644 index 0000000000..aaf0f4f4fa --- /dev/null +++ b/backend/migrations/232_group_force_openai_fast.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS force_openai_fast BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN groups.force_openai_fast IS + 'Force OpenAI gateway requests in this group to use service_tier=priority before global Fast/Flex policy evaluation'; diff --git a/backend/migrations/group_force_openai_fast_migration_test.go b/backend/migrations/group_force_openai_fast_migration_test.go new file mode 100644 index 0000000000..100c1294ab --- /dev/null +++ b/backend/migrations/group_force_openai_fast_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupForceOpenAIFastMigration(t *testing.T) { + content, err := FS.ReadFile("232_group_force_openai_fast.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "ADD COLUMN IF NOT EXISTS force_openai_fast BOOLEAN NOT NULL DEFAULT FALSE") + require.Contains(t, sql, "COMMENT ON COLUMN groups.force_openai_fast") +} From 1cb28fd4293b9784e1925134074aa8c702a66070 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:12 +0900 Subject: [PATCH 17/56] Regenerate Group persistence for Fast mode --- backend/ent/group.go | 13 ++++++- backend/ent/group/group.go | 10 ++++++ backend/ent/group/where.go | 15 ++++++++ backend/ent/group_create.go | 65 ++++++++++++++++++++++++++++++++++ backend/ent/group_update.go | 34 ++++++++++++++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++++++++++++++++++++++- backend/ent/runtime/runtime.go | 26 ++++++++------ backend/ent/schema/group.go | 3 ++ 9 files changed, 210 insertions(+), 13 deletions(-) diff --git a/backend/ent/group.go b/backend/ent/group.go index 8f30c26470..29912a2133 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -122,6 +122,8 @@ type Group struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"` // 是否允许此 OpenAI 分组访问 Live 接口 AllowLive bool `json:"allow_live,omitempty"` + // 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority + ForceOpenaiFast bool `json:"force_openai_fast,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -252,7 +254,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) @@ -616,6 +618,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.AllowLive = value.Bool } + case group.FieldForceOpenaiFast: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i]) + } else if value.Valid { + _m.ForceOpenaiFast = value.Bool + } case group.FieldRequireOauthOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i]) @@ -955,6 +963,9 @@ func (_m *Group) String() string { builder.WriteString("allow_live=") builder.WriteString(fmt.Sprintf("%v", _m.AllowLive)) builder.WriteString(", ") + builder.WriteString("force_openai_fast=") + builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast)) + builder.WriteString(", ") builder.WriteString("require_oauth_only=") builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 5ec04d32ae..87cdad3dff 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -118,6 +118,8 @@ const ( FieldAllowMessagesDispatch = "allow_messages_dispatch" // FieldAllowLive holds the string denoting the allow_live field in the database. FieldAllowLive = "allow_live" + // FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database. + FieldForceOpenaiFast = "force_openai_fast" // FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database. FieldRequireOauthOnly = "require_oauth_only" // FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database. @@ -266,6 +268,7 @@ var Columns = []string{ FieldSortOrder, FieldAllowMessagesDispatch, FieldAllowLive, + FieldForceOpenaiFast, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -386,6 +389,8 @@ var ( DefaultAllowMessagesDispatch bool // DefaultAllowLive holds the default value on creation for the "allow_live" field. DefaultAllowLive bool + // DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field. + DefaultForceOpenaiFast bool // DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field. DefaultRequireOauthOnly bool // DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field. @@ -657,6 +662,11 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAllowLive, opts...).ToFunc() } +// ByForceOpenaiFast orders the results by the force_openai_fast field. +func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc() +} + // ByRequireOauthOnly orders the results by the require_oauth_only field. func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 685a48a922..357c6921b8 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -290,6 +290,11 @@ func AllowLive(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAllowLive, v)) } +// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ. +func ForceOpenaiFast(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) +} + // RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ. func RequireOauthOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) @@ -2270,6 +2275,16 @@ func AllowLiveNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldAllowLive, v)) } +// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field. +func ForceOpenaiFastEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) +} + +// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field. +func ForceOpenaiFastNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v)) +} + // RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field. func RequireOauthOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index e5e92ccd3f..9e0a846ee5 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -704,6 +704,20 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate { return _c } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate { + _c.mutation.SetForceOpenaiFast(v) + return _c +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate { + if v != nil { + _c.SetForceOpenaiFast(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -1095,6 +1109,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultAllowLive _c.mutation.SetAllowLive(v) } + if _, ok := _c.mutation.ForceOpenaiFast(); !ok { + v := group.DefaultForceOpenaiFast + _c.mutation.SetForceOpenaiFast(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1286,6 +1304,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.AllowLive(); !ok { return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)} } + if _, ok := _c.mutation.ForceOpenaiFast(); !ok { + return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)} + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1560,6 +1581,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) _node.AllowLive = value } + if value, ok := _c.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) + _node.ForceOpenaiFast = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2618,6 +2643,18 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert { return u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert { + u.Set(group.FieldForceOpenaiFast, v) + return u +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert { + u.SetExcluded(group.FieldForceOpenaiFast) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3817,6 +3854,20 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne { }) } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetForceOpenaiFast(v) + }) +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateForceOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5207,6 +5258,20 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk { }) } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetForceOpenaiFast(v) + }) +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateForceOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 41e06b4471..ed68bcacb9 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -989,6 +989,20 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate { return _u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate { + _u.mutation.SetForceOpenaiFast(v) + return _u +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate { + if v != nil { + _u.SetForceOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1797,6 +1811,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AllowLive(); ok { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) } + if value, ok := _u.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -3110,6 +3127,20 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne { return _u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne { + _u.mutation.SetForceOpenaiFast(v) + return _u +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetForceOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -3948,6 +3979,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AllowLive(); ok { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) } + if value, ok := _u.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 270eecd87a..8aa1cc7a69 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -954,6 +954,7 @@ var ( {Name: "sort_order", Type: field.TypeInt, Default: 0}, {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, {Name: "allow_live", Type: field.TypeBool, Default: false}, + {Name: "force_openai_fast", Type: field.TypeBool, Default: false}, {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 54d284ad97..6c22f3373c 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22158,6 +22158,7 @@ type GroupMutation struct { addsort_order *int allow_messages_dispatch *bool allow_live *bool + force_openai_fast *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24944,6 +24945,42 @@ func (m *GroupMutation) ResetAllowLive() { m.allow_live = nil } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (m *GroupMutation) SetForceOpenaiFast(b bool) { + m.force_openai_fast = &b +} + +// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation. +func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) { + v := m.force_openai_fast + if v == nil { + return + } + return *v, true +} + +// OldForceOpenaiFast returns the old "force_openai_fast" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldForceOpenaiFast(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err) + } + return oldValue.ForceOpenaiFast, nil +} + +// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field. +func (m *GroupMutation) ResetForceOpenaiFast() { + m.force_openai_fast = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -25773,7 +25810,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 62) + fields := make([]string, 0, 63) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25927,6 +25964,9 @@ func (m *GroupMutation) Fields() []string { if m.allow_live != nil { fields = append(fields, group.FieldAllowLive) } + if m.force_openai_fast != nil { + fields = append(fields, group.FieldForceOpenaiFast) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -26070,6 +26110,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AllowMessagesDispatch() case group.FieldAllowLive: return m.AllowLive() + case group.FieldForceOpenaiFast: + return m.ForceOpenaiFast() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -26203,6 +26245,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAllowMessagesDispatch(ctx) case group.FieldAllowLive: return m.OldAllowLive(ctx) + case group.FieldForceOpenaiFast: + return m.OldForceOpenaiFast(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -26591,6 +26635,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetAllowLive(v) return nil + case group.FieldForceOpenaiFast: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetForceOpenaiFast(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -27332,6 +27383,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldAllowLive: m.ResetAllowLive() return nil + case group.FieldForceOpenaiFast: + m.ResetForceOpenaiFast() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 088b876b96..8323961ebd 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1157,52 +1157,56 @@ func init() { groupDescAllowLive := groupFields[47].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) + // groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field. + groupDescForceOpenaiFast := groupFields[48].Descriptor() + // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. + group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[48].Descriptor() + groupDescRequireOauthOnly := groupFields[49].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[49].Descriptor() + groupDescRequirePrivacySet := groupFields[50].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[50].Descriptor() + groupDescDefaultMappedModel := groupFields[51].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[52].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[52].Descriptor() + groupDescModelsListConfig := groupFields[53].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[53].Descriptor() + groupDescRpmLimit := groupFields[54].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[54].Descriptor() + groupDescMaxReasoningEffort := groupFields[55].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[55].Descriptor() + groupDescReasoningEffortMappings := groupFields[56].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[56].Descriptor() + groupDescProfitControlEnabled := groupFields[57].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[57].Descriptor() + groupDescProfitMinMargin := groupFields[58].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[58].Descriptor() + groupDescProfitSafetyBuffer := groupFields[59].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index f195ac51db..01efd0f5c3 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -242,6 +242,9 @@ func (Group) Fields() []ent.Field { field.Bool("allow_live"). Default(false). Comment("是否允许此 OpenAI 分组访问 Live 接口"), + field.Bool("force_openai_fast"). + Default(false). + Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"), field.Bool("require_oauth_only"). Default(false). Comment("仅允许非 apikey 类型账号关联到此分组"), From 563979e91e8b6f1e330ceb96274d067ba74d2c58 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:16 +0900 Subject: [PATCH 18/56] Persist the group Fast setting --- backend/internal/repository/group_repo.go | 2 ++ backend/internal/service/group.go | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index b5de46353d..125456cd59 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -103,6 +103,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). + SetForceOpenaiFast(groupIn.ForceOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -279,6 +280,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). + SetForceOpenaiFast(groupIn.ForceOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bf0440afc3..8703057377 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -100,6 +100,7 @@ type Group struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool AllowLive bool + ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string From 95982a508f1d708879a11c6760df4523ec475c2d Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:34 +0900 Subject: [PATCH 19/56] Project group Fast into API key auth --- backend/internal/repository/api_key_repo.go | 2 + ...openai_fast_projection_integration_test.go | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 6e517f4524..f1bd5c53d0 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -210,6 +210,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldSupportedModelScopes, group.FieldAllowMessagesDispatch, group.FieldAllowLive, + group.FieldForceOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -1007,6 +1008,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { SortOrder: g.SortOrder, AllowMessagesDispatch: g.AllowMessagesDispatch, AllowLive: g.AllowLive, + ForceOpenAIFast: g.ForceOpenaiFast, RequireOAuthOnly: g.RequireOauthOnly, RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, diff --git a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go new file mode 100644 index 0000000000..a3e00a541d --- /dev/null +++ b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI, + RateMultiplier: 1, ForceOpenAIFast: true, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-fast-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "fast-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group) + require.True(t, got.Group.ForceOpenAIFast) +} From 94bb9354ce06b857541d2df2e4d577fb72ce071d Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:37 +0900 Subject: [PATCH 20/56] Accept group Fast in admin service inputs --- backend/internal/service/admin_service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index e48eea51c1..0195bc01c7 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -266,6 +266,7 @@ type CreateGroupInput struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool AllowLive bool + ForceOpenAIFast bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -341,6 +342,7 @@ type UpdateGroupInput struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool AllowLive *bool + ForceOpenAIFast *bool DefaultMappedModel *string RequireOAuthOnly *bool RequirePrivacySet *bool From e9e66da5a8bc77edcadceade68ca59e99a348861 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:41 +0900 Subject: [PATCH 21/56] Manage group Fast through group lifecycle --- backend/internal/service/admin_group.go | 18 ++++++ .../service/admin_service_group_test.go | 60 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 9e29c30eaa..032c7024bc 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -295,6 +295,18 @@ func groupSupportsOAuthOnlyFilter(platform string) bool { platform == PlatformComposite } +func groupSupportsOpenAIFast(platform string) bool { + return platform == PlatformOpenAI || platform == PlatformComposite +} + +func sanitizeGroupOpenAIFast(group *Group) { + if group == nil || !groupSupportsOpenAIFast(group.Platform) { + if group != nil { + group.ForceOpenAIFast = false + } + } +} + func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") @@ -500,6 +512,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn SupportedModelScopes: input.SupportedModelScopes, AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, + ForceOpenAIFast: input.ForceOpenAIFast, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -510,6 +523,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } @@ -862,6 +876,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.AllowLive != nil { group.AllowLive = *input.AllowLive } + if input.ForceOpenAIFast != nil { + group.ForceOpenAIFast = *input.ForceOpenAIFast + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } @@ -895,6 +912,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 72c5e9a0f7..02694cdfde 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1082,6 +1082,66 @@ func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) { require.True(t, repo.created.AllowLive) } +func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing.T) { + for _, tt := range []struct { + name string + platform string + want bool + }{ + {name: "openai", platform: PlatformOpenAI, want: true}, + {name: "composite", platform: PlatformComposite, want: true}, + {name: "anthropic", platform: PlatformAnthropic, want: false}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.Equal(t, tt.want, repo.created.ForceOpenAIFast) + }) + } +} + +func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) { + existingGroup := &Group{ + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + Platform: PlatformAnthropic, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.False(t, repo.updated.ForceOpenAIFast) +} + +func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) { + existingGroup := &Group{ + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + invalidator := &authCacheInvalidatorStub{} + svc := &adminServiceImpl{groupRepo: repo, authCacheInvalidator: invalidator} + enabled := true + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + ForceOpenAIFast: &enabled, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.True(t, repo.updated.ForceOpenAIFast) + require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs) +} + func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) { existingGroup := &Group{ ID: 1, From 7e0681657154268d63f117fff076c2ff5d594f49 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:55 +0900 Subject: [PATCH 22/56] Preserve group Fast when duplicating groups --- backend/internal/service/admin_group_duplicate.go | 1 + backend/internal/service/admin_group_duplicate_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index deda6d3822..09fcbb8ec7 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -144,6 +144,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { SortOrder: source.SortOrder, AllowMessagesDispatch: source.AllowMessagesDispatch, AllowLive: source.AllowLive, + ForceOpenAIFast: source.ForceOpenAIFast, RequireOAuthOnly: source.RequireOAuthOnly, RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index f4a7bef2c5..4a13c53fce 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -166,6 +166,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing SortOrder: 9, AllowMessagesDispatch: true, AllowLive: true, + ForceOpenAIFast: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", @@ -212,6 +213,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID) require.Equal(t, source.ModelRouting, duplicate.ModelRouting) require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) + require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast) require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) From 7f5e915dd1bd9b8294d2e015f16f5b5a9d274e8e Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:57:59 +0900 Subject: [PATCH 23/56] Expose group Fast in admin requests --- .../internal/handler/admin/group_handler.go | 4 ++++ .../admin/group_handler_openai_fast_test.go | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 backend/internal/handler/admin/group_handler_openai_fast_test.go diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 2fcd69a9b2..13f7d2fd8c 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -147,6 +147,7 @@ type CreateGroupRequest struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` + ForceOpenAIFast bool `json:"force_openai_fast"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -216,6 +217,7 @@ type UpdateGroupRequest struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` + ForceOpenAIFast *bool `json:"force_openai_fast"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -550,6 +552,7 @@ func (h *GroupHandler) Create(c *gin.Context) { SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, + ForceOpenAIFast: req.ForceOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -679,6 +682,7 @@ func (h *GroupHandler) Update(c *gin.Context) { SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, + ForceOpenAIFast: req.ForceOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, diff --git a/backend/internal/handler/admin/group_handler_openai_fast_test.go b/backend/internal/handler/admin/group_handler_openai_fast_test.go new file mode 100644 index 0000000000..22a9a53500 --- /dev/null +++ b/backend/internal/handler/admin/group_handler_openai_fast_test.go @@ -0,0 +1,23 @@ +package admin + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) { + var createReq CreateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true}`), &createReq)) + require.True(t, createReq.ForceOpenAIFast) + + var updateReq UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false}`), &updateReq)) + require.NotNil(t, updateReq.ForceOpenAIFast) + require.False(t, *updateReq.ForceOpenAIFast) + + var omitted UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) + require.Nil(t, omitted.ForceOpenAIFast) +} From 9fba8ed43b01c446b59650892864dc9df3f760f0 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:03 +0900 Subject: [PATCH 24/56] Return group Fast in admin responses --- .../dto/group_mapper_openai_fast_test.go | 23 +++++++++++++++++++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 ++ 3 files changed, 26 insertions(+) create mode 100644 backend/internal/handler/dto/group_mapper_openai_fast_test.go diff --git a/backend/internal/handler/dto/group_mapper_openai_fast_test.go b/backend/internal/handler/dto/group_mapper_openai_fast_test.go new file mode 100644 index 0000000000..c5aee931dc --- /dev/null +++ b/backend/internal/handler/dto/group_mapper_openai_fast_test.go @@ -0,0 +1,23 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) { + group := &service.Group{ + ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, + } + + userJSON, err := json.Marshal(GroupFromService(group)) + require.NoError(t, err) + require.NotContains(t, string(userJSON), "force_openai_fast") + + adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) + require.NoError(t, err) + require.Contains(t, string(adminJSON), `"force_openai_fast":true`) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1c02cd69a3..63aa423cbb 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -148,6 +148,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { } out := &AdminGroup{ Group: groupFromServiceBase(g), + ForceOpenAIFast: g.ForceOpenAIFast, ProfitControlEnabled: g.ProfitControlEnabled, ProfitMinMargin: g.ProfitMinMargin, ProfitSafetyBuffer: g.ProfitSafetyBuffer, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 5ac667e171..f07592c2ca 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -164,6 +164,8 @@ type Group struct { // 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。 type AdminGroup struct { Group + // ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。 + ForceOpenAIFast bool `json:"force_openai_fast"` // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 From a44a1019f8da93cee4b1d7a91467b246d806c72b Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:19 +0900 Subject: [PATCH 25/56] Carry group Fast through auth snapshots --- .../internal/service/api_key_auth_cache.go | 1 + .../service/api_key_auth_cache_impl.go | 4 ++- .../api_key_auth_cache_openai_fast_test.go | 35 +++++++++++++++++++ .../service/api_key_auth_cache_profit_test.go | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/api_key_auth_cache_openai_fast_test.go diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 71e0318dc0..0bdbfc8293 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -103,6 +103,7 @@ type APIKeyAuthGroupSnapshot struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` + ForceOpenAIFast bool `json:"force_openai_fast"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 3c95141a47..f267a3fe89 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots) +const apiKeyAuthSnapshotVersion = 21 // v21: group force_openai_fast field type apiKeyAuthCacheConfig struct { l1Size int @@ -418,6 +418,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) SupportedModelScopes: apiKey.Group.SupportedModelScopes, AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, AllowLive: apiKey.Group.AllowLive, + ForceOpenAIFast: apiKey.Group.ForceOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -516,6 +517,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho SupportedModelScopes: snapshot.Group.SupportedModelScopes, AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, AllowLive: snapshot.Group.AllowLive, + ForceOpenAIFast: snapshot.Group.ForceOpenAIFast, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go new file mode 100644 index 0000000000..18a1d3ca9d --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { + groupID := int64(50) + apiKey := &APIKey{ + ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-fast-roundtrip", Status: StatusActive, + User: &User{ID: 40, Status: StatusActive}, + Group: &Group{ + ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, ForceOpenAIFast: true, + }, + } + svc := &APIKeyService{} + + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)}) + require.NoError(t, err) + var cached APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &cached)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.Hydrated) + require.True(t, materialized.Group.ForceOpenAIFast) + require.Equal(t, 21, cached.Snapshot.Version) +} diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index fbcd96a42a..d69d21d869 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段") + require.Equal(t, 21, snapshot.Version, "v21 起认证快照携带分组 Fast 强制开关") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) From f8eae45048981936d74a1855b4aaef1582ad9c8a Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:23 +0900 Subject: [PATCH 26/56] Force OpenAI Fast across gateway paths --- .../service/openai_fast_policy_test.go | 79 +++++++++++++++++++ .../service/openai_fast_policy_ws_test.go | 28 +++++++ .../openai_gateway_chat_completions.go | 1 + .../service/openai_gateway_messages.go | 1 + .../service/openai_gateway_request_body.go | 29 ++++++- .../service/openai_oauth_passthrough_test.go | 33 ++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index c5144b6a02..ce76b4a74b 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -262,6 +262,85 @@ func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T } } +func TestApplyOpenAIFastPolicyToBody_GroupForceInjectsAndOverridesTier(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + for _, body := range [][]byte{ + []byte(`{"model":"gpt-5.6-sol","input":"hi"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"default"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"flex"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"client-unknown"}`), + } { + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) + } +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceStillHonorsGlobalPolicy(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", []byte(`{"model":"gpt-5.6-sol"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(updated, "service_tier").Exists(), + "the global filter remains authoritative after the group requests priority") +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresHydratedGroup(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + body := []byte(`{"model":"gpt-5.6-sol"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceOnlyTargetsOpenAIAccounts(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + body := []byte(`{"model":"grok-4.1"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformComposite, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody( + ctx, + &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, + "grok-4.1", + body, + ) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresSupportedGroupPlatform(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + body := []byte(`{"model":"gpt-5.6-sol"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody( + ctx, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "gpt-5.6-sol", + body, + ) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + // TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule 验证默认配置 // 下客户端显式发送的 OpenAI 官方合法 tier 能透传到上游而不被静默剥离。 func TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule(t *testing.T) { diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index 195bdd459c..607d32a22a 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -122,6 +122,34 @@ func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) { } } +func TestWSResponseCreate_GroupForceInjectsMissingTier(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + frame := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) +} + +func TestWSResponseCreate_GroupForceDoesNotTouchOtherFrames(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + frame := []byte(`{"type":"response.cancel","model":"gpt-5.6-sol"}`) + + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, string(frame), string(updated)) +} + func TestWSResponseCreate_FlexPassThrough(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 07b32497bd..82c6a0faa8 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -305,6 +305,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, policyErr } responsesBody = updatedBody + responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) // 5. Get access token token, _, err := s.GetAccessToken(ctx, account) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index cfd0649154..68110ab493 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -291,6 +291,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, policyErr } responsesBody = updatedBody + responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) grokCacheIdentity := "" if account.Platform == PlatformGrok { grokIntentBody := responsesBody diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index d4bdbb67b9..2d0c91852b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1638,11 +1638,23 @@ func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicyS return nil } +func openAIGroupForcesFast(ctx context.Context, account *Account) bool { + if ctx == nil || account == nil || account.Platform != PlatformOpenAI { + return false + } + group, _ := ctx.Value(ctxkey.Group).(*Group) + return IsGroupContextValid(group) && groupSupportsOpenAIFast(group.Platform) && group.ForceOpenAIFast +} + // applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request // body. When action=filter it removes the service_tier field; when // action=block it returns (body, *OpenAIFastBlockedError). On pass it // normalizes the service_tier value (e.g. client alias "fast" → "priority"). -// action=force_priority rewrites any matched known tier to "priority". +// action=force_priority rewrites any matched known tier to "priority". Before +// the global policy is evaluated, a trusted request Group with +// ForceOpenAIFast enabled unconditionally sets service_tier to "priority". +// The global policy remains authoritative and may still pass, filter, or block +// that final value. // // Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 // 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 @@ -1653,6 +1665,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, if len(body) == 0 { return body, nil } + if openAIGroupForcesFast(ctx, account) { + updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) + if err != nil { + return body, fmt.Errorf("force group service_tier priority on body: %w", err) + } + body = updated + } rawTier := gjson.GetBytes(body, "service_tier").String() if rawTier == "" { return body, nil @@ -1726,6 +1745,7 @@ func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlocked // - filter: returns a copy with top-level service_tier removed // - force_priority: keeps service_tier and rewrites it to "priority" // - block: returns (frame, *OpenAIFastBlockedError) +// - Group ForceOpenAIFast: sets priority first, then applies the global rule // // Only frames whose "type" field strictly equals "response.create" are // inspected/mutated. Any other frame type — including the empty string — @@ -1764,6 +1784,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( if frameType != "response.create" { return frame, nil, nil } + if openAIGroupForcesFast(ctx, account) { + updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) + if err != nil { + return frame, nil, fmt.Errorf("force group service_tier priority in ws frame: %w", err) + } + frame = updated + } rawTier := gjson.GetBytes(frame, "service_tier").String() if rawTier == "" { return frame, nil, nil diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index fa750fa5b5..4053fe3e52 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" @@ -473,6 +474,38 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali require.NotContains(t, body, "\"name\":\"edit\"") } +func TestOpenAIGatewayService_OAuthPassthrough_GroupForceOpenAIFastInjectsMissingTier(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"test","input":"hi"}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 123, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, + RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, OpenAIFastTierPriority, *result.ServiceTier) +} + // 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、 // namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉 // 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。 From 7903716ad1386fd6fe008a5eab3f2aff8e87711c Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:43 +0900 Subject: [PATCH 27/56] Add frontend group Fast types --- frontend/src/types/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0a1b7183d8..ae86a3be2c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -611,6 +611,7 @@ export interface Group { } export interface AdminGroup extends Group { + force_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 @@ -775,6 +776,7 @@ export interface CreateGroupRequest { weekly_limit_usd?: number | null monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean + force_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean @@ -837,6 +839,7 @@ export interface UpdateGroupRequest { weekly_limit_usd?: number | null monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean + force_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean From 1a40e5690fb19234e67b2c4560cac8c4cb915bf2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:47 +0900 Subject: [PATCH 28/56] Localize the group Fast control --- frontend/src/i18n/locales/en/admin/overview.ts | 5 +++++ frontend/src/i18n/locales/zh/admin/overview.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index e91233132d..41a7781c8c 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1151,6 +1151,11 @@ export default { unsupportedMessage: 'This Sub2API server cannot generate the required Live attestation. Live will not work even if enabled. Continue anyway?', enableAnyway: 'Enable anyway' }, + openaiFast: { + title: 'OpenAI Fast mode', + force: 'Force Fast (priority)', + hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.' + }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index cbc930fa10..27cf6fdfd7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1149,6 +1149,11 @@ export default { unsupportedMessage: '当前 Sub2API 服务端无法生成 Live 所需的设备证明,即使开启也不能使用。是否仍然开启?', enableAnyway: '仍然开启' }, + openaiFast: { + title: 'OpenAI Fast 模式', + force: '强制使用 Fast(priority)', + hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。' + }, invalidRequestFallback: { title: '无效请求兜底分组', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', From 3fbce499d825d421dec933ecb5c7785da7420a22 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:51 +0900 Subject: [PATCH 29/56] Add group Fast form helpers --- .../admin/__tests__/groupsOpenAIFast.spec.ts | 36 +++++++++++++++++++ frontend/src/views/admin/groupsOpenAIFast.ts | 10 ++++++ 2 files changed, 46 insertions(+) create mode 100644 frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts create mode 100644 frontend/src/views/admin/groupsOpenAIFast.ts diff --git a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts new file mode 100644 index 0000000000..e94ee5a36c --- /dev/null +++ b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeGroupOpenAIFast, + supportsGroupOpenAIFast, +} from "../groupsOpenAIFast"; +import en from "@/i18n/locales/en/admin/overview"; +import zh from "@/i18n/locales/zh/admin/overview"; + +describe("groupsOpenAIFast", () => { + it("supports OpenAI and composite groups", () => { + expect(supportsGroupOpenAIFast("openai")).toBe(true); + expect(supportsGroupOpenAIFast("composite")).toBe(true); + expect(supportsGroupOpenAIFast("anthropic")).toBe(false); + }); + + it("clears stale enabled state on unsupported platforms", () => { + expect(normalizeGroupOpenAIFast("openai", true)).toBe(true); + expect(normalizeGroupOpenAIFast("composite", true)).toBe(true); + expect(normalizeGroupOpenAIFast("anthropic", true)).toBe(false); + expect(normalizeGroupOpenAIFast("openai", false)).toBe(false); + }); + + it("provides localized switch labels", () => { + expect(zh.groups.openaiFast).toMatchObject({ + title: expect.any(String), + force: expect.any(String), + hint: expect.stringContaining("service_tier=priority"), + }); + expect(en.groups.openaiFast).toMatchObject({ + title: expect.any(String), + force: expect.any(String), + hint: expect.stringContaining("service_tier=priority"), + }); + }); +}); diff --git a/frontend/src/views/admin/groupsOpenAIFast.ts b/frontend/src/views/admin/groupsOpenAIFast.ts new file mode 100644 index 0000000000..a4e3f544a2 --- /dev/null +++ b/frontend/src/views/admin/groupsOpenAIFast.ts @@ -0,0 +1,10 @@ +export function supportsGroupOpenAIFast(platform: string): boolean { + return platform === "openai" || platform === "composite"; +} + +export function normalizeGroupOpenAIFast( + platform: string, + enabled: boolean, +): boolean { + return supportsGroupOpenAIFast(platform) && enabled; +} From 17747df8423e1c9a584a0cd27784adfcd625c7ce Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 18:58:55 +0900 Subject: [PATCH 30/56] Add the group Fast admin switch --- frontend/src/views/admin/GroupsView.vue | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 612624904d..040cf22ef6 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1581,6 +1581,45 @@ + +
+

+ {{ t("admin.groups.openaiFast.title") }} +

+
+ + +
+

+ {{ t("admin.groups.openaiFast.hint") }} +

+
+
+ +
+

+ {{ t("admin.groups.openaiFast.title") }} +

+
+ + +
+

+ {{ t("admin.groups.openaiFast.hint") }} +

+
+
{ createForm.video_price_1080p = null; createForm.video_model_prices = createVideoModelPricesForm(); createForm.long_context_pricing_enabled = true; + createForm.force_openai_fast = false; createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; @@ -5982,6 +6067,10 @@ const handleCreateGroup = async () => { // 构建请求数据,包含模型路由配置 const requestData = { ...createGroupForm, + force_openai_fast: normalizeGroupOpenAIFast( + createForm.platform, + createForm.force_openai_fast, + ), model_pricing: groupPricingToAPI( createForm.model_pricing, createForm.platform, @@ -6110,6 +6199,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.monthly_limit_usd = group.monthly_limit_usd; editForm.long_context_pricing_enabled = group.long_context_pricing_enabled ?? true; + editForm.force_openai_fast = group.force_openai_fast ?? false; editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = @@ -6216,6 +6306,7 @@ const closeEditModal = () => { editForm.video_price_1080p = null; editForm.video_model_prices = createVideoModelPricesForm(); editForm.long_context_pricing_enabled = true; + editForm.force_openai_fast = false; editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null; @@ -6249,6 +6340,10 @@ const handleUpdateGroup = async () => { // 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除) const payload = { ...editForm, + force_openai_fast: normalizeGroupOpenAIFast( + editForm.platform, + editForm.force_openai_fast, + ), model_pricing: groupPricingToAPI( editForm.model_pricing, editForm.platform, From 0681aa2566025f6530438fa813db2687d5974986 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Sun, 23 Aug 2026 19:45:15 +0900 Subject: [PATCH 31/56] Cover Group Fast in native Responses --- .../service/openai_gateway_forward.go | 9 ++- .../service/openai_oauth_passthrough_test.go | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 304af59909..6ed06f1e8a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -625,7 +625,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } - if rawTier := requestView.ServiceTier; rawTier != "" { + rawTier := requestView.ServiceTier + if openAIGroupForcesFast(ctx, account) { + rawTier = OpenAIFastTierPriority + if requestView.ServiceTier != OpenAIFastTierPriority { + markPatchSet("service_tier", OpenAIFastTierPriority) + } + } + if rawTier != "" { if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" { action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier) switch action { diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 4053fe3e52..99b2e8f618 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -981,6 +981,73 @@ func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *te require.Contains(t, string(upstream.lastBody), `"stream":true`) } +func TestOpenAIGatewayService_OAuthLegacy_GroupForceOpenAIFastInjectsMissingTier(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") + inputBody := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"text","text":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_group_fast_legacy"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + svc.cfg = &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}} + svc.httpUpstream = upstream + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": false, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, inputBody) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, OpenAIFastTierPriority, *result.ServiceTier) +} + +func TestOpenAIGatewayService_OAuthLegacy_GroupForceStillHonorsGlobalFilter(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") + inputBody := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"text","text":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_group_fast_filter"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + svc.cfg = &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}} + svc.httpUpstream = upstream + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": false, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, inputBody) + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists()) + require.Nil(t, result.ServiceTier) +} + func TestOpenAIGatewayService_OAuthLegacy_UpstreamRequestIgnoresClientCancel(t *testing.T) { gin.SetMode(gin.TestMode) From c7cf84ad39a1344192df6274111e8fbd032897a3 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 25 Aug 2026 01:49:40 +0900 Subject: [PATCH 32/56] Preserve Group Fast in integration fixtures --- backend/internal/repository/fixtures_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index ca927be265..987881f143 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -90,6 +90,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetSubscriptionType(g.SubscriptionType). SetRateMultiplier(g.RateMultiplier). SetIsExclusive(g.IsExclusive). + SetForceOpenaiFast(g.ForceOpenAIFast). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) From eebf4e3ffbfbb7715e31eb141a8360f26c296edb Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:30:57 +0900 Subject: [PATCH 33/56] Add the free Fast migration --- .../migrations_schema_integration_test.go | 1 + .../migrations/233_group_free_openai_fast.sql | 5 +++++ .../group_free_openai_fast_migration_test.go | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 backend/migrations/233_group_free_openai_fast.sql create mode 100644 backend/migrations/group_free_openai_fast_migration_test.go diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 4d32cce370..57f06fd2a8 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -58,6 +58,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。 requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false) + requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false) // api_keys: key length should be 128 requireColumn(t, tx, "api_keys", "key", "character varying", 128, false) diff --git a/backend/migrations/233_group_free_openai_fast.sql b/backend/migrations/233_group_free_openai_fast.sql new file mode 100644 index 0000000000..ef84cd31ca --- /dev/null +++ b/backend/migrations/233_group_free_openai_fast.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN groups.free_openai_fast IS + 'Whether Fast/priority requests in this OpenAI/Composite group are billed to users at Standard price'; diff --git a/backend/migrations/group_free_openai_fast_migration_test.go b/backend/migrations/group_free_openai_fast_migration_test.go new file mode 100644 index 0000000000..0943e0306b --- /dev/null +++ b/backend/migrations/group_free_openai_fast_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupFreeOpenAIFastMigration(t *testing.T) { + content, err := FS.ReadFile("233_group_free_openai_fast.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE") + require.Contains(t, sql, "COMMENT ON COLUMN groups.free_openai_fast") +} From e7caedc22ab05bf7bb9a637c26d8c14464bd0bfd Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:30:59 +0900 Subject: [PATCH 34/56] Regenerate Group persistence for free Fast --- backend/ent/group.go | 13 ++++++- backend/ent/group/group.go | 10 ++++++ backend/ent/group/where.go | 15 ++++++++ backend/ent/group_create.go | 65 ++++++++++++++++++++++++++++++++++ backend/ent/group_update.go | 34 ++++++++++++++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++++++++++++++++++++++- backend/ent/runtime/runtime.go | 26 ++++++++------ backend/ent/schema/group.go | 3 ++ 9 files changed, 210 insertions(+), 13 deletions(-) diff --git a/backend/ent/group.go b/backend/ent/group.go index 29912a2133..61f5caa349 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -124,6 +124,8 @@ type Group struct { AllowLive bool `json:"allow_live,omitempty"` // 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority ForceOpenaiFast bool `json:"force_openai_fast,omitempty"` + // 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费 + FreeOpenaiFast bool `json:"free_openai_fast,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -254,7 +256,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) @@ -624,6 +626,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ForceOpenaiFast = value.Bool } + case group.FieldFreeOpenaiFast: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i]) + } else if value.Valid { + _m.FreeOpenaiFast = value.Bool + } case group.FieldRequireOauthOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i]) @@ -966,6 +974,9 @@ func (_m *Group) String() string { builder.WriteString("force_openai_fast=") builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast)) builder.WriteString(", ") + builder.WriteString("free_openai_fast=") + builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast)) + builder.WriteString(", ") builder.WriteString("require_oauth_only=") builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 87cdad3dff..b795a888c1 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -120,6 +120,8 @@ const ( FieldAllowLive = "allow_live" // FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database. FieldForceOpenaiFast = "force_openai_fast" + // FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database. + FieldFreeOpenaiFast = "free_openai_fast" // FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database. FieldRequireOauthOnly = "require_oauth_only" // FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database. @@ -269,6 +271,7 @@ var Columns = []string{ FieldAllowMessagesDispatch, FieldAllowLive, FieldForceOpenaiFast, + FieldFreeOpenaiFast, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -391,6 +394,8 @@ var ( DefaultAllowLive bool // DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field. DefaultForceOpenaiFast bool + // DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field. + DefaultFreeOpenaiFast bool // DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field. DefaultRequireOauthOnly bool // DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field. @@ -667,6 +672,11 @@ func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc() } +// ByFreeOpenaiFast orders the results by the free_openai_fast field. +func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc() +} + // ByRequireOauthOnly orders the results by the require_oauth_only field. func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 357c6921b8..aaebb4a363 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -295,6 +295,11 @@ func ForceOpenaiFast(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ. +func FreeOpenaiFast(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ. func RequireOauthOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) @@ -2285,6 +2290,16 @@ func ForceOpenaiFastNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + +// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field. func RequireOauthOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 9e0a846ee5..2089224707 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -718,6 +718,20 @@ func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate { return _c } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate { + _c.mutation.SetFreeOpenaiFast(v) + return _c +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate { + if v != nil { + _c.SetFreeOpenaiFast(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -1113,6 +1127,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultForceOpenaiFast _c.mutation.SetForceOpenaiFast(v) } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + v := group.DefaultFreeOpenaiFast + _c.mutation.SetFreeOpenaiFast(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1307,6 +1325,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.ForceOpenaiFast(); !ok { return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)} } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)} + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1585,6 +1606,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) _node.ForceOpenaiFast = value } + if value, ok := _c.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + _node.FreeOpenaiFast = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2655,6 +2680,18 @@ func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert { return u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert { + u.Set(group.FieldFreeOpenaiFast, v) + return u +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert { + u.SetExcluded(group.FieldFreeOpenaiFast) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3868,6 +3905,20 @@ func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5272,6 +5323,20 @@ func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index ed68bcacb9..44f26608d5 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1003,6 +1003,20 @@ func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1814,6 +1828,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -3141,6 +3158,20 @@ func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -3982,6 +4013,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 8aa1cc7a69..180de294f3 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -955,6 +955,7 @@ var ( {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, {Name: "allow_live", Type: field.TypeBool, Default: false}, {Name: "force_openai_fast", Type: field.TypeBool, Default: false}, + {Name: "free_openai_fast", Type: field.TypeBool, Default: false}, {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 6c22f3373c..dc724cb011 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22159,6 +22159,7 @@ type GroupMutation struct { allow_messages_dispatch *bool allow_live *bool force_openai_fast *bool + free_openai_fast *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24981,6 +24982,42 @@ func (m *GroupMutation) ResetForceOpenaiFast() { m.force_openai_fast = nil } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (m *GroupMutation) SetFreeOpenaiFast(b bool) { + m.free_openai_fast = &b +} + +// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation. +func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) { + v := m.free_openai_fast + if v == nil { + return + } + return *v, true +} + +// OldFreeOpenaiFast returns the old "free_openai_fast" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err) + } + return oldValue.FreeOpenaiFast, nil +} + +// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field. +func (m *GroupMutation) ResetFreeOpenaiFast() { + m.free_openai_fast = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -25810,7 +25847,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 63) + fields := make([]string, 0, 64) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25967,6 +26004,9 @@ func (m *GroupMutation) Fields() []string { if m.force_openai_fast != nil { fields = append(fields, group.FieldForceOpenaiFast) } + if m.free_openai_fast != nil { + fields = append(fields, group.FieldFreeOpenaiFast) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -26112,6 +26152,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AllowLive() case group.FieldForceOpenaiFast: return m.ForceOpenaiFast() + case group.FieldFreeOpenaiFast: + return m.FreeOpenaiFast() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -26247,6 +26289,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAllowLive(ctx) case group.FieldForceOpenaiFast: return m.OldForceOpenaiFast(ctx) + case group.FieldFreeOpenaiFast: + return m.OldFreeOpenaiFast(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -26642,6 +26686,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetForceOpenaiFast(v) return nil + case group.FieldFreeOpenaiFast: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetFreeOpenaiFast(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -27386,6 +27437,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldForceOpenaiFast: m.ResetForceOpenaiFast() return nil + case group.FieldFreeOpenaiFast: + m.ResetFreeOpenaiFast() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 8323961ebd..12e214a251 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1161,52 +1161,56 @@ func init() { groupDescForceOpenaiFast := groupFields[48].Descriptor() // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) + // groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field. + groupDescFreeOpenaiFast := groupFields[49].Descriptor() + // group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field. + group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[49].Descriptor() + groupDescRequireOauthOnly := groupFields[50].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[50].Descriptor() + groupDescRequirePrivacySet := groupFields[51].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[51].Descriptor() + groupDescDefaultMappedModel := groupFields[52].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[52].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[53].Descriptor() + groupDescModelsListConfig := groupFields[54].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[54].Descriptor() + groupDescRpmLimit := groupFields[55].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[55].Descriptor() + groupDescMaxReasoningEffort := groupFields[56].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[56].Descriptor() + groupDescReasoningEffortMappings := groupFields[57].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[57].Descriptor() + groupDescProfitControlEnabled := groupFields[58].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[58].Descriptor() + groupDescProfitMinMargin := groupFields[59].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[59].Descriptor() + groupDescProfitSafetyBuffer := groupFields[60].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 01efd0f5c3..a356447c3a 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -245,6 +245,9 @@ func (Group) Fields() []ent.Field { field.Bool("force_openai_fast"). Default(false). Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"), + field.Bool("free_openai_fast"). + Default(false). + Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"), field.Bool("require_oauth_only"). Default(false). Comment("仅允许非 apikey 类型账号关联到此分组"), From fb409787c68a38fe3e013c6c7ec431353fd127f4 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:00 +0900 Subject: [PATCH 35/56] Persist the free Fast group policy --- backend/internal/repository/group_repo.go | 2 ++ backend/internal/service/group.go | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 125456cd59..cc65e88e42 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -104,6 +104,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -281,6 +282,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 8703057377..71e2c3bf22 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -101,6 +101,7 @@ type Group struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority + FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费 RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string From 7dcf72846183028b7eb32864211f9f492884cc9a Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:02 +0900 Subject: [PATCH 36/56] Project free Fast into API key auth --- backend/internal/repository/api_key_repo.go | 2 ++ .../api_key_repo_openai_fast_projection_integration_test.go | 3 ++- backend/internal/repository/fixtures_integration_test.go | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index f1bd5c53d0..14d3aef271 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -211,6 +211,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, + group.FieldFreeOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -1009,6 +1010,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { AllowMessagesDispatch: g.AllowMessagesDispatch, AllowLive: g.AllowLive, ForceOpenAIFast: g.ForceOpenaiFast, + FreeOpenAIFast: g.FreeOpenaiFast, RequireOAuthOnly: g.RequireOauthOnly, RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, diff --git a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go index a3e00a541d..f3687c3070 100644 --- a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go +++ b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go @@ -17,7 +17,7 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { suffix := time.Now().UnixNano() group := mustCreateGroup(t, integrationEntClient, &service.Group{ Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI, - RateMultiplier: 1, ForceOpenAIFast: true, + RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) user := mustCreateUser(t, integrationEntClient, &service.User{ Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5, @@ -42,4 +42,5 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { require.NoError(t, err) require.NotNil(t, got.Group) require.True(t, got.Group.ForceOpenAIFast) + require.True(t, got.Group.FreeOpenAIFast) } diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 987881f143..005ec7d0ba 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -91,6 +91,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetRateMultiplier(g.RateMultiplier). SetIsExclusive(g.IsExclusive). SetForceOpenaiFast(g.ForceOpenAIFast). + SetFreeOpenaiFast(g.FreeOpenAIFast). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) From 636d6be69051f26eb9e9b8fc4a6a14e7bd04d774 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:30 +0900 Subject: [PATCH 37/56] Expose free Fast in admin requests --- backend/internal/handler/admin/group_handler.go | 4 ++++ .../handler/admin/group_handler_openai_fast_test.go | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 13f7d2fd8c..1f5f479a9f 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -148,6 +148,7 @@ type CreateGroupRequest struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -218,6 +219,7 @@ type UpdateGroupRequest struct { AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` ForceOpenAIFast *bool `json:"force_openai_fast"` + FreeOpenAIFast *bool `json:"free_openai_fast"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -553,6 +555,7 @@ func (h *GroupHandler) Create(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -683,6 +686,7 @@ func (h *GroupHandler) Update(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, diff --git a/backend/internal/handler/admin/group_handler_openai_fast_test.go b/backend/internal/handler/admin/group_handler_openai_fast_test.go index 22a9a53500..54ff52f9f9 100644 --- a/backend/internal/handler/admin/group_handler_openai_fast_test.go +++ b/backend/internal/handler/admin/group_handler_openai_fast_test.go @@ -9,15 +9,19 @@ import ( func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) { var createReq CreateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true}`), &createReq)) + require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq)) require.True(t, createReq.ForceOpenAIFast) + require.True(t, createReq.FreeOpenAIFast) var updateReq UpdateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false}`), &updateReq)) + require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq)) require.NotNil(t, updateReq.ForceOpenAIFast) require.False(t, *updateReq.ForceOpenAIFast) + require.NotNil(t, updateReq.FreeOpenAIFast) + require.False(t, *updateReq.FreeOpenAIFast) var omitted UpdateGroupRequest require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) require.Nil(t, omitted.ForceOpenAIFast) + require.Nil(t, omitted.FreeOpenAIFast) } From 08d6c153b79b13ee0d4dde26b984808bb6a510e2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:32 +0900 Subject: [PATCH 38/56] Return free Fast in admin responses --- backend/internal/handler/dto/group_mapper_openai_fast_test.go | 4 +++- backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/dto/group_mapper_openai_fast_test.go b/backend/internal/handler/dto/group_mapper_openai_fast_test.go index c5aee931dc..6ad07b061f 100644 --- a/backend/internal/handler/dto/group_mapper_openai_fast_test.go +++ b/backend/internal/handler/dto/group_mapper_openai_fast_test.go @@ -10,14 +10,16 @@ import ( func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) { group := &service.Group{ - ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, + ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } userJSON, err := json.Marshal(GroupFromService(group)) require.NoError(t, err) require.NotContains(t, string(userJSON), "force_openai_fast") + require.NotContains(t, string(userJSON), "free_openai_fast") adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) require.NoError(t, err) require.Contains(t, string(adminJSON), `"force_openai_fast":true`) + require.Contains(t, string(adminJSON), `"free_openai_fast":true`) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 63aa423cbb..9f200de152 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { out := &AdminGroup{ Group: groupFromServiceBase(g), ForceOpenAIFast: g.ForceOpenAIFast, + FreeOpenAIFast: g.FreeOpenAIFast, ProfitControlEnabled: g.ProfitControlEnabled, ProfitMinMargin: g.ProfitMinMargin, ProfitSafetyBuffer: g.ProfitSafetyBuffer, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index f07592c2ca..95ca33eca3 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -166,6 +166,8 @@ type AdminGroup struct { Group // ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。 ForceOpenAIFast bool `json:"force_openai_fast"` + // FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。 + FreeOpenAIFast bool `json:"free_openai_fast"` // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 From fd55f3248f29e96b02624e8bd89764e8b0cbbd43 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:33 +0900 Subject: [PATCH 39/56] Manage free Fast through group lifecycle --- backend/internal/service/admin_group.go | 5 +++++ backend/internal/service/admin_service.go | 2 ++ backend/internal/service/admin_service_group_test.go | 8 ++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 032c7024bc..9275cf3df7 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -303,6 +303,7 @@ func sanitizeGroupOpenAIFast(group *Group) { if group == nil || !groupSupportsOpenAIFast(group.Platform) { if group != nil { group.ForceOpenAIFast = false + group.FreeOpenAIFast = false } } } @@ -513,6 +514,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, ForceOpenAIFast: input.ForceOpenAIFast, + FreeOpenAIFast: input.FreeOpenAIFast, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -879,6 +881,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.ForceOpenAIFast != nil { group.ForceOpenAIFast = *input.ForceOpenAIFast } + if input.FreeOpenAIFast != nil { + group.FreeOpenAIFast = *input.FreeOpenAIFast + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0195bc01c7..3392e79f15 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -267,6 +267,7 @@ type CreateGroupInput struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool + FreeOpenAIFast bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -343,6 +344,7 @@ type UpdateGroupInput struct { AllowMessagesDispatch *bool AllowLive *bool ForceOpenAIFast *bool + FreeOpenAIFast *bool DefaultMappedModel *string RequireOAuthOnly *bool RequirePrivacySet *bool diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 02694cdfde..db34d09e99 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1097,19 +1097,20 @@ func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing svc := &adminServiceImpl{groupRepo: repo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, + Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) require.NoError(t, err) require.NotNil(t, group) require.Equal(t, tt.want, repo.created.ForceOpenAIFast) + require.Equal(t, tt.want, repo.created.FreeOpenAIFast) }) } } func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) { existingGroup := &Group{ - ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } repo := &groupRepoStubForAdmin{getByID: existingGroup} svc := &adminServiceImpl{groupRepo: repo} @@ -1121,6 +1122,7 @@ func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *te require.NoError(t, err) require.NotNil(t, group) require.False(t, repo.updated.ForceOpenAIFast) + require.False(t, repo.updated.FreeOpenAIFast) } func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) { @@ -1134,11 +1136,13 @@ func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ ForceOpenAIFast: &enabled, + FreeOpenAIFast: &enabled, }) require.NoError(t, err) require.NotNil(t, group) require.True(t, repo.updated.ForceOpenAIFast) + require.True(t, repo.updated.FreeOpenAIFast) require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs) } From 8224434d218e87447962044d67fd6accbbb2ef13 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:35 +0900 Subject: [PATCH 40/56] Preserve free Fast when duplicating groups --- backend/internal/service/admin_group_duplicate.go | 1 + backend/internal/service/admin_group_duplicate_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 09fcbb8ec7..e1c4165b0f 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -145,6 +145,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { AllowMessagesDispatch: source.AllowMessagesDispatch, AllowLive: source.AllowLive, ForceOpenAIFast: source.ForceOpenAIFast, + FreeOpenAIFast: source.FreeOpenAIFast, RequireOAuthOnly: source.RequireOAuthOnly, RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 4a13c53fce..f33ad8bc8b 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -167,6 +167,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, + FreeOpenAIFast: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", @@ -214,6 +215,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.ModelRouting, duplicate.ModelRouting) require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast) + require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast) require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) From e619ca386eef8962d8b8edea431ff837a361fc45 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:36 +0900 Subject: [PATCH 41/56] Carry free Fast through auth snapshots --- backend/internal/service/api_key_auth_cache.go | 1 + backend/internal/service/api_key_auth_cache_impl.go | 4 +++- .../internal/service/api_key_auth_cache_openai_fast_test.go | 5 +++-- backend/internal/service/api_key_auth_cache_profit_test.go | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 0bdbfc8293..be08213d17 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -104,6 +104,7 @@ type APIKeyAuthGroupSnapshot struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index f267a3fe89..800c888f68 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 21 // v21: group force_openai_fast field +const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field type apiKeyAuthCacheConfig struct { l1Size int @@ -419,6 +419,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, AllowLive: apiKey.Group.AllowLive, ForceOpenAIFast: apiKey.Group.ForceOpenAIFast, + FreeOpenAIFast: apiKey.Group.FreeOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -518,6 +519,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, AllowLive: snapshot.Group.AllowLive, ForceOpenAIFast: snapshot.Group.ForceOpenAIFast, + FreeOpenAIFast: snapshot.Group.FreeOpenAIFast, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go index 18a1d3ca9d..07e906e4e7 100644 --- a/backend/internal/service/api_key_auth_cache_openai_fast_test.go +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -15,7 +15,7 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { User: &User{ID: 40, Status: StatusActive}, Group: &Group{ ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, - Hydrated: true, ForceOpenAIFast: true, + Hydrated: true, ForceOpenAIFast: true, FreeOpenAIFast: true, }, } svc := &APIKeyService{} @@ -31,5 +31,6 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { require.NotNil(t, materialized.Group) require.True(t, materialized.Group.Hydrated) require.True(t, materialized.Group.ForceOpenAIFast) - require.Equal(t, 21, cached.Snapshot.Version) + require.True(t, materialized.Group.FreeOpenAIFast) + require.Equal(t, 22, cached.Snapshot.Version) } diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index d69d21d869..51347e0eab 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 21, snapshot.Version, "v21 起认证快照携带分组 Fast 强制开关") + require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) From e6722126ba904d6515cfa27ed35ea32b5dc2dad2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:01 +0900 Subject: [PATCH 42/56] Add frontend free Fast types --- frontend/src/types/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ae86a3be2c..018d054f5d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -612,6 +612,7 @@ export interface Group { export interface AdminGroup extends Group { force_openai_fast: boolean + free_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 @@ -777,6 +778,7 @@ export interface CreateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean @@ -840,6 +842,7 @@ export interface UpdateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean From 498e06c5821d5e7cf9afde50d28344cba5a41407 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:03 +0900 Subject: [PATCH 43/56] Localize the free Fast control --- frontend/src/i18n/locales/en/admin/overview.ts | 4 +++- frontend/src/i18n/locales/zh/admin/overview.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 41a7781c8c..87c38ebe4e 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1154,7 +1154,9 @@ export default { openaiFast: { title: 'OpenAI Fast mode', force: 'Force Fast (priority)', - hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.' + hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.', + free: 'Free Fast', + freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.' }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 27cf6fdfd7..d4bb80c9bf 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1152,7 +1152,9 @@ export default { openaiFast: { title: 'OpenAI Fast 模式', force: '强制使用 Fast(priority)', - hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。' + hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。', + free: '免费 Fast', + freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。' }, invalidRequestFallback: { title: '无效请求兜底分组', From 4df6b0636bdf251142a83ebaf83831ba59f89e9b Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:04 +0900 Subject: [PATCH 44/56] Add the free Fast group switch --- frontend/src/views/admin/GroupsView.vue | 71 +++++++++++++++++++ .../admin/__tests__/groupsOpenAIFast.spec.ts | 4 ++ 2 files changed, 75 insertions(+) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 040cf22ef6..62bb9998ca 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1618,6 +1618,35 @@

{{ t("admin.groups.openaiFast.hint") }}

+
+ + +
+

+ {{ t("admin.groups.openaiFast.freeHint") }} +

@@ -3385,6 +3414,35 @@

{{ t("admin.groups.openaiFast.hint") }}

+
+ + +
+

+ {{ t("admin.groups.openaiFast.freeHint") }} +

@@ -5130,6 +5188,7 @@ const createForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5492,6 +5551,7 @@ const editForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5967,6 +6027,7 @@ const closeCreateModal = () => { createForm.video_model_prices = createVideoModelPricesForm(); createForm.long_context_pricing_enabled = true; createForm.force_openai_fast = false; + createForm.free_openai_fast = false; createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; @@ -6071,6 +6132,10 @@ const handleCreateGroup = async () => { createForm.platform, createForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + createForm.platform, + createForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( createForm.model_pricing, createForm.platform, @@ -6200,6 +6265,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.long_context_pricing_enabled = group.long_context_pricing_enabled ?? true; editForm.force_openai_fast = group.force_openai_fast ?? false; + editForm.free_openai_fast = group.free_openai_fast ?? false; editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = @@ -6307,6 +6373,7 @@ const closeEditModal = () => { editForm.video_model_prices = createVideoModelPricesForm(); editForm.long_context_pricing_enabled = true; editForm.force_openai_fast = false; + editForm.free_openai_fast = false; editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null; @@ -6344,6 +6411,10 @@ const handleUpdateGroup = async () => { editForm.platform, editForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + editForm.platform, + editForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( editForm.model_pricing, editForm.platform, diff --git a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts index e94ee5a36c..a0333e0a5a 100644 --- a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts @@ -26,11 +26,15 @@ describe("groupsOpenAIFast", () => { title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); expect(en.groups.openaiFast).toMatchObject({ title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); }); }); From 9908d3ca2fe44b004ec2242806e5865cc09986e2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:06 +0900 Subject: [PATCH 45/56] Bill free Fast requests at Standard price --- .../openai_gateway_record_usage_test.go | 71 +++++++++++++++++++ .../internal/service/openai_gateway_usage.go | 44 ++++++++++++ 2 files changed, 115 insertions(+) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b65f55c71c..8b6497a2cb 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3071,6 +3071,77 @@ func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract( require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) } +func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(77) + serviceTier := "priority" + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + inputPrice := 0.001 + outputPrice := 0.002 + fastMultiplier := 3.0 + apiKey := &APIKey{ + ID: 1020, + GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, RateMultiplier: 0.5, FreeOpenAIFast: true, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, + BillingMode: BillingModeToken, + InputPrice: &inputPrice, + OutputPrice: &outputPrice, + FastMultiplier: &fastMultiplier, + }}, + }, + } + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_free_fast", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens}, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2020}, + Account: &Account{ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12) + + standardTotal := float64(tokens.InputTokens)*inputPrice + float64(tokens.OutputTokens)*outputPrice + require.InDelta(t, standardTotal*fastMultiplier, usageRepo.lastLog.TotalCost, 1e-10) + require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10) +} + +func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) { + apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}} + + require.True(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "priority", + )) + require.False(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + "priority", + )) +} + func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6fb688353a..cb998b55fa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -132,6 +132,24 @@ func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { return timezone.Now() } +func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { + if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { + return false + } + if account == nil || !account.IsOpenAI() { + return false + } + if !groupSupportsOpenAIFast(apiKey.Group.Platform) { + return false + } + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + return true + default: + return false + } +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -277,6 +295,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } } + // Free Fast changes only the customer charge. Keep priority TotalCost and + // service_tier for upstream accounting, but evaluate ActualCost once more at + // the Standard tier using the same channel, peak, and long-context policy. + if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) { + standardCost, standardErr := s.calculateOpenAIRecordUsageCost( + ctx, + result, + apiKey, + billingModels, + multiplier, + imageMultiplier, + videoMultiplier, + baseMultiplier, + tokens, + "", + longContextBillingGate, + pricingAt, + ) + if standardErr != nil { + return standardErr + } + if cost != nil && standardCost != nil { + cost.ActualCost = standardCost.ActualCost + } + } + // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() billingType := BillingTypeBalance From aa7a811e66489e132d495eb5f7eb1425fcdb61b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 1 Sep 2026 10:15:45 +0800 Subject: [PATCH 46/56] feat(groups): add deny-or-downgrade control for reasoning effort ceiling OpenAI/Composite groups can now reject requests that exceed the max reasoning effort instead of always rewriting them down to the ceiling. Existing groups keep the previous auto-downgrade behavior. --- backend/ent/group.go | 13 +- backend/ent/group/group.go | 12 ++ backend/ent/group/where.go | 70 +++++++++++ backend/ent/group_create.go | 70 +++++++++++ backend/ent/group_update.go | 44 +++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++- backend/ent/runtime/runtime.go | 14 ++- backend/ent/schema/group.go | 4 + .../internal/handler/admin/group_handler.go | 6 + .../internal/handler/composite_platform.go | 31 +++-- .../handler/composite_platform_test.go | 23 +++- backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 + .../handler/openai_chat_completions.go | 5 +- .../handler/openai_gateway_handler.go | 20 +-- backend/internal/handler/ops_error_logger.go | 1 + .../internal/handler/ops_error_logger_test.go | 15 +++ backend/internal/repository/api_key_repo.go | 2 + backend/internal/repository/group_repo.go | 2 + backend/internal/server/api_contract_test.go | 1 + backend/internal/service/admin_group.go | 12 ++ .../internal/service/admin_group_duplicate.go | 7 +- .../service/admin_group_duplicate_test.go | 24 ++-- backend/internal/service/admin_service.go | 4 + .../admin_service_composite_group_test.go | 19 +-- .../service/admin_service_group_test.go | 14 ++- .../internal/service/api_key_auth_cache.go | 2 + .../service/api_key_auth_cache_impl.go | 2 + .../service/api_key_service_cache_test.go | 16 +-- backend/internal/service/group.go | 3 + .../service/openai_gateway_messages.go | 11 +- .../openai_gateway_messages_chat_fallback.go | 12 +- ...nai_gateway_messages_chat_fallback_test.go | 4 +- .../service/openai_reasoning_effort_policy.go | 117 ++++++++++++++++-- .../openai_reasoning_effort_policy_test.go | 75 +++++++++-- .../internal/service/openai_ws_forwarder.go | 3 + .../service/openai_ws_forwarder_ingress.go | 8 +- .../openai_ws_v2_passthrough_adapter.go | 16 +-- .../232_group_reasoning_effort_over_limit.sql | 5 + .../group/ReasoningEffortPolicyFields.vue | 37 ++++++ .../src/i18n/locales/en/admin/overview.ts | 6 +- .../src/i18n/locales/zh/admin/overview.ts | 6 +- frontend/src/types/index.ts | 3 + frontend/src/views/admin/GroupsView.vue | 25 ++++ .../__tests__/groupsReasoningEffort.spec.ts | 21 ++++ .../src/views/admin/groupsReasoningEffort.ts | 11 ++ 47 files changed, 751 insertions(+), 105 deletions(-) create mode 100644 backend/migrations/232_group_reasoning_effort_over_limit.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 8f30c26470..f08c511391 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -136,6 +136,8 @@ type Group struct { RpmLimit int `json:"rpm_limit,omitempty"` // OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` + // 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问 + MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` // OpenAI reasoning effort 自定义精确映射;先映射再应用上限 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"` // 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池 @@ -258,7 +260,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort: + case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -662,6 +664,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.MaxReasoningEffort = value.String } + case group.FieldMaxReasoningEffortOverLimit: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i]) + } else if value.Valid { + _m.MaxReasoningEffortOverLimit = value.String + } case group.FieldReasoningEffortMappings: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i]) @@ -976,6 +984,9 @@ func (_m *Group) String() string { builder.WriteString("max_reasoning_effort=") builder.WriteString(_m.MaxReasoningEffort) builder.WriteString(", ") + builder.WriteString("max_reasoning_effort_over_limit=") + builder.WriteString(_m.MaxReasoningEffortOverLimit) + builder.WriteString(", ") builder.WriteString("reasoning_effort_mappings=") builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 5ec04d32ae..9e3a304f04 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -132,6 +132,8 @@ const ( FieldRpmLimit = "rpm_limit" // FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database. FieldMaxReasoningEffort = "max_reasoning_effort" + // FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database. + FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit" // FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database. FieldReasoningEffortMappings = "reasoning_effort_mappings" // FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database. @@ -273,6 +275,7 @@ var Columns = []string{ FieldModelsListConfig, FieldRpmLimit, FieldMaxReasoningEffort, + FieldMaxReasoningEffortOverLimit, FieldReasoningEffortMappings, FieldProfitControlEnabled, FieldProfitMinMargin, @@ -404,6 +407,10 @@ var ( DefaultMaxReasoningEffort string // MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. MaxReasoningEffortValidator func(string) error + // DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field. + DefaultMaxReasoningEffortOverLimit string + // MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. + MaxReasoningEffortOverLimitValidator func(string) error // DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field. DefaultReasoningEffortMappings []domain.ReasoningEffortMapping // DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field. @@ -682,6 +689,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc() } +// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field. +func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc() +} + // ByProfitControlEnabled orders the results by the profit_control_enabled field. func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 685a48a922..0ed5281982 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -315,6 +315,11 @@ func MaxReasoningEffort(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v)) } +// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ. +func MaxReasoningEffortOverLimit(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v)) +} + // ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ. func ProfitControlEnabled(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v)) @@ -2460,6 +2465,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group { return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v)) } +// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...)) +} + +// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...)) +} + +// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v)) +} + +// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field. +func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v)) +} + // ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field. func ProfitControlEnabledEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index e5e92ccd3f..e7a0837393 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -802,6 +802,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate { return _c } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate { + _c.mutation.SetMaxReasoningEffortOverLimit(v) + return _c +} + +// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil. +func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate { + if v != nil { + _c.SetMaxReasoningEffortOverLimit(*v) + } + return _c +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate { _c.mutation.SetReasoningEffortMappings(v) @@ -1123,6 +1137,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultMaxReasoningEffort _c.mutation.SetMaxReasoningEffort(v) } + if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok { + v := group.DefaultMaxReasoningEffortOverLimit + _c.mutation.SetMaxReasoningEffortOverLimit(v) + } if _, ok := _c.mutation.ReasoningEffortMappings(); !ok { v := group.DefaultReasoningEffortMappings _c.mutation.SetReasoningEffortMappings(v) @@ -1317,6 +1335,14 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} } } + if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok { + return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)} + } + if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok { + if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)} + } + } if _, ok := _c.mutation.ReasoningEffortMappings(); !ok { return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)} } @@ -1588,6 +1614,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) _node.MaxReasoningEffort = value } + if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok { + _spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value) + _node.MaxReasoningEffortOverLimit = value + } if value, ok := _c.mutation.ReasoningEffortMappings(); ok { _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) _node.ReasoningEffortMappings = value @@ -2708,6 +2738,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert { return u } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert { + u.Set(group.FieldMaxReasoningEffortOverLimit, v) + return u +} + +// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create. +func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert { + u.SetExcluded(group.FieldMaxReasoningEffortOverLimit) + return u +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert { u.Set(group.FieldReasoningEffortMappings, v) @@ -3922,6 +3964,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne { }) } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetMaxReasoningEffortOverLimit(v) + }) +} + +// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateMaxReasoningEffortOverLimit() + }) +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5312,6 +5368,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk { }) } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetMaxReasoningEffortOverLimit(v) + }) +} + +// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateMaxReasoningEffortOverLimit() + }) +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 41e06b4471..d1dbb8975b 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1094,6 +1094,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate { return _u } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate { + _u.mutation.SetMaxReasoningEffortOverLimit(v) + return _u +} + +// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate { + if v != nil { + _u.SetMaxReasoningEffortOverLimit(*v) + } + return _u +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate { _u.mutation.SetReasoningEffortMappings(v) @@ -1487,6 +1501,11 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} } } + if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok { + if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)} + } + } return nil } @@ -1821,6 +1840,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.MaxReasoningEffort(); ok { _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) } + if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok { + _spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value) + } if value, ok := _u.mutation.ReasoningEffortMappings(); ok { _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) } @@ -3215,6 +3237,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO return _u } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne { + _u.mutation.SetMaxReasoningEffortOverLimit(v) + return _u +} + +// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne { + if v != nil { + _u.SetMaxReasoningEffortOverLimit(*v) + } + return _u +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne { _u.mutation.SetReasoningEffortMappings(v) @@ -3621,6 +3657,11 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} } } + if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok { + if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)} + } + } return nil } @@ -3972,6 +4013,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.MaxReasoningEffort(); ok { _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) } + if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok { + _spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value) + } if value, ok := _u.mutation.ReasoningEffortMappings(); ok { _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 270eecd87a..067d3cca0e 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -961,6 +961,7 @@ var ( {Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rpm_limit", Type: field.TypeInt, Default: 0}, {Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""}, + {Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"}, {Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "profit_control_enabled", Type: field.TypeBool, Default: false}, {Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 54d284ad97..03387ca839 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22166,6 +22166,7 @@ type GroupMutation struct { rpm_limit *int addrpm_limit *int max_reasoning_effort *string + max_reasoning_effort_over_limit *string reasoning_effort_mappings *[]domain.ReasoningEffortMapping appendreasoning_effort_mappings []domain.ReasoningEffortMapping profit_control_enabled *bool @@ -25216,6 +25217,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() { m.max_reasoning_effort = nil } +// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field. +func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) { + m.max_reasoning_effort_over_limit = &s +} + +// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation. +func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) { + v := m.max_reasoning_effort_over_limit + if v == nil { + return + } + return *v, true +} + +// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err) + } + return oldValue.MaxReasoningEffortOverLimit, nil +} + +// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field. +func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() { + m.max_reasoning_effort_over_limit = nil +} + // SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) { m.reasoning_effort_mappings = &dem @@ -25773,7 +25810,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 62) + fields := make([]string, 0, 63) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25948,6 +25985,9 @@ func (m *GroupMutation) Fields() []string { if m.max_reasoning_effort != nil { fields = append(fields, group.FieldMaxReasoningEffort) } + if m.max_reasoning_effort_over_limit != nil { + fields = append(fields, group.FieldMaxReasoningEffortOverLimit) + } if m.reasoning_effort_mappings != nil { fields = append(fields, group.FieldReasoningEffortMappings) } @@ -26084,6 +26124,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.RpmLimit() case group.FieldMaxReasoningEffort: return m.MaxReasoningEffort() + case group.FieldMaxReasoningEffortOverLimit: + return m.MaxReasoningEffortOverLimit() case group.FieldReasoningEffortMappings: return m.ReasoningEffortMappings() case group.FieldProfitControlEnabled: @@ -26217,6 +26259,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldRpmLimit(ctx) case group.FieldMaxReasoningEffort: return m.OldMaxReasoningEffort(ctx) + case group.FieldMaxReasoningEffortOverLimit: + return m.OldMaxReasoningEffortOverLimit(ctx) case group.FieldReasoningEffortMappings: return m.OldReasoningEffortMappings(ctx) case group.FieldProfitControlEnabled: @@ -26640,6 +26684,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetMaxReasoningEffort(v) return nil + case group.FieldMaxReasoningEffortOverLimit: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMaxReasoningEffortOverLimit(v) + return nil case group.FieldReasoningEffortMappings: v, ok := value.([]domain.ReasoningEffortMapping) if !ok { @@ -27353,6 +27404,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldMaxReasoningEffort: m.ResetMaxReasoningEffort() return nil + case group.FieldMaxReasoningEffortOverLimit: + m.ResetMaxReasoningEffortOverLimit() + return nil case group.FieldReasoningEffortMappings: m.ResetReasoningEffortMappings() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 088b876b96..2f76855b5d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1189,20 +1189,26 @@ func init() { group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) + // groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field. + groupDescMaxReasoningEffortOverLimit := groupFields[55].Descriptor() + // group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field. + group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string) + // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. + group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[55].Descriptor() + groupDescReasoningEffortMappings := groupFields[56].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[56].Descriptor() + groupDescProfitControlEnabled := groupFields[57].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[57].Descriptor() + groupDescProfitMinMargin := groupFields[58].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[58].Descriptor() + groupDescProfitSafetyBuffer := groupFields[59].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index f195ac51db..c6d93b70e8 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -271,6 +271,10 @@ func (Group) Fields() []ent.Field { MaxLen(20). Default(""). Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"), + field.String("max_reasoning_effort_over_limit"). + MaxLen(20). + Default("downgrade"). + Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"), field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}). Default([]domain.ReasoningEffortMapping{}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 2fcd69a9b2..97a84a4c54 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -156,6 +156,8 @@ type CreateGroupRequest struct { RPMLimit int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` + // 超过上限时的访问控制:downgrade(默认)或 deny。 + MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` // OpenAI/Codex 推理强度精确映射。 ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) @@ -225,6 +227,8 @@ type UpdateGroupRequest struct { RPMLimit *int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 MaxReasoningEffort *string `json:"max_reasoning_effort"` + // 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。 + MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"` // nil 不修改,空数组清空,非空数组替换。 ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) @@ -557,6 +561,7 @@ func (h *GroupHandler) Create(c *gin.Context) { ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, + MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) @@ -686,6 +691,7 @@ func (h *GroupHandler) Update(c *gin.Context) { ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, + MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index fad5e12b56..49e87cb332 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -1,6 +1,7 @@ package handler import ( + "net/http" "strings" "github.com/Wei-Shaw/sub2api/internal/service" @@ -59,17 +60,17 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string { return apiKey.Group.Platform } -func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) { +func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) { if apiKey == nil || apiKey.Group == nil { - return "", nil, false + return "", nil, "", false } if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { - return "", nil, false + return "", nil, "", false } if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { - return "", nil, false + return "", nil, "", false } - return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true + return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true } func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) { @@ -100,13 +101,21 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request result.RequestedReasoningEffort = requested } -func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) { +func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) { bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String())) - maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) if !ok { - return body, false + return body, false, nil } - return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings) + return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} + +func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) { + if c == nil || err == nil || write == nil { + return + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) + write(c, http.StatusForbidden, "permission_error", err.Error()) } func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { @@ -121,9 +130,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" { return } - maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) if !ok { return } - c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings)) + c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit)) } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 4d2c0f583f..73db844e7e 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -117,7 +117,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder()) openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI)) - got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body) + got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body) + require.NoError(t, err) require.True(t, changed) require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got)) requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context()) @@ -125,7 +126,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { require.Equal(t, "max", *requested) bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`)) - bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body) + bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body) + require.NoError(t, err) require.True(t, changed) require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound)) @@ -133,14 +135,27 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil) omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI)) bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`)) - omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body) + omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body) + require.NoError(t, err) require.False(t, changed) require.Equal(t, body, omitted) + denyGroup := *group + denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny + denyAPIKey := &service.APIKey{Group: &denyGroup} + denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI)) + _, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body) + require.Error(t, err) + var overLimit *service.ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok)) - got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body) + got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body) + require.NoError(t, err) require.False(t, changed) require.Equal(t, body, got) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1c02cd69a3..2afe2243de 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -221,6 +221,7 @@ func groupFromServiceBase(g *service.Group) Group { RequirePrivacySet: g.RequirePrivacySet, RPMLimit: g.RPMLimit, MaxReasoningEffort: g.MaxReasoningEffort, + MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit, ReasoningEffortMappings: g.ReasoningEffortMappings, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 5ac667e171..d0b6ba78bd 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -153,6 +153,8 @@ type Group struct { RPMLimit int `json:"rpm_limit"` // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` + // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 + MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"` diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index be75ab8dea..e74b11b9b6 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } - if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) + return + } else if changed { body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 40763b1976..c9fa07a14f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -435,7 +435,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } - if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) + return + } else if changed { body = cappedBody } if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed { @@ -2530,7 +2533,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.Int("candidate_count", scheduleDecision.CandidateCount), ) - maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) + maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) var requestPayloadHash string var turnStartsMu sync.Mutex turnStarts := make(map[int]time.Time, 4) @@ -2557,12 +2560,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ - ClientLifecycleContext: clientLifecycleCtx, - InitialRequestModel: reqModel, - InitialTurnStartedAt: firstTurnStartedAt, - MaxReasoningEffort: maxReasoningEffort, - ReasoningEffortMappings: reasoningEffortMappings, - TurnStarted: recordTurnStart, + ClientLifecycleContext: clientLifecycleCtx, + InitialRequestModel: reqModel, + InitialTurnStartedAt: firstTurnStartedAt, + MaxReasoningEffort: maxReasoningEffort, + MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, + ReasoningEffortMappings: reasoningEffortMappings, + TurnStarted: recordTurnStart, BeforeRequest: func(turn int, payload []byte, originalModel string) error { c.Set(securityAuditWSTurnContextKey, turn) service.BeginOpsStreamTurn(c, turn) diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 41a1ea0583..add82f77bf 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool { strings.Contains(msg, "this group is restricted to claude code clients") || strings.Contains(msg, "this group does not allow /v1/messages dispatch") || strings.Contains(msg, "image generation is not enabled for this group") || + (strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) || strings.Contains(msg, "token counting is not supported for this platform") || strings.Contains(msg, "images api is not supported for this platform") || (strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) || diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 640e8efa91..bd29d103bf 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { wantErrType: "permission_error", wantPhase: "request", }, + { + name: "group reasoning effort over limit deny", + errType: "permission_error", + message: `reasoning effort "high" exceeds this group's limit of "low"`, + code: "", + status: http.StatusForbidden, + wantErrType: "permission_error", + wantPhase: "request", + }, { name: "route token counting platform unsupported", errType: "not_found_error", @@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) { code: "403", status: http.StatusForbidden, }, + { + name: "provider reasoning effort over limit shaped error", + message: `reasoning effort "high" exceeds this group's limit of "low"`, + code: "403", + status: http.StatusForbidden, + }, { name: "provider token counting unsupported shaped error", message: "Token counting is not supported for this platform", diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 6e517f4524..bfe810f5ba 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -215,6 +215,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldModelsListConfig, group.FieldRpmLimit, group.FieldMaxReasoningEffort, + group.FieldMaxReasoningEffortOverLimit, group.FieldReasoningEffortMappings, group.FieldPeakRateEnabled, group.FieldPeakStart, @@ -1014,6 +1015,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { ModelsListConfig: g.ModelsListConfig, RPMLimit: g.RpmLimit, MaxReasoningEffort: g.MaxReasoningEffort, + MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit, ReasoningEffortMappings: g.ReasoningEffortMappings, PeakRateEnabled: g.PeakRateEnabled, PeakStart: g.PeakStart, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index b5de46353d..69a8ac0eed 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -110,6 +110,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetModelsListConfig(groupIn.ModelsListConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). + SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). SetReasoningEffortMappings(groupIn.ReasoningEffortMappings). SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). @@ -286,6 +287,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetModelsListConfig(groupIn.ModelsListConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). + SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). SetReasoningEffortMappings(groupIn.ReasoningEffortMappings). SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index c7a16945f0..d6a96e5ecf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -392,6 +392,7 @@ func TestAPIContracts(t *testing.T) { "require_oauth_only": false, "require_privacy_set": false, "max_reasoning_effort": "", + "max_reasoning_effort_over_limit": "", "reasoning_effort_mappings": null, "rpm_limit": 0, "created_at": "2025-01-02T03:04:05Z", diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 9e29c30eaa..728232044d 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -309,6 +309,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) } + maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(platform, input.MaxReasoningEffortOverLimit) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err) + } reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err) @@ -507,6 +511,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), RPMLimit: input.RPMLimit, MaxReasoningEffort: maxReasoningEffort, + MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) @@ -887,6 +892,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd } group.MaxReasoningEffort = maxReasoningEffort } + if input.MaxReasoningEffortOverLimit != nil { + maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(group.Platform, *input.MaxReasoningEffortOverLimit) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err) + } + group.MaxReasoningEffortOverLimit = maxReasoningEffortOverLimit + } if input.ReasoningEffortMappings != nil { reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings) if err != nil { diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index deda6d3822..0eb34a8db8 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -152,9 +152,10 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { Enabled: source.ModelsListConfig.Enabled, Models: append([]string(nil), source.ModelsListConfig.Models...), }, - RPMLimit: source.RPMLimit, - MaxReasoningEffort: source.MaxReasoningEffort, - ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...), + RPMLimit: source.RPMLimit, + MaxReasoningEffort: source.MaxReasoningEffort, + MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit, + ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...), } } diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index f4a7bef2c5..9e7833b177 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -175,17 +175,18 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing HaikuMappedModel: "gpt-5-mini", ExactModelMappings: map[string]string{"claude-special": "gpt-special"}, }, - ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, - RPMLimit: 99, - MaxReasoningEffort: "medium", - ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, - CreatedAt: createdAt, - UpdatedAt: createdAt, - AccountCount: 12, - ActiveAccountCount: 8, - RateLimitedAccountCount: 2, - DuplicateOperationID: "old-operation-must-not-copy", - AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}}, + ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, + RPMLimit: 99, + MaxReasoningEffort: "medium", + MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny, + ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + AccountCount: 12, + ActiveAccountCount: 8, + RateLimitedAccountCount: 2, + DuplicateOperationID: "old-operation-must-not-copy", + AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}}, } repo := newDuplicateGroupRepoStub(source) repo.sourceBindings[source.ID] = []AccountGroup{ @@ -215,6 +216,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) + require.Equal(t, source.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit) require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings) require.EqualValues(t, 2, duplicate.AccountCount) require.EqualValues(t, 2, duplicate.ActiveAccountCount) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index e48eea51c1..d052eaa900 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -275,6 +275,8 @@ type CreateGroupInput struct { RPMLimit int // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string + // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 + MaxReasoningEffortOverLimit string // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 ReasoningEffortMappings []ReasoningEffortMapping // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) @@ -350,6 +352,8 @@ type UpdateGroupInput struct { RPMLimit *int // MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。 MaxReasoningEffort *string + // MaxReasoningEffortOverLimit 空字符串视为 downgrade;nil 表示未提供不改动。 + MaxReasoningEffortOverLimit *string // ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。 ReasoningEffortMappings *[]ReasoningEffortMapping // 分组利润控制(nil 表示不修改;margin/buffer 为小数) diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index f1c00482b1..5463f9e513 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -42,10 +42,11 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te svc := &adminServiceImpl{groupRepo: groupRepo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "Composite", - Platform: PlatformComposite, - RateMultiplier: 1, - MaxReasoningEffort: "medium", + Name: "Composite", + Platform: PlatformComposite, + RateMultiplier: 1, + MaxReasoningEffort: "medium", + MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny, ReasoningEffortMappings: []ReasoningEffortMapping{ {From: "max", To: "xhigh"}, }, @@ -55,6 +56,7 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te require.NoError(t, err) require.Equal(t, PlatformComposite, groupRepo.created.Platform) require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort) + require.Equal(t, ReasoningEffortOverLimitDeny, groupRepo.created.MaxReasoningEffortOverLimit) require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings) require.Equal(t, int64(99), group.ID) require.Equal(t, int64(2), group.AccountCount) @@ -90,17 +92,20 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te } svc := &adminServiceImpl{groupRepo: groupRepo} maxReasoningEffort := "low" + maxReasoningEffortOverLimit := ReasoningEffortOverLimitDeny reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}} group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{ - MaxReasoningEffort: &maxReasoningEffort, - ReasoningEffortMappings: &reasoningEffortMappings, - CopyAccountsFromGroupIDs: []int64{10, 20}, + MaxReasoningEffort: &maxReasoningEffort, + MaxReasoningEffortOverLimit: &maxReasoningEffortOverLimit, + ReasoningEffortMappings: &reasoningEffortMappings, + CopyAccountsFromGroupIDs: []int64{10, 20}, }) require.NoError(t, err) require.Equal(t, PlatformComposite, group.Platform) require.Equal(t, "low", group.MaxReasoningEffort) + require.Equal(t, ReasoningEffortOverLimitDeny, group.MaxReasoningEffortOverLimit) require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings) require.Equal(t, int64(99), clearedGroupID) require.ElementsMatch(t, []int64{10, 20}, copiedFrom) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 72c5e9a0f7..b6d147af0e 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -935,12 +935,13 @@ func TestAdminService_UpdateGroup_RejectsInvalidReasoningEffortMappings(t *testi func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t *testing.T) { existing := &Group{ - ID: 1, - Name: "openai-group", - Platform: PlatformOpenAI, - Status: StatusActive, - MaxReasoningEffort: "medium", - ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, + ID: 1, + Name: "openai-group", + Platform: PlatformOpenAI, + Status: StatusActive, + MaxReasoningEffort: "medium", + MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny, + ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, } repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} @@ -949,6 +950,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t require.NoError(t, err) require.Empty(t, repo.updated.MaxReasoningEffort) + require.Equal(t, ReasoningEffortOverLimitDowngrade, repo.updated.MaxReasoningEffortOverLimit) require.Empty(t, repo.updated.ReasoningEffortMappings) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 71e0318dc0..53f4e9b2c2 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -112,6 +112,8 @@ type APIKeyAuthGroupSnapshot struct { // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` + // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 + MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` // ReasoningEffortMappings rewrites explicit effort values before the ceiling. ReasoningEffortMappings []ReasoningEffortMapping `json:"reasoning_effort_mappings"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 3c95141a47..7c52322cea 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -423,6 +423,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) ModelsListConfig: apiKey.Group.ModelsListConfig, RPMLimit: apiKey.Group.RPMLimit, MaxReasoningEffort: apiKey.Group.MaxReasoningEffort, + MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit, ReasoningEffortMappings: apiKey.Group.ReasoningEffortMappings, PeakRateEnabled: apiKey.Group.PeakRateEnabled, PeakStart: apiKey.Group.PeakStart, @@ -521,6 +522,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho ModelsListConfig: snapshot.Group.ModelsListConfig, RPMLimit: snapshot.Group.RPMLimit, MaxReasoningEffort: snapshot.Group.MaxReasoningEffort, + MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit, ReasoningEffortMappings: snapshot.Group.ReasoningEffortMappings, PeakRateEnabled: snapshot.Group.PeakRateEnabled, PeakStart: snapshot.Group.PeakStart, diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 31f62adfee..ff5cffefef 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -295,13 +295,14 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi Concurrency: 3, }, Group: &Group{ - ID: groupID, - Name: "composite", - Platform: PlatformComposite, - Status: StatusActive, - SubscriptionType: SubscriptionTypeStandard, - RateMultiplier: 1, - MaxReasoningEffort: "medium", + ID: groupID, + Name: "composite", + Platform: PlatformComposite, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + MaxReasoningEffort: "medium", + MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny, ReasoningEffortMappings: []ReasoningEffortMapping{ {From: "max", To: "xhigh"}, }, @@ -315,6 +316,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi require.NotNil(t, roundTrip.Group) require.Equal(t, PlatformComposite, roundTrip.Group.Platform) require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort) + require.Equal(t, ReasoningEffortOverLimitDeny, roundTrip.Group.MaxReasoningEffortOverLimit) require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings) } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bf0440afc3..1c7b7ba9b3 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -113,6 +113,9 @@ type Group struct { // MaxReasoningEffort limits the effective OpenAI/Codex 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 + // exceeds the ceiling: downgrade (default) or deny. + MaxReasoningEffortOverLimit string // ReasoningEffortMappings rewrites explicit request values before applying the ceiling. ReasoningEffortMappings []ReasoningEffortMapping diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index cfd0649154..6f9fc398fe 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -270,7 +270,16 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } if account.Platform == PlatformOpenAI { - if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed { + policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody) + if policyErr != nil { + var overLimit *ReasoningEffortOverLimitError + if errors.As(policyErr, &overLimit) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + } + return nil, policyErr + } + if changed { responsesBody = policyBody if responsesReq.Reasoning != nil { responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String() diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 9a9dfc611f..c4fcdd772f 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -80,7 +81,16 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatBody = normalizedBody } if account.Platform == PlatformOpenAI { - if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed { + policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody) + if policyErr != nil { + var overLimit *ReasoningEffortOverLimitError + if errors.As(policyErr, &overLimit) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + } + return nil, policyErr + } + if changed { chatBody = policyBody if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" { reasoningEffort = &effectiveEffort diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go index 43c453376c..60594026ab 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -114,7 +114,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} ctx := context.Background() if tt.maxPolicy != "" { - ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil) + ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil, "") } result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "") require.NoError(t, err) @@ -464,7 +464,7 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint( openai_compat.ExtraKeyResponsesSupported: true, } - ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil) + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, "") result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "") require.NoError(t, err) require.NotNil(t, result) diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 17432e49c7..97ccb28517 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -12,6 +12,11 @@ import ( const ( maxReasoningEffortMappings = 64 maxReasoningEffortValueLen = 64 + + // ReasoningEffortOverLimitDowngrade rewrites values above the ceiling. + ReasoningEffortOverLimitDowngrade = "downgrade" + // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded. + ReasoningEffortOverLimitDeny = "deny" ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} @@ -21,9 +26,35 @@ type requestedReasoningEffortContextKey struct{} type openAIReasoningEffortPolicy struct { maxEffort string + overLimit string mappings []ReasoningEffortMapping } +// ReasoningEffortOverLimitError is returned when a group policy is set to deny +// requests whose explicit reasoning effort exceeds the ceiling. +type ReasoningEffortOverLimitError struct { + Requested string + Max string +} + +func (e *ReasoningEffortOverLimitError) Error() string { + if e == nil { + return "reasoning effort exceeds this group's limit" + } + requested := strings.TrimSpace(e.Requested) + max := strings.TrimSpace(e.Max) + if requested == "" && max == "" { + return "reasoning effort exceeds this group's limit" + } + if requested == "" { + return fmt.Sprintf("reasoning effort exceeds this group's limit of %q", max) + } + if max == "" { + return fmt.Sprintf("reasoning effort %q exceeds this group's limit", requested) + } + return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max) +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -84,6 +115,42 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error ) } +// NormalizeMaxReasoningEffortOverLimit canonicalizes the over-limit action. +// Empty means the historical default: automatically downgrade to the ceiling. +func NormalizeMaxReasoningEffortOverLimit(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", ReasoningEffortOverLimitDowngrade: + return ReasoningEffortOverLimitDowngrade + case ReasoningEffortOverLimitDeny: + return ReasoningEffortOverLimitDeny + default: + return "" + } +} + +func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (string, error) { + value := NormalizeMaxReasoningEffortOverLimit(raw) + if value == "" { + return "", fmt.Errorf( + "reasoning effort over-limit action %q is not supported; allowed values: %s, %s", + raw, + ReasoningEffortOverLimitDowngrade, + ReasoningEffortOverLimitDeny, + ) + } + if value == ReasoningEffortOverLimitDowngrade { + return value, nil + } + if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return "", fmt.Errorf( + "reasoning effort policy is only supported for platforms %q and %q", + PlatformOpenAI, + PlatformComposite, + ) + } + return value, nil +} + func reasoningEffortRank(raw string) (int, bool) { switch NormalizeMaxReasoningEffort(raw) { case "minimal": @@ -170,12 +237,13 @@ func RequestedReasoningEffortFromContext(ctx context.Context) *string { // WithOpenAIReasoningEffortPolicy binds a group policy to a request after its // concrete target platform has been resolved to OpenAI. The policy is copied so // retries and asynchronous forwarding cannot observe later slice mutations. -func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping) context.Context { +func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) context.Context { if ctx == nil { ctx = context.Background() } policy := openAIReasoningEffortPolicy{ maxEffort: maxEffort, + overLimit: overLimit, mappings: append([]ReasoningEffortMapping(nil), mappings...), } return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy) @@ -183,15 +251,15 @@ func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mapp // ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound // to the request. An unbound request is returned byte-for-byte unchanged. -func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool) { +func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool, error) { if ctx == nil { - return body, false + return body, false, nil } policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy) if !ok { - return body, false + return body, false, nil } - return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings) + return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit) } func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) { @@ -211,24 +279,32 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { } maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort) mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings) + overLimit := NormalizeMaxReasoningEffortOverLimit(group.MaxReasoningEffortOverLimit) if maxErr != nil { maxEffort = "" } if mappingsErr != nil { mappings = []ReasoningEffortMapping{} } + if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { + overLimit = ReasoningEffortOverLimitDowngrade + } group.MaxReasoningEffort = maxEffort + group.MaxReasoningEffortOverLimit = overLimit group.ReasoningEffortMappings = mappings } -// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then caps -// known effort levels. Omitted values remain untouched so upstream defaults -// stay in control. -func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping) ([]byte, bool) { +// ApplyOpenAIReasoningEffortPolicy applies one exact mapping 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) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { - return body, false + return body, false, nil } + deny := hasMax && NormalizeMaxReasoningEffortOverLimit(overLimit) == ReasoningEffortOverLimitDeny + canonicalMax := NormalizeMaxReasoningEffort(maxEffort) result := body changed := false @@ -246,7 +322,10 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] if currentRank, recognized := reasoningEffortRank(effective); recognized { effective = NormalizeMaxReasoningEffort(effective) if hasMax && currentRank > maxRank { - effective = NormalizeMaxReasoningEffort(maxEffort) + if deny { + return body, false, &ReasoningEffortOverLimitError{Requested: effective, Max: canonicalMax} + } + effective = canonicalMax } } if effective == original { @@ -260,5 +339,19 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] result = updated changed = true } - return result, changed + return result, changed, nil +} + +func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHooks) ([]byte, error) { + if hooks == nil || (hooks.MaxReasoningEffort == "" && len(hooks.ReasoningEffortMappings) == 0) { + return payload, nil + } + capped, changed, err := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings, hooks.MaxReasoningEffortOverLimit) + if err != nil { + return payload, err + } + if changed { + return capped, nil + } + return payload, nil } diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index 2ef836cc31..e504471f1e 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -114,30 +114,69 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { require.ErrorContains(t, err, "not supported") } +func TestNormalizeMaxReasoningEffortOverLimit(t *testing.T) { + require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit("")) + require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit(" downgrade ")) + require.Equal(t, ReasoningEffortOverLimitDeny, NormalizeMaxReasoningEffortOverLimit("DENY")) + require.Empty(t, NormalizeMaxReasoningEffortOverLimit("block")) +} + +func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) { + value, err := normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDowngrade, value) + + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformComposite, "deny") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDeny, value) + + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDowngrade, value) + + _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") + require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + + _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block") + require.ErrorContains(t, err, "not supported") +} + func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { body := []byte(`{"reasoning":{"effort":"max"}}`) - unbound, changed := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body) + unbound, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body) + require.NoError(t, err) require.False(t, changed) require.Equal(t, body, unbound) mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}} - ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings) + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings, "") mappings[0].To = "low" - got, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + got, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + require.NoError(t, err) require.True(t, changed) require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String()) + + denyCtx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, ReasoningEffortOverLimitDeny) + _, _, err = ApplyOpenAIReasoningEffortPolicyFromContext(denyCtx, body) + require.Error(t, err) + var overLimit *ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + require.Equal(t, "max", overLimit.Requested) + require.Equal(t, "medium", overLimit.Max) } func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { - name string - body string - max string - mappings []ReasoningEffortMapping - path string - want string - changed bool + name string + body string + max string + overLimit string + mappings []ReasoningEffortMapping + path string + want string + changed bool + deny bool }{ {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}, @@ -152,10 +191,24 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {name: "does not chain mappings", body: `{"reasoning_effort":"max"}`, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}, {From: "xhigh", To: "low"}}, path: "reasoning_effort", want: "xhigh", changed: true}, {name: "keeps unknown without mapping", body: `{"reasoning_effort":"future"}`, max: "low", path: "reasoning_effort", want: "future", changed: false}, {name: "keeps non string value", body: `{"reasoning_effort":{"level":"high"}}`, max: "low", path: "reasoning_effort.level", want: "high", changed: false}, + {name: "deny rejects over ceiling", body: `{"reasoning_effort":"high"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high", deny: true}, + {name: "deny keeps value at ceiling", body: `{"reasoning_effort":"low"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "low"}, + {name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true}, + {name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true}, + {name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, changed := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings) + got, changed, err := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings, tt.overLimit) + if tt.deny { + require.Error(t, err) + var overLimit *ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + require.False(t, changed) + require.Equal(t, tt.body, string(got)) + return + } + require.NoError(t, err) require.Equal(t, tt.changed, changed) if tt.path != "" { require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String()) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 0994446624..b5a466949f 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -255,6 +255,9 @@ type OpenAIWSIngressHooks struct { InitialTurnStartedAt time.Time // MaxReasoningEffort limits explicit reasoning effort values for this WS session. MaxReasoningEffort string + // MaxReasoningEffortOverLimit is the access control when an explicit effort + // exceeds the ceiling: downgrade (default) or deny. + MaxReasoningEffortOverLimit string // ReasoningEffortMappings rewrites explicit effort values for this WS session. ReasoningEffortMappings []ReasoningEffortMapping TurnStarted func(turn int, startedAt time.Time) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b6af8fc06c..e57c2dd899 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -263,10 +263,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } requestedReasoningEffort := CanonicalRequestedReasoningEffort(normalized, strings.TrimSpace(values[1].String())) - if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { - if capped, changed := ApplyOpenAIReasoningEffortPolicy(normalized, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { - normalized = capped - } + if next, policyErr := applyOpenAIWSReasoningEffortPolicy(normalized, hooks); policyErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr) + } else { + normalized = next } responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 95de10df71..715a747918 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -696,10 +696,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = liteFirstMessage } originalFirstClientMessage := firstClientMessage - if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { - if capped, changed := ApplyOpenAIReasoningEffortPolicy(firstClientMessage, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { - firstClientMessage = capped - } + if next, policyErr := applyOpenAIWSReasoningEffortPolicy(firstClientMessage, hooks); policyErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr) + } else { + firstClientMessage = next } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) @@ -1032,10 +1032,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( payload = litePayload } originalResponseCreate := payload - if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { - if capped, changed := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { - payload = capped - } + if next, policyErr := applyOpenAIWSReasoningEffortPolicy(payload, hooks); policyErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr) + } else { + payload = next } usageMeta.captureRequestedReasoningEffort(originalResponseCreate) } diff --git a/backend/migrations/232_group_reasoning_effort_over_limit.sql b/backend/migrations/232_group_reasoning_effort_over_limit.sql new file mode 100644 index 0000000000..3b7aec986b --- /dev/null +++ b/backend/migrations/232_group_reasoning_effort_over_limit.sql @@ -0,0 +1,5 @@ +-- Per-group access control when an explicit OpenAI/Codex reasoning effort +-- exceeds max_reasoning_effort. Existing groups keep the previous behaviour +-- (automatically downgrade to the ceiling). +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS max_reasoning_effort_over_limit VARCHAR(20) NOT NULL DEFAULT 'downgrade'; diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index ba363d7451..99ac1fdc3c 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -17,6 +17,22 @@

{{ t("admin.groups.form.maxReasoningEffortHint") }}

+
+ +