diff --git a/README.md b/README.md
index 509dcaaa03..169adb28d3 100644
--- a/README.md
+++ b/README.md
@@ -174,7 +174,7 @@ Please read the following carefully before using this project:
-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.
+Thanks to AxisNow for sponsoring this project! AxisNow protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — self-hosted private-deployment CDN | subscription-based DDoS-protected CDN | autonomous, flexibly composable CDN network .
diff --git a/README_CN.md b/README_CN.md
index 015d3c9dc1..2729aa046f 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -177,7 +177,7 @@
-感谢 AxisNow 赞助了本项目!AxisNow 正在打造一个云无关的边缘控制层。使用 AxisNow 可以保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — 这些都在您可控的、自由组合的边缘网络。
+感谢 AxisNow 赞助了本项目!AxisNow 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — 自建私有部署 CDN |订阅式高防 CDN |自主可控、灵活组合的 CDN 网络 。
diff --git a/README_JA.md b/README_JA.md
index 9bd3802a32..3a33c4e753 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -174,6 +174,11 @@
APIMart のご支援に感謝します!APIMart は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、こちらの登録リンク からすぐに利用を開始できます。
+
+
+AxisNow のご支援に感謝します!AxisNow はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — 自社構築・プライベートデプロイ CDN |サブスクリプション型高防御 CDN |自律制御で柔軟に組み合わせられる CDN ネットワーク 。
+
+
## 概要
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index c3ba241b62..8fe4a535ae 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.184
+0.1.185
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 8c856cbf61..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"`
// 哈希校验间隔(分钟)
@@ -2279,11 +2281,12 @@ 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")
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/domain/constants.go b/backend/internal/domain/constants.go
index 1ed90ee940..388cab606c 100644
--- a/backend/internal/domain/constants.go
+++ b/backend/internal/domain/constants.go
@@ -43,7 +43,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
- APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
+ APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
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/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/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/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go
new file mode 100644
index 0000000000..7bb7fd68b1
--- /dev/null
+++ b/backend/internal/handler/openai_automation_bootstrap_test.go
@@ -0,0 +1,133 @@
+package handler
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+const automationBootstrapPrompt = "Review the project and report any important changes."
+
+func codexAutomationBootstrap(automationID, lastRun, prompt string) string {
+ return "Automation: Scheduled project review\n" +
+ "Automation ID: " + automationID + "\n" +
+ "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" +
+ "Last run: " + lastRun + "\n\n" + prompt
+}
+
+func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte {
+ t.Helper()
+ return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` +
+ mustJSON(t, output) + callID + `}]}`)
+}
+
+func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
+ tests := []struct {
+ name string
+ lastRun string
+ crlf bool
+ }{
+ {name: "never", lastRun: "never"},
+ {name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"},
+ {name: "crlf", lastRun: "never", crlf: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt)
+ if tt.crlf {
+ output = strings.ReplaceAll(output, "\n", "\r\n")
+ }
+ got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
+ require.True(t, changed)
+ require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
+ require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
+ require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
+ require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
+ })
+ }
+}
+
+func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
+ validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
+ tests := []struct {
+ name string
+ body []byte
+ }{
+ {
+ name: "ordinary missing call id",
+ body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`),
+ },
+ {
+ name: "tui namespace",
+ body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
+ },
+ {
+ name: "valid call id",
+ body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`),
+ },
+ {
+ name: "previous response",
+ body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
+ },
+ {
+ name: "real call context",
+ body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
+ },
+ {
+ name: "heartbeat output",
+ body: codexAutomationBootstrapBody(t, `wiki `, ""),
+ },
+ {
+ name: "mismatched memory id",
+ body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
+ },
+ {
+ name: "unsafe automation id",
+ body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""),
+ },
+ {
+ name: "invalid timestamp",
+ body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""),
+ },
+ {
+ name: "mismatched timestamp epoch",
+ body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""),
+ },
+ {
+ name: "missing separator",
+ body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""),
+ },
+ {
+ name: "empty prompt",
+ body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""),
+ },
+ {
+ name: "mixed missing call id output",
+ body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, changed := normalizeCodexAutomationBootstrap(tt.body)
+ require.False(t, changed)
+ require.Equal(t, tt.body, got)
+ })
+ }
+}
+
+func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
+ output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
+ body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
+
+ got, changed := normalizeCodexAutomationBootstrap(body)
+ require.True(t, changed)
+ require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
+ require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String())
+ require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
+
+ again, changedAgain := normalizeCodexAutomationBootstrap(got)
+ require.False(t, changedAgain)
+ require.Equal(t, got, again)
+}
diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go
index 686c6c13c5..aed2ba34b6 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/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-1 do 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-1 work `,
+ `thread-1 work suffix`,
+ `thread-1 workx `,
+ `thread-1 work `,
+ `thread-1 work `,
+ `thread-1 work `,
+ `thread-1 work `,
+ `thread-1 work `,
+ `thread-1 work `,
+ `thread-1 work `,
+ }
+ 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..25209d87e0 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,18 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
body = cappedBody
}
+ if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
+ body = normalizedBody
+ reqLog.Info("openai.codex_automation_bootstrap_normalized",
+ zap.String("normalization", "call_output_to_user_message"),
+ )
+ }
+ 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 +1596,312 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
return false
}
+func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
+ return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
+}
+
+func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
+ return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
+}
+
+func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]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 !isCandidate(item) {
+ return body, false
+ }
+ }
+ }
+
+ changed := false
+ for i, raw := range input {
+ item, ok := raw.(map[string]any)
+ if !ok || !isCandidate(item) {
+ continue
+ }
+ output, ok := item["output"].(string)
+ if !ok {
+ 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 isCodexAutomationCandidate(item map[string]any) bool {
+ if stringField(item, "type") != "function_call_output" ||
+ stringField(item, "namespace") != "codex_app" ||
+ stringField(item, "name") != "automation_update" {
+ return false
+ }
+ output, ok := item["output"].(string)
+ return ok && validCodexAutomationBootstrap(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 validCodexAutomationBootstrap(value string) bool {
+ normalized := strings.ReplaceAll(value, "\r\n", "\n")
+ if strings.ContainsRune(normalized, '\r') {
+ return false
+ }
+ lines := strings.Split(normalized, "\n")
+ if len(lines) < 6 {
+ return false
+ }
+ if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
+ return false
+ }
+ automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
+ if !ok || !validCodexAutomationID(automationID) {
+ return false
+ }
+ expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
+ if lines[2] != expectedMemory {
+ return false
+ }
+ lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
+ if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
+ return false
+ }
+ return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
+}
+
+func codexAutomationHeaderValue(line, prefix string) (string, bool) {
+ if !strings.HasPrefix(line, prefix) {
+ return "", false
+ }
+ value := strings.TrimPrefix(line, prefix)
+ return value, value != "" && strings.TrimSpace(value) == value
+}
+
+func validCodexAutomationID(value string) bool {
+ if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
+ return false
+ }
+ for i := 0; i < len(value); i++ {
+ c := value[i]
+ if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func validCodexAutomationLastRun(value string) bool {
+ if value == "never" {
+ return true
+ }
+ separator := strings.LastIndex(value, " (")
+ if separator <= 0 || !strings.HasSuffix(value, ")") {
+ return false
+ }
+ runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
+ if err != nil {
+ return false
+ }
+ epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
+ return err == nil && runAt.UnixMilli() == epochMillis
+}
+
+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,
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/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go
index 0e8a05d36a..8698bd9337 100644
--- a/backend/internal/pkg/claude/constants.go
+++ b/backend/internal/pkg/claude/constants.go
@@ -25,6 +25,17 @@ const (
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
+
+ // server-side refusal fallback beta 字段族(beta Messages API 专有)。
+ // 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
+ // body.fallback_credit_token,上游仅在 anthropic-beta 携带对应 token 时接受;
+ // 缺 token 时 Pydantic 拒收:"fallbacks: Extra inputs are not permitted"。
+ // 仅用于 sanitize 的条件判断(strip-or-keep),禁止加入
+ // FullClaudeCodeMimicryBetas / DefaultBetaHeader / APIKeyBetaHeader /
+ // Bedrock 白名单:server-side fallback 会换模型、改计费,不能默认打开。
+ BetaServerSideFallback = "server-side-fallback-2026-07-01"
+ BetaFallbackCredit = "fallback-credit-2026-07-01"
+ BetaFallbackCreditLegacy = "fallback-credit-2026-06-01"
)
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go
index 3abb528e98..165fea4baf 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,69 @@ import (
"github.com/lib/pq"
)
+const (
+ maxDatabaseInitializationRetries = 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 {
+ for attempt := 1; ; attempt++ {
+ if err := initialize(ctx); err == nil {
+ return nil
+ } else {
+ 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
+ }
+ }
+ }
+}
+
+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 +135,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..e3bde3d24d
--- /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, maxDatabaseInitializationRetries+1, attempts)
+ require.Len(t, delays, maxDatabaseInitializationRetries)
+ 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/backend/internal/service/account.go b/backend/internal/service/account.go
index 3967e407bd..ad1ad82a1b 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1387,8 +1387,8 @@ func (a *Account) IsCodingPlan() bool {
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
-// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses
-// 端点,适配 Codex);kimi/zhipu 无此端点。
+// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
+// Responses 端点,适配 Codex);zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
return APIProtocolChatCompletions
@@ -1399,7 +1399,7 @@ func (a *Account) GetAPIProtocol() string {
case APIProtocolAnthropic:
return APIProtocolAnthropic
case APIProtocolResponses:
- if a.Platform == PlatformDeepseek {
+ if a.SupportsNativeCNResponses() {
return APIProtocolResponses
}
case APIProtocolChatCompletions:
@@ -1408,6 +1408,35 @@ func (a *Account) GetAPIProtocol() string {
return APIProtocolChatCompletions
}
+// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
+// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
+// /v1/responses(moonshot.cn / kimi.com/coding)。
+func (a *Account) SupportsNativeCNResponses() bool {
+ if a == nil {
+ return false
+ }
+ switch a.Platform {
+ case PlatformDeepseek, PlatformKimi:
+ return true
+ default:
+ return false
+ }
+}
+
+// UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发
+// (显式 responses,或 adaptive 且平台具备原生端点)。
+func (a *Account) UsesNativeCNResponses() bool {
+ if a == nil || !a.SupportsNativeCNResponses() {
+ return false
+ }
+ switch a.GetAPIProtocol() {
+ case APIProtocolResponses, APIProtocolAdaptive:
+ return true
+ default:
+ return false
+ }
+}
+
// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
func (a *Account) IsAdaptiveAPIProtocol() bool {
return a.GetAPIProtocol() == APIProtocolAdaptive
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 遍历自定义规则,按数组顺序先命中为准。
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) {
diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go
index 8811fd840f..db7b2418e1 100644
--- a/backend/internal/service/account_test_service_cn_adaptive.go
+++ b/backend/internal/service/account_test_service_cn_adaptive.go
@@ -18,8 +18,8 @@ import (
const accountTestSuppressCompletionContextKey = "account_test_suppress_completion"
// testCNProviderAdaptiveConnection verifies every native endpoint used by an
-// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus
-// Anthropic; DeepSeek additionally uses its native Responses endpoint.
+// adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic;
+// DeepSeek and Kimi additionally use their native Responses endpoints.
func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
testModelID := strings.TrimSpace(modelID)
if testModelID == "" {
@@ -44,7 +44,7 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac
return err
}
- if account.Platform == PlatformDeepseek {
+ if account.SupportsNativeCNResponses() {
if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil {
return err
}
@@ -159,8 +159,8 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL)
payload := createOpenAITestPayload(testModelID, false)
- // DeepSeek's native Responses endpoint is stateless and does not need the
- // OpenAI probe's synthetic instructions.
+ // DeepSeek / Kimi native Responses endpoints are stateless and do not need
+ // the OpenAI probe's synthetic instructions.
delete(payload, "instructions")
payloadBytes, _ := json.Marshal(payload)
payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes)
diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go
index 410cc26261..ffbc497ced 100644
--- a/backend/internal/service/account_test_service_cn_adaptive_test.go
+++ b/backend/internal/service/account_test_service_cn_adaptive_test.go
@@ -83,36 +83,25 @@ data: {"type":"response.completed"}
}
func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) {
- for index, testCase := range []struct {
- name string
- platform string
- model string
- }{
- {name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"},
- {name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"},
- } {
- t.Run(testCase.name, func(t *testing.T) {
- account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform)
- svc, upstream := adaptiveCNAccountTestService(
- account,
- adaptiveCNChatTestResponse(),
- adaptiveCNAnthropicTestResponse(),
- )
- c, recorder := newTestContext()
+ account := adaptiveCNAccountTestAccount(301, PlatformZhipu)
+ svc, upstream := adaptiveCNAccountTestService(
+ account,
+ adaptiveCNChatTestResponse(),
+ adaptiveCNAnthropicTestResponse(),
+ )
+ c, recorder := newTestContext()
- err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault)
+ err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "hello", AccountTestModeDefault)
- require.NoError(t, err)
- require.Len(t, upstream.requests, 2)
- require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String())
- require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String())
- require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization"))
- require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key"))
- require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`))
- require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
- require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证")
- })
- }
+ require.NoError(t, err)
+ require.Len(t, upstream.requests, 2)
+ require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String())
+ require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String())
+ require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization"))
+ require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key"))
+ require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`))
+ require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
+ require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证")
}
func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) {
@@ -139,6 +128,30 @@ func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testin
require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证")
}
+func TestAccountTestService_AdaptiveKimiAlsoTestsResponsesEndpoint(t *testing.T) {
+ account := adaptiveCNAccountTestAccount(306, PlatformKimi)
+ svc, upstream := adaptiveCNAccountTestService(
+ account,
+ adaptiveCNChatTestResponse(),
+ adaptiveCNAnthropicTestResponse(),
+ adaptiveCNResponsesTestResponse(),
+ )
+ c, recorder := newTestContext()
+
+ err := svc.TestAccountConnection(c, account.ID, "k3-256k", "", AccountTestModeDefault)
+
+ require.NoError(t, err)
+ require.Len(t, upstream.requests, 3)
+ require.Equal(t, "http://responses.example/v1/responses", upstream.requests[2].URL.String())
+ require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context()))
+ require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization"))
+ require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool())
+ require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool())
+ require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists())
+ require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
+ require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证")
+}
+
func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) {
account := adaptiveCNAccountTestAccount(303, PlatformDeepseek)
svc, upstream := adaptiveCNAccountTestService(
diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go
index 210caa0695..3aef38698a 100644
--- a/backend/internal/service/adaptive_api_protocol_test.go
+++ b/backend/internal/service/adaptive_api_protocol_test.go
@@ -116,10 +116,10 @@ func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T
func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) {
gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`)
+ body := []byte(`{"model":"glm-4.7","input":"hello","max_output_tokens":32,"stream":false}`)
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
- account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
+ account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{
APIProtocolChatCompletions: "http://chat.example",
APIProtocolAnthropic: "http://anthropic.example",
})
@@ -131,6 +131,24 @@ func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testi
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
}
+func TestAdaptiveProtocolRoutesKimiResponsesShapedChatToNativeResponses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"k3-256k","input":"hello","max_output_tokens":32,"stream":false}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example/v1",
+ })
+
+ _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "")
+ require.Error(t, err)
+ require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
+}
+
func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
@@ -147,21 +165,42 @@ func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) {
require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String())
}
-func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) {
+func TestAdaptiveProtocolRoutesKimiResponsesToNativeResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`)
+ body := []byte(`{"model":"k3-256k","input":"hello","store":true,"previous_response_id":"resp_old","stream":false}`)
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
APIProtocolChatCompletions: "http://chat.example",
APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example/v1",
})
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
require.Error(t, err)
- require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
+ require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
+}
+
+func TestAdaptiveProtocolRoutesKimiCodingResponsesToNativeResponses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"k3-256k","input":"hello","stream":false}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
+ APIProtocolChatCompletions: "https://api.kimi.com/coding/v1",
+ APIProtocolAnthropic: "https://api.kimi.com/coding",
+ APIProtocolResponses: "https://api.kimi.com/coding/v1",
+ })
+ account.Credentials["account_mode"] = AccountModeCoding
+
+ _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
+ require.Error(t, err)
+ require.Equal(t, "https://api.kimi.com/coding/v1/responses", upstream.lastReq.URL.String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
}
func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) {
diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go
index d2b491e632..d8f48541de 100644
--- a/backend/internal/service/bedrock_request.go
+++ b/backend/internal/service/bedrock_request.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -638,10 +639,26 @@ func filterBedrockBetaTokens(tokens []string) []string {
return result
}
+// sanitizeBedrockFieldsForBetaTokens 与直连路径的 sanitizeAnthropicBodyForBetaTokens
+// 对称:按最终 Bedrock beta tokens 决定是否保留 body 中的 beta 字段。
+// - context_management 缺 context-management beta → strip
+// - fallbacks 缺 server-side-fallback beta → strip
+// - fallback_credit_token 缺 server-side-fallback / 任一 fallback-credit beta → strip
+//
+// 注意:fallback beta token 均不在 bedrockSupportedBetaTokens 白名单内
+// (会被 filterBedrockBetaTokens 过滤掉),因此条件 strip 实际总会剥除——这是预期:
+// Bedrock Invoke 不支持这些 beta 字段。
func sanitizeBedrockFieldsForBetaTokens(body []byte, betaTokens []string) []byte {
if !containsBedrockBetaToken(betaTokens, bedrockContextManagementBetaToken) && gjson.GetBytes(body, "context_management").Exists() {
body, _ = sjson.DeleteBytes(body, "context_management")
}
+ if !containsBedrockBetaToken(betaTokens, claude.BetaServerSideFallback) && gjson.GetBytes(body, "fallbacks").Exists() {
+ body, _ = sjson.DeleteBytes(body, "fallbacks")
+ }
+ if !containsAnyBedrockBetaToken(betaTokens, claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy) &&
+ gjson.GetBytes(body, "fallback_credit_token").Exists() {
+ body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
+ }
return body
}
@@ -654,6 +671,16 @@ func containsBedrockBetaToken(tokens []string, target string) bool {
return false
}
+// containsAnyBedrockBetaToken 判断 tokens 是否包含 targets 中的任意一个 token。
+func containsAnyBedrockBetaToken(tokens []string, targets ...string) bool {
+ for _, target := range targets {
+ if containsBedrockBetaToken(tokens, target) {
+ return true
+ }
+ }
+ return false
+}
+
// bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
@@ -760,6 +787,8 @@ const defaultCCMaxTokens = 81920
// - 移除 service_tier(Anthropic API 专有,Bedrock 不支持)
// - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
// - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
+// - 无条件移除 fallbacks / fallback_credit_token(server-side refusal fallback,
+// Anthropic 直连 beta API 专有;Bedrock Invoke 无对应 beta,永不支持)
// - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
// - 注入 anthropic_version(CC 通过 HTTP 头发送,Bedrock 需要放在请求体中)
func sanitizeBedrockCCFields(body []byte) []byte {
@@ -772,6 +801,12 @@ func sanitizeBedrockCCFields(body []byte) []byte {
if gjson.GetBytes(body, "context_management").Exists() {
body, _ = sjson.DeleteBytes(body, "context_management")
}
+ if gjson.GetBytes(body, "fallbacks").Exists() {
+ body, _ = sjson.DeleteBytes(body, "fallbacks")
+ }
+ if gjson.GetBytes(body, "fallback_credit_token").Exists() {
+ body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
+ }
if !gjson.GetBytes(body, "max_tokens").Exists() {
body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
}
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..dfe43f1265 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,56 @@ 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))
+ // 缓存写入按标准输入价,与 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))
},
},
{
- 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 +307,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 +424,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 +455,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..9d282de061 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,32 @@ 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-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": 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}
+}`
+
+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 +74,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 +118,74 @@ 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)
}
}
}
+// 目录阶梯对缓存分项同样生效: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())
+ 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/cn_providers_test.go b/backend/internal/service/cn_providers_test.go
index 7ec7a4fddd..84598f3ba7 100644
--- a/backend/internal/service/cn_providers_test.go
+++ b/backend/internal/service/cn_providers_test.go
@@ -469,7 +469,7 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
}
// TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵:
-// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。
+// responses 仅 deepseek / kimi;缺失/非法值回退 chat_completions(与旧行为一致)。
func TestGetAPIProtocol(t *testing.T) {
t.Parallel()
@@ -486,15 +486,24 @@ func TestGetAPIProtocol(t *testing.T) {
require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol())
require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol())
require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol())
+ require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol())
- require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点")
require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认")
}
+func TestSupportsNativeCNResponses(t *testing.T) {
+ t.Parallel()
+ require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses())
+ require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
+ require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
+ require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
+ require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
+}
+
func TestAdaptiveProtocolBaseURLs(t *testing.T) {
t.Parallel()
@@ -639,10 +648,12 @@ func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) {
require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1"))
require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com"))
require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"))
+ require.Equal(t, "https://api.moonshot.cn/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.moonshot.cn/v1"))
+ require.Equal(t, "https://api.kimi.com/coding/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.kimi.com/coding/v1"))
}
// TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、
-// 清除 previous_response_id;非 deepseek responses 协议原样返回。
+// 清除 previous_response_id;非原生 CN Responses 协议原样返回。
func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
t.Parallel()
@@ -668,6 +679,22 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
+ kimiResponses := &Account{
+ Platform: PlatformKimi, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_protocol": APIProtocolResponses},
+ }
+ kimiNormalized := normalizeDeepSeekResponsesRequestBody(kimiResponses, body)
+ require.False(t, gjson.GetBytes(kimiNormalized, "store").Bool())
+ require.False(t, gjson.GetBytes(kimiNormalized, "previous_response_id").Exists())
+
+ kimiCodingAdaptive := &Account{
+ Platform: PlatformKimi, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_protocol": APIProtocolAdaptive, "account_mode": AccountModeCoding},
+ }
+ kimiCodingNormalized := normalizeDeepSeekResponsesRequestBody(kimiCodingAdaptive, body)
+ require.False(t, gjson.GetBytes(kimiCodingNormalized, "store").Bool())
+ require.False(t, gjson.GetBytes(kimiCodingNormalized, "previous_response_id").Exists())
+
// openai 账号原样返回
openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body)))
diff --git a/backend/internal/service/gateway_fallbacks_sanitize_test.go b/backend/internal/service/gateway_fallbacks_sanitize_test.go
new file mode 100644
index 0000000000..d73254ce9d
--- /dev/null
+++ b/backend/internal/service/gateway_fallbacks_sanitize_test.go
@@ -0,0 +1,295 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+// ============================================================================
+// 背景
+// ============================================================================
+//
+// Anthropic 上游对 body.fallbacks / body.fallback_credit_token 字段实施
+// Pydantic extra='forbid' 校验:当且仅当 anthropic-beta header 含
+// server-side-fallback-2026-07-01(fallback_credit_token 额外接受
+// fallback-credit-2026-07-01 / fallback-credit-2026-06-01)时接受。
+// 否则报:
+// "fallbacks: Extra inputs are not permitted"
+//
+// fallbacks 是 beta Messages API 的 server-side refusal fallback 字段;本仓
+// 不写入该字段,全部来自客户端(Claude Code / SDK / OpenCode 等)透传。
+// OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta(不含 fallback
+// beta),因此必须在出口按最终 beta header 条件 strip,与 context_management
+// 的对称约束同构。策略是"剥字段,不注入 beta":fallback 会换模型、改计费,
+// 不允许当默认打开。
+//
+// 本文件覆盖:
+// 1) sanitizeAnthropicBodyForBetaTokens 对 fallbacks / fallback_credit_token
+// 的条件 strip(以及与 context_management 的组合行为)
+// 2) buildUpstreamRequest OAuth mimic / API-key passthrough 端到端
+// 3) Bedrock 路径的对称 strip(PrepareBedrockRequestBodyWithTokens /
+// sanitizeBedrockCCFields)
+
+// ============================================================================
+// sanitizeAnthropicBodyForBetaTokens — fallbacks / fallback_credit_token
+// ============================================================================
+
+func TestSanitizeAnthropicBodyForBetaTokens_NoFallbackFieldsNoChange(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
+ require.False(t, changed)
+ require.Equal(t, string(body), string(out))
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_FallbacksKeptWhenBetaPresent(t *testing.T) {
+ body := []byte(`{"model":"claude-opus-4-7","fallbacks":"default","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body,
+ "claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01")
+ require.False(t, changed, "客户端 header 已带 server-side-fallback beta → 字段保留(不过度删除)")
+ require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
+ require.Equal(t, "default", gjson.GetBytes(out, "fallbacks").String())
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenBetaMissing(t *testing.T) {
+ // 客户端透传的两种形态:字符串 "default" 与模型数组
+ for name, fallbacks := range map[string]string{
+ "string_default": `"fallbacks":"default"`,
+ "model_array": `"fallbacks":["claude-opus-4-6","claude-sonnet-4-6"]`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5",` + fallbacks + `,"messages":[]}`)
+ // 模拟 OAuth mimic / 默认 API-key beta:只有 oauth/interleaved,无 fallback beta
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body,
+ "oauth-2025-04-20,interleaved-thinking-2025-05-14")
+ require.True(t, changed)
+ require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
+ "header 不含 server-side-fallback beta 时必须 strip fallbacks,否则上游 400")
+ require.True(t, gjson.GetBytes(out, "messages").Exists(), "strip 不得误伤其他字段")
+ })
+ }
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenHeaderEmpty(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
+ require.True(t, changed)
+ require.False(t, gjson.GetBytes(out, "fallbacks").Exists())
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenStrippedWhenCreditBetaMissing(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
+ require.True(t, changed)
+ require.False(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
+ "缺 credit/fallback beta 时必须 strip fallback_credit_token")
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenKeptWithAnyAcceptedBeta(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
+ // 三个 beta token 任意一个在 header 中都必须保留字段
+ for _, beta := range []string{
+ claude.BetaServerSideFallback,
+ claude.BetaFallbackCredit,
+ claude.BetaFallbackCreditLegacy,
+ } {
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,"+beta)
+ require.Falsef(t, changed, "header 含 %s 时 fallback_credit_token 必须保留", beta)
+ require.Truef(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
+ "header 含 %s 时 fallback_credit_token 必须保留", beta)
+ }
+}
+
+// ★ 组合场景:只带 context-management beta → 剥 fallbacks,保留 context_management
+// (守住"早退导致 fallbacks 漏洗"与"过度删除 context_management"两个方向的回归)
+func TestSanitizeAnthropicBodyForBetaTokens_StripsFallbacksKeepsContextManagement(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body, "context-management-2025-06-27")
+ require.True(t, changed)
+ require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
+ "header 只有 context-management beta → fallbacks 必须 strip")
+ require.True(t, gjson.GetBytes(out, "context_management").Exists(),
+ "context-management beta 在 header 中 → context_management 不得被误删")
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_KeepsBothWhenBothBetasPresent(t *testing.T) {
+ body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
+ out, changed := sanitizeAnthropicBodyForBetaTokens(body,
+ "context-management-2025-06-27,server-side-fallback-2026-07-01")
+ require.False(t, changed, "两个 beta 都在 header 中 → 所有字段保留")
+ require.True(t, gjson.GetBytes(out, "context_management").Exists())
+ require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
+ require.True(t, gjson.GetBytes(out, "fallback_credit_token").Exists())
+}
+
+func TestSanitizeAnthropicBodyForBetaTokens_EmptyBodyUnchanged(t *testing.T) {
+ out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "server-side-fallback-2026-07-01")
+ require.False(t, changed)
+ require.Empty(t, out)
+
+ out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "server-side-fallback-2026-07-01")
+ require.False(t, changed)
+ require.Empty(t, out)
+}
+
+// ============================================================================
+// buildUpstreamRequest 端到端
+// 挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
+// ============================================================================
+
+// OAuth mimic:FullClaudeCodeMimicryBetas 不含 fallback beta → body.fallbacks
+// 必须被 strip,且 outgoing anthropic-beta 不得注入 server-side-fallback beta
+// (剥字段,不注入 beta)。
+func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsFallbacksEndToEnd(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+
+ account := &Account{ID: 601, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
+ Credentials: map[string]any{"access_token": "oauth-tok"},
+ Status: StatusActive,
+ Schedulable: true,
+ }
+ // 客户端默认透传 "fallbacks":"default"(Claude Code / SDK / OpenCode 等)
+ body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
+ svc := &GatewayService{cfg: &config.Config{}}
+ req, _, err := svc.buildUpstreamRequest(
+ context.Background(), c, account, body,
+ "oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
+ )
+ require.NoError(t, err)
+
+ outBody := readUpstreamBodyForTest(t, req)
+ outBeta := getHeaderRaw(req.Header, "anthropic-beta")
+
+ require.False(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
+ "OAuth mimic 端到端:mimic beta 集合不含 fallback beta → outgoing body 必须没有 fallbacks,"+
+ "否则上游报 fallbacks: Extra inputs are not permitted")
+ require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaServerSideFallback),
+ "修复策略是剥字段而非注入 beta:outgoing anthropic-beta 不得含 server-side-fallback beta")
+ require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
+ "mimic beta 集合本身不受影响")
+}
+
+// API-key passthrough + 客户端 header 未带 fallback beta → strip
+func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsFallbacksWhenClientHeaderMissingBeta(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ // 客户端仅带 oauth beta,不带 server-side-fallback-2026-07-01
+ c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
+
+ body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
+ svc := &GatewayService{cfg: &config.Config{}}
+ req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
+ context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
+ )
+ require.NoError(t, err)
+ require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "fallbacks").Exists(),
+ "API-key passthrough + 客户端未带 fallback beta → strip body 字段")
+}
+
+// API-key passthrough + 客户端 header 带 fallback beta → 保留(不过度删除)
+func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesFallbacksWhenClientHeaderHasBeta(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,server-side-fallback-2026-07-01")
+
+ // 模型数组形态:有 beta 时必须原样保留
+ body := []byte(`{"model":"claude-opus-4-7","fallbacks":["claude-opus-4-6","claude-sonnet-4-6"],"messages":[]}`)
+ svc := &GatewayService{cfg: &config.Config{}}
+ req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
+ context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
+ )
+ require.NoError(t, err)
+
+ outBody := readUpstreamBodyForTest(t, req)
+ require.True(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
+ "客户端 header 带 server-side-fallback beta → 字段保留(不过度删除)")
+ fallbacks := gjson.GetBytes(outBody, "fallbacks").Array()
+ require.Len(t, fallbacks, 2)
+ require.Equal(t, "claude-opus-4-6", fallbacks[0].String())
+ require.Equal(t, "claude-sonnet-4-6", fallbacks[1].String())
+}
+
+// ============================================================================
+// Bedrock 对称 strip
+// ============================================================================
+
+// fallback beta token 不在 bedrockSupportedBetaTokens 白名单内(会被
+// filterBedrockBetaTokens 过滤),因此条件 strip 实际总会剥除——这是预期。
+func TestPrepareBedrockRequestBodyWithTokens_FallbacksRequireSupportedBeta(t *testing.T) {
+ modelID := "us.anthropic.claude-opus-4-6-v1"
+
+ t.Run("strips fallbacks when final tokens omit server-side-fallback beta", func(t *testing.T) {
+ input := `{
+ "messages":[{"role":"user","content":"hi"}],
+ "max_tokens":100,
+ "fallbacks":"default",
+ "fallback_credit_token":"tok_123"
+ }`
+ betaTokens := []string{"context-1m-2025-08-07"}
+
+ result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
+ require.NoError(t, err)
+
+ assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
+ assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
+ assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
+ assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
+ })
+
+ t.Run("strips fallbacks even when client passes server-side-fallback token (not whitelisted)", func(t *testing.T) {
+ input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"fallbacks":"default"}`
+
+ result, err := PrepareBedrockRequestBodyWithTokens(
+ []byte(input), modelID, []string{claude.BetaServerSideFallback}, false,
+ )
+ require.NoError(t, err)
+
+ assert.False(t, gjson.GetBytes(result, "fallbacks").Exists(),
+ "Bedrock 白名单不含 fallback beta → 条件 strip 总会剥除(预期)")
+ for _, name := range bedrockAnthropicBetaNames(result) {
+ assert.NotEqual(t, claude.BetaServerSideFallback, name,
+ "fallback beta 不得进入 Bedrock anthropic_beta 白名单输出")
+ }
+ })
+
+ t.Run("leaves body without fallback fields otherwise intact", func(t *testing.T) {
+ input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
+
+ result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
+ require.NoError(t, err)
+
+ assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
+ assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
+ assert.False(t, gjson.GetBytes(result, "context_management").Exists())
+ assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
+ })
+}
+
+// sanitizeBedrockCCFields:fallbacks / fallback_credit_token 是 Anthropic 直连
+// beta API 专有字段,Bedrock 无对应 beta → 无条件剥除。
+func TestSanitizeBedrockCCFields_StripsFallbacksUnconditionally(t *testing.T) {
+ body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
+ result := sanitizeBedrockCCFields(body)
+
+ assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
+ assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
+ assert.False(t, gjson.GetBytes(result, "context_management").Exists())
+ assert.True(t, gjson.GetBytes(result, "messages").Exists())
+}
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_request.go b/backend/internal/service/gateway_request.go
index 111b807f2f..f5e4a4d500 100644
--- a/backend/internal/service/gateway_request.go
+++ b/backend/internal/service/gateway_request.go
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -898,9 +899,22 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27"
// - 若两侧不一致上游 Pydantic schema 拒收:
// "context_management: Extra inputs are not permitted"
//
-// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的
-// context_management 字段:缺 beta token → strip。这将限制完全建立在
-// "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
+// fallbacks 场景(与 context_management 同构):
+// - `fallbacks` / `fallback_credit_token` 是 beta Messages API 的
+// server-side refusal fallback 字段;标准 Messages schema 没有它们,
+// 客户端(Claude Code / SDK / OpenCode 等)最近开始默认透传
+// `"fallbacks":"default"`(或模型列表)
+// - 上游接受的前提是 anthropic-beta 含 `server-side-fallback-2026-07-01`
+// (fallback_credit_token 额外接受 credit beta,见下)
+// - 缺 token 时上游 Pydantic extra='forbid' 拒收:
+// "fallbacks: Extra inputs are not permitted"
+// - 本仓不写入该字段,全部来自客户端透传;OAuth mimic 用
+// FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta),
+// 若不 strip,body 字段与 header 不对称 → 所有模型 400
+//
+// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段:
+// 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。
+// 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
//
// 调用约束:必须在 CCH 签名之前调用,否则签名 hash 与最终 body
// 不一致,上游会以 third-party 拒收。
@@ -911,23 +925,58 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string)
if len(body) == 0 {
return body, false
}
- if !gjson.GetBytes(body, "context_management").Exists() {
+
+ changed := false
+
+ // context_management:需要 context-management beta。
+ if b, deleted := stripAnthropicBodyFieldUnlessBeta(
+ body, "context_management", anthropicBetaHeader, anthropicBetaContextManagementToken,
+ ); deleted {
+ body, changed = b, true
+ }
+
+ // fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。
+ if b, deleted := stripAnthropicBodyFieldUnlessBeta(
+ body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback,
+ ); deleted {
+ body, changed = b, true
+ }
+
+ // fallback_credit_token:server-side-fallback 或(新旧任一)fallback-credit beta
+ // 任意一个即可保留。
+ if b, deleted := stripAnthropicBodyFieldUnlessBeta(
+ body, "fallback_credit_token", anthropicBetaHeader,
+ claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy,
+ ); deleted {
+ body, changed = b, true
+ }
+
+ return body, changed
+}
+
+// stripAnthropicBodyFieldUnlessBeta 当 field 存在且 anthropic-beta header 不含
+// requiredTokens 中**任何一个** token 时删除该字段(保留条件:含任一 required token)。
+// 单 token 调用即「缺该 beta 则 strip」。返回 (newBody, deleted)。
+func stripAnthropicBodyFieldUnlessBeta(body []byte, field, anthropicBetaHeader string, requiredTokens ...string) ([]byte, bool) {
+ if !gjson.GetBytes(body, field).Exists() {
return body, false
}
- if anthropicBetaTokensContains(anthropicBetaHeader, anthropicBetaContextManagementToken) {
- return body, false
+ for _, token := range requiredTokens {
+ if anthropicBetaTokensContains(anthropicBetaHeader, token) {
+ return body, false
+ }
}
- if b, err := sjson.DeleteBytes(body, "context_management"); err == nil {
- return b, true
- } else {
+ b, err := sjson.DeleteBytes(body, field)
+ if err != nil {
// 不应发生:gjson 刚验证过字段存在 + body 是合法 JSON。如果 sjson 仍报错,
- // 调用方会拿到 (body, false),但此前 computeFinalAnthropicBeta 已按“strip 后”
- // 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
+ // 调用方会拿到原 body(视为未删除),但此前 computeFinalAnthropicBeta 可能已按
+ // "strip 后" 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
logger.LegacyPrintf("service.gateway",
- "[CtxMgmtSanitize] sjson.DeleteBytes failed unexpectedly: %v (body len=%d). "+
- "body and final anthropic-beta header may be out of sync.", err, len(body))
+ "[BetaFieldSanitize] sjson.DeleteBytes(%s) failed unexpectedly: %v (body len=%d). "+
+ "body and final anthropic-beta header may be out of sync.", field, err, len(body))
+ return body, false
}
- return body, false
+ return b, true
}
// anthropicBetaTokensContains 检测逗号分隔的 anthropic-beta header 是否含指定 token。
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/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/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_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index 329cfebad4..5b2d92c69a 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -123,12 +123,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
return
}
if account.IsCNProvider() {
- // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions,
- // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。
- // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses
- // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。
- if account.GetAPIProtocol() == APIProtocolResponses ||
- (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) {
+ // 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false
+ // 走 Chat Completions 直转,跳过网络探测。
+ // 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生
+ // Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免
+ // 切换后残留强制模式。
+ if account.UsesNativeCNResponses() {
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
openai_compat.ExtraKeyResponsesSupported: true,
diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go
index f3b4eb0e6d..7da1da0055 100644
--- a/backend/internal/service/openai_apikey_responses_probe_test.go
+++ b/backend/internal/service/openai_apikey_responses_probe_test.go
@@ -60,7 +60,8 @@ func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) {
}{
{name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
{name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
- {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
+ {name: "kimi adaptive supports responses", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
+ {name: "kimi responses protocol supports responses", id: 205, platform: PlatformKimi, protocol: APIProtocolResponses, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
{name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
}
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 6acb7d242c..6c97eb9b84 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
}
@@ -309,6 +324,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 +357,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 +413,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 +469,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 +501,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 dadf89d78e..49b5f4e462 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(
@@ -181,7 +205,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 +427,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()
@@ -884,6 +953,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) {
@@ -1073,8 +1149,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
@@ -1088,19 +1165,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(
@@ -1119,6 +1196,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()
@@ -1817,7 +1940,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")
@@ -1827,6 +1950,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)
diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go
index ce76b4a74b..23486ba2f8 100644
--- a/backend/internal/service/openai_fast_policy_test.go
+++ b/backend/internal/service/openai_fast_policy_test.go
@@ -36,7 +36,13 @@ func (s *openAIFastPolicyRepoStub) Set(ctx context.Context, key, value string) e
}
func (s *openAIFastPolicyRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
- panic("unexpected GetMultiple call")
+ result := make(map[string]string, len(keys))
+ for _, key := range keys {
+ if value, ok := s.values[key]; ok {
+ result[key] = value
+ }
+ }
+ return result, nil
}
func (s *openAIFastPolicyRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 82c6a0faa8..10232829fa 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -102,13 +102,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
// 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。
- // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM
+ // Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM
// 没有 Responses 端点,先转换成 Chat Completions 再直转。
if account.IsAdaptiveAPIProtocol() {
if !isResponsesShape {
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
- if account.Platform != PlatformDeepseek {
+ if !account.SupportsNativeCNResponses() {
var responsesReq apicompat.ResponsesRequest
if err := json.Unmarshal(body, &responsesReq); err != nil {
return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err)
@@ -126,7 +126,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel)
}
- // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。
+ // DeepSeek / Kimi 原生 Responses 请求继续走下方 Responses→Chat 回程转换。
}
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点,
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
index 7785e44ed8..a98c11e5e8 100644
--- a/backend/internal/service/openai_gateway_chat_completions_raw.go
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -39,7 +39,8 @@ var openaiCCRawAllowedHeaders = map[string]bool{
// `{base_url}/v1/chat/completions`,**不**做 CC↔Responses 协议转换。
//
// 适用场景:account.platform=openai && account.type=apikey && 上游已被探测确认
-// 不支持 /v1/responses 端点(如 DeepSeek/Kimi/GLM/Qwen 等第三方 OpenAI 兼容上游)。
+// 不支持 /v1/responses 端点(如 GLM/Qwen 等第三方 OpenAI 兼容上游);CN 供应商
+// 固定 chat_completions 协议也走此路径。
//
// 与 ForwardAsChatCompletions 的关键差异:
//
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index 6ed06f1e8a..18b5aff28e 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -133,8 +133,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
- nativeDeepSeekResponses := account.Platform == PlatformDeepseek &&
- (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol())
+ nativeCNResponses := account.UsesNativeCNResponses()
+ nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses
if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath &&
needsOpenAIResponsesClientToolAdaptation(body) {
adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
@@ -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 && !nativeCNResponses {
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
}
@@ -1275,13 +1275,13 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
return false
}
if account.IsCNProvider() {
- // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生
- // Responses,Kimi/GLM 回退 Chat Completions。
+ // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
+ // 有原生 Responses,GLM 回退 Chat Completions。
switch account.GetAPIProtocol() {
case APIProtocolChatCompletions:
return true
case APIProtocolAdaptive:
- return account.Platform != PlatformDeepseek
+ return !account.SupportsNativeCNResponses()
default:
return false
}
@@ -1305,7 +1305,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
case AccountTypeAPIKey:
// API Key accounts use Platform API or custom base URL
baseURL := account.GetOpenAIBaseURL()
- if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() {
+ if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
}
if baseURL == "" {
@@ -1322,7 +1322,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
- // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除
+ // DeepSeek / Kimi 原生 Responses 端点为无状态实现:强制 store=false、清除
// previous_response_id,避免携带状态字段被上游拒绝。
body = normalizeDeepSeekResponsesRequestBody(account, body)
diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go
index 4bd4ba26c1..ecca154781 100644
--- a/backend/internal/service/openai_gateway_passthrough.go
+++ b/backend/internal/service/openai_gateway_passthrough.go
@@ -591,6 +591,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
}
case AccountTypeAPIKey:
baseURL := account.GetOpenAIBaseURL()
+ if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
+ baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
+ }
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
@@ -601,7 +604,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
}
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
- // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
+ // DeepSeek / Kimi 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
body = normalizeDeepSeekResponsesRequestBody(account, body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go
index 8b6497a2cb..1973bba56e 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/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go
index 2d0c91852b..46f69b54d6 100644
--- a/backend/internal/service/openai_gateway_request_body.go
+++ b/backend/internal/service/openai_gateway_request_body.go
@@ -118,12 +118,12 @@ func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body m
}
}
-// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点:
-// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端
-// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。
+// normalizeDeepSeekResponsesRequestBody 适配无状态 CN Responses 端点:
+// 强制 store=false 并清除 previous_response_id(DeepSeek / Kimi 官方
+// Responses 均不支持服务端状态存储,携带这些字段会被拒绝)。
+// 非原生 Responses 协议账号原样返回。
func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte {
- if account == nil || account.Platform != PlatformDeepseek ||
- (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) {
+ if account == nil || !account.UsesNativeCNResponses() {
return body
}
normalized, err := sjson.SetBytes(body, "store", false)
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 99b2e8f618..36171ae72d 100644
--- a/backend/internal/service/openai_oauth_passthrough_test.go
+++ b/backend/internal/service/openai_oauth_passthrough_test.go
@@ -2544,6 +2544,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"))
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_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go
index 94738221a2..939db28437 100644
--- a/backend/internal/service/openai_ws_forwarder_payload.go
+++ b/backend/internal/service/openai_ws_forwarder_payload.go
@@ -41,6 +41,9 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri
}
case AccountTypeAPIKey:
baseURL := account.GetOpenAIBaseURL()
+ if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
+ baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
+ }
if baseURL == "" {
targetURL = openaiPlatformAPIURL
} else {
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 结束超时")
+ }
+ })
+ }
+}
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() {
diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go
index 6ae24dd39e..826f6a7803 100644
--- a/backend/internal/service/pricing_service.go
+++ b/backend/internal/service/pricing_service.go
@@ -1,15 +1,18 @@
package service
import (
+ "bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
+ "maps"
"os"
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -22,18 +25,24 @@ 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$`)
+ // 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
- 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 +53,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 +67,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 +81,6 @@ var (
CacheCreationInputTokenCostPriority: 5e-07,
CacheReadInputTokenCost: 2e-08,
CacheReadInputTokenCostPriority: 4e-08,
- LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
- LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
- LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -388,6 +388,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()
@@ -408,6 +409,7 @@ func (s *PricingService) downloadPricingData() error {
// 更新内存数据
s.mu.Lock()
+ warnDroppedLongContextLadders(s.pricingData, data)
s.pricingData = data
s.lastUpdated = time.Now()
s.localHash = syncHash
@@ -424,9 +426,11 @@ 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
+ var orphanCacheTiers, lopsidedLadders []string
for modelName, rawEntry := range rawData {
// 跳过 sample_spec 等文档条目
@@ -500,12 +504,28 @@ 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)
+ 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
}
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")
@@ -514,6 +534,270 @@ 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 档在计费中统一跟随输入倍率,不单独折算:
+// 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段
+// 无法参与计费,由 orphanCacheTierFields 哨兵告警。
+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
+}
+
+// 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 在两层数据合并后
+// 统一并入——若在此处抢先建条目,纯 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)
@@ -527,12 +811,14 @@ 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)
hashStr := hex.EncodeToString(hash[:])
s.mu.Lock()
+ warnDroppedLongContextLadders(s.pricingData, pricingData)
s.pricingData = pricingData
s.localHash = hashStr
@@ -579,6 +865,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_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/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go
index 1a933aec10..a02c74fe72 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,274 @@ 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)
+}
+
+// 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}
+
+ 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/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,
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/config.example.yaml b/deploy/config.example.yaml
index 033cb29908..c8798ee171 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(可选)
@@ -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
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"]
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 364436d109..8702ca7595 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1319,7 +1319,7 @@
/>
-
+
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
@@ -3814,6 +3814,7 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
+ cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
isHeaderOverrideCapable,
@@ -4006,7 +4007,7 @@ const upstreamBillingAutoProbeEnabled = ref(true)
// ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ──
const accountMode = ref('payg')
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
-// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。
+// responses=deepseek / kimi 原生 Responses 端点(Codex)。与账号类型正交。
const apiProtocol = ref('adaptive')
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
const zhipuOrganization = ref('')
@@ -4027,14 +4028,14 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
}
return 'kimi'
})
-// 当前平台可选的协议档(responses 仅 deepseek)。
+// 当前平台可选的协议档(responses 仅 deepseek / kimi)。
const cnProtocolOptions = computed>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
{ value: 'adaptive', labelKey: 'adaptive' },
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
- if (form.platform === 'deepseek') {
+ if (cnSupportsNativeResponses(form.platform)) {
opts.push({ value: 'responses', labelKey: 'responses' })
}
return opts
@@ -4044,7 +4045,7 @@ const cnAdaptiveProtocolOptions = computed
-
+
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
@@ -2909,6 +2909,7 @@ import {
isHeaderOverrideCapable,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
+ cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
@@ -3057,7 +3058,7 @@ const cnProtocolOptions = computed {
@@ -3947,7 +3948,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
storedProtocol === 'responses'
? storedProtocol
: 'chat_completions'
- if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') {
+ if (!cnSupportsNativeResponses(newAccount.platform) && editApiProtocol.value === 'responses') {
editApiProtocol.value = 'chat_completions'
}
const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value)
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
index 26305c4233..876e719fd7 100644
--- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
@@ -375,7 +375,31 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
base_url: 'https://api.moonshot.cn/v1',
api_base_urls: {
chat_completions: 'https://api.moonshot.cn/v1',
- anthropic: 'https://api.moonshot.cn/anthropic'
+ anthropic: 'https://api.moonshot.cn/anthropic',
+ responses: 'https://api.moonshot.cn/v1'
+ }
+ })
+ })
+
+ it('submits adaptive Kimi Coding Plan Responses endpoint', async () => {
+ const wrapper = mountModal()
+ await selectButtonByText(wrapper, 'Kimi')
+ await selectButtonByText(wrapper, 'admin.accounts.cnProviders.accountMode.coding')
+ await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi coding')
+ await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi-coding')
+
+ await wrapper.get('form#create-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAccountMock).toHaveBeenCalledTimes(1)
+ expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
+ account_mode: 'coding',
+ api_protocol: 'adaptive',
+ base_url: 'https://api.kimi.com/coding/v1',
+ api_base_urls: {
+ chat_completions: 'https://api.kimi.com/coding/v1',
+ anthropic: 'https://api.kimi.com/coding',
+ responses: 'https://api.kimi.com/coding/v1'
}
})
})
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index eceb670c92..d81ffacebe 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -355,6 +355,39 @@ describe('EditAccountModal', () => {
})
})
+ it('preserves adaptive Kimi Responses endpoint on submit', async () => {
+ const account = buildAccount()
+ account.platform = 'kimi'
+ account.credentials = {
+ api_key: 'sk-kimi',
+ account_mode: 'payg',
+ api_protocol: 'adaptive',
+ base_url: 'https://api.moonshot.cn/v1',
+ api_base_urls: {
+ chat_completions: 'https://api.moonshot.cn/v1',
+ anthropic: 'https://api.moonshot.cn/anthropic',
+ responses: 'https://api.moonshot.cn/v1'
+ }
+ }
+ updateAccountMock.mockReset().mockResolvedValue(account)
+ checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
+
+ const wrapper = mountModal(account)
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+
+ expect(updateAccountMock).toHaveBeenCalledTimes(1)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
+ account_mode: 'payg',
+ api_protocol: 'adaptive',
+ base_url: 'https://api.moonshot.cn/v1',
+ api_base_urls: {
+ chat_completions: 'https://api.moonshot.cn/v1',
+ anthropic: 'https://api.moonshot.cn/anthropic',
+ responses: 'https://api.moonshot.cn/v1'
+ }
+ })
+ })
+
it('preserves adaptive GLM endpoints on submit', async () => {
const account = buildAccount()
account.platform = 'zhipu'
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
index 618210ddf0..d7326631e1 100644
--- a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
@@ -1,18 +1,27 @@
import { describe, expect, it } from 'vitest'
-import { defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
+import { cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
+
+describe('cnSupportsNativeResponses', () => {
+ it('is true for DeepSeek and Kimi only', () => {
+ expect(cnSupportsNativeResponses('deepseek')).toBe(true)
+ expect(cnSupportsNativeResponses('kimi')).toBe(true)
+ expect(cnSupportsNativeResponses('zhipu')).toBe(false)
+ expect(cnSupportsNativeResponses('openai')).toBe(false)
+ })
+})
describe('defaultCNAdaptiveBaseUrls', () => {
it('resolves Kimi endpoints by account mode', () => {
expect(defaultCNAdaptiveBaseUrls('kimi', 'payg')).toEqual({
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic',
- responses: ''
+ responses: 'https://api.moonshot.cn/v1'
})
expect(defaultCNAdaptiveBaseUrls('kimi', 'coding')).toEqual({
chat_completions: 'https://api.kimi.com/coding/v1',
anthropic: 'https://api.kimi.com/coding',
- responses: ''
+ responses: 'https://api.kimi.com/coding/v1'
})
})
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index 7f844fba0a..0ed581567c 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -256,10 +256,15 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
export type CnAccountMode = 'payg' | 'coding'
-/** 仅 deepseek 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
+/** deepseek / kimi 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses'
export type CnNativeApiProtocol = Exclude
+/** DeepSeek 与 Kimi(按量付费 / Coding Plan)提供原生 Responses 端点。 */
+export function cnSupportsNativeResponses(platform: string): boolean {
+ return platform === 'deepseek' || platform === 'kimi'
+}
+
export interface CnBaseUrlPreset {
mode: CnAccountMode
protocol: CnApiProtocol
@@ -273,8 +278,10 @@ export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUr
kimi: [
{ mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' },
{ mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' },
+ { mode: 'payg', protocol: 'responses', label: 'Moonshot Responses', url: 'https://api.moonshot.cn/v1' },
{ mode: 'coding', protocol: 'chat_completions', label: 'Kimi For Coding', url: 'https://api.kimi.com/coding/v1' },
- { mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' }
+ { mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' },
+ { mode: 'coding', protocol: 'responses', label: 'Kimi Coding Responses', url: 'https://api.kimi.com/coding/v1' }
],
zhipu: [
{ mode: 'payg', protocol: 'chat_completions', label: 'GLM PaaS', url: 'https://open.bigmodel.cn/api/paas/v4' },
@@ -307,7 +314,7 @@ export function defaultCNBaseUrl(
return ''
}
}
- // responses 仅 deepseek:base 与 chat_completions 相同(端点路径差异由后端处理)。
+ // responses:Kimi / DeepSeek 的 base 与 chat_completions 相同(端点路径差异由后端处理)。
switch (platform) {
case 'kimi':
return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1'
@@ -330,7 +337,7 @@ export function defaultCNAdaptiveBaseUrls(
return {
chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'),
- responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : ''
+ responses: cnSupportsNativeResponses(platform) ? defaultCNBaseUrl(platform, mode, 'responses') : ''
}
}