From 5fcbe7e3070875a92d05299eafe30f67e897ad7f Mon Sep 17 00:00:00 2001 From: CHOS1N Date: Sun, 5 Jul 2026 13:58:08 +0800 Subject: [PATCH 01/29] Add response_format compatibility mapping --- .../chatcompletions_responses_bridge.go | 3 + .../chatcompletions_responses_bridge_test.go | 54 +++++++++++ .../chatcompletions_responses_test.go | 56 +++++++++++ .../apicompat/chatcompletions_to_responses.go | 7 ++ .../internal/pkg/apicompat/response_format.go | 92 +++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 2 + 6 files changed, 214 insertions(+) create mode 100644 backend/internal/pkg/apicompat/response_format.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f0570b58ec..eeeedd29aa 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -38,6 +38,9 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR if len(req.ToolChoice) > 0 { out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) } + if req.Text != nil { + out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format) + } return out, nil } diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 3e55e23a81..b194d88141 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -73,6 +73,60 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t * assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content)) } +func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"role":"user","content":"Return JSON"} + ]`), + Text: &ResponsesText{ + Format: json.RawMessage(`{"type":"json_object"}`), + }, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"json_object"}`, string(out.ResponseFormat)) +} + +func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"role":"user","content":"Return structured JSON"} + ]`), + Text: &ResponsesText{ + Format: json.RawMessage(`{ + "type":"json_schema", + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + }`), + }, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type":"json_schema", + "json_schema":{ + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + } + }`, string(out.ResponseFormat)) +} + func chatMessageRoles(messages []ChatMessage) []string { roles := make([]string, 0, len(messages)) for _, message := range messages { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 795a73938e..b30330863c 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -242,6 +242,62 @@ func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { assert.Equal(t, "auto", resp.Reasoning.Summary) } +func TestChatCompletionsToResponses_ResponseFormatJsonObject(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return JSON"`)}}, + ResponseFormat: json.RawMessage(`{"type":"json_object"}`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Text) + assert.JSONEq(t, `{"type":"json_object"}`, string(resp.Text.Format)) + + payload, err := json.Marshal(resp) + require.NoError(t, err) + var serialized struct { + Text ResponsesText `json:"text"` + } + require.NoError(t, json.Unmarshal(payload, &serialized)) + assert.JSONEq(t, `{"type":"json_object"}`, string(serialized.Text.Format)) +} + +func TestChatCompletionsToResponses_ResponseFormatJsonSchema(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return structured JSON"`)}}, + ResponseFormat: json.RawMessage(`{ + "type":"json_schema", + "json_schema":{ + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + } + }`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Text) + assert.JSONEq(t, `{ + "type":"json_schema", + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + }`, string(resp.Text.Format)) +} + func TestChatCompletionsToResponses_ImageURL(t *testing.T) { content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]` req := &ChatCompletionsRequest{ diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 7cbb4f5f20..07c557ab3b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -69,6 +69,13 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } } + if format := chatResponseFormatToResponsesTextFormat(req.ResponseFormat); len(format) > 0 { + if out.Text == nil { + out.Text = &ResponsesText{} + } + out.Text.Format = format + } + // tools[] and legacy functions[] → ResponsesTool[] if len(req.Tools) > 0 || len(req.Functions) > 0 { out.Tools = convertChatToolsToResponses(req.Tools, req.Functions) diff --git a/backend/internal/pkg/apicompat/response_format.go b/backend/internal/pkg/apicompat/response_format.go new file mode 100644 index 0000000000..afb5c3e2fd --- /dev/null +++ b/backend/internal/pkg/apicompat/response_format.go @@ -0,0 +1,92 @@ +package apicompat + +import "encoding/json" + +func chatResponseFormatToResponsesTextFormat(raw json.RawMessage) json.RawMessage { + raw = normalizedRawJSON(raw) + if len(raw) == 0 { + return nil + } + + obj, ok := rawJSONObject(raw) + if !ok || rawString(obj["type"]) != "json_schema" { + return raw + } + + schemaRaw := normalizedRawJSON(obj["json_schema"]) + if len(schemaRaw) == 0 { + return raw + } + + var schema map[string]json.RawMessage + if err := json.Unmarshal(schemaRaw, &schema); err != nil { + return raw + } + schema["type"] = rawJSONString("json_schema") + + out, err := json.Marshal(schema) + if err != nil { + return raw + } + return out +} + +func responsesTextFormatToChatResponseFormat(raw json.RawMessage) json.RawMessage { + raw = normalizedRawJSON(raw) + if len(raw) == 0 { + return nil + } + + obj, ok := rawJSONObject(raw) + if !ok || rawString(obj["type"]) != "json_schema" { + return raw + } + if _, alreadyChatShape := obj["json_schema"]; alreadyChatShape { + return raw + } + + schema := make(map[string]json.RawMessage, len(obj)) + for key, value := range obj { + if key == "type" { + continue + } + schema[key] = value + } + if len(schema) == 0 { + return raw + } + + schemaRaw, err := json.Marshal(schema) + if err != nil { + return raw + } + out, err := json.Marshal(map[string]json.RawMessage{ + "type": rawJSONString("json_schema"), + "json_schema": schemaRaw, + }) + if err != nil { + return raw + } + return out +} + +func normalizedRawJSON(raw json.RawMessage) json.RawMessage { + raw = bytesTrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return nil + } + return append(json.RawMessage(nil), raw...) +} + +func rawJSONObject(raw json.RawMessage) (map[string]json.RawMessage, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, false + } + return obj, true +} + +func rawJSONString(value string) json.RawMessage { + data, _ := json.Marshal(value) + return data +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index d293780278..cf5bf106bc 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -216,6 +216,7 @@ type ResponsesReasoning struct { // ResponsesText configures text output options in the Responses API. type ResponsesText struct { + Format json.RawMessage `json:"format,omitempty"` Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high" } @@ -438,6 +439,7 @@ type ChatCompletionsRequest struct { ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh" ServiceTier string `json:"service_tier,omitempty"` Stop json.RawMessage `json:"stop,omitempty"` // string or []string + ResponseFormat json.RawMessage `json:"response_format,omitempty"` // Legacy function calling (deprecated but still supported) Functions []ChatFunction `json:"functions,omitempty"` From e2326a7998636048dd6b5a77e582d4d71b3c1b0c Mon Sep 17 00:00:00 2001 From: CHOS1N Date: Mon, 6 Jul 2026 12:14:30 +0800 Subject: [PATCH 02/29] Format response format types --- backend/internal/pkg/apicompat/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index cf5bf106bc..a0fd07a0d1 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -217,7 +217,7 @@ type ResponsesReasoning struct { // ResponsesText configures text output options in the Responses API. type ResponsesText struct { Format json.RawMessage `json:"format,omitempty"` - Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high" + Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high" } // ResponsesInputItem is one item in the Responses API input array. From 2a3dcb499f0e86ec3b495b17fec01e350dab6361 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 14:06:21 +0800 Subject: [PATCH 03/29] =?UTF-8?q?fix(scheduler):=20=E7=A9=BA=20model=5Fmap?= =?UTF-8?q?ping=20=E7=9A=84=20OpenAI=20OAuth=20=E8=B4=A6=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=90=B8=E6=94=B6=E5=85=A8=E9=83=A8=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空映射此前一律视为「允许所有」,导致混合部署下请求非 OpenAI 模型 别名(deepseek-v4 等)时,OAuth 账号可能被调度选中;转发阶段对未知 模型原样透传,Codex 上游返回不可重试的 400,请求无法 failover 到 真正支持该模型的 API Key 账号。 IsModelSupported 对空映射的 OpenAI OAuth(未开透传)账号改为与转发 归一化对齐的判定 isOpenAIOAuthServableModel:可归一到已知 Codex 模型 集合(含 gpt-image-*、推理后缀、别名拼写)或 claude-* dispatch 系列 才视为可服务。API Key/显式映射/透传/其他平台语义不变。 Fixes #3662 --- backend/internal/service/account.go | 11 +- .../internal/service/openai_model_mapping.go | 28 +++++ .../openai_oauth_model_support_test.go | 103 ++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_oauth_model_support_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ae25bf387d..bed15d9c8d 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -750,10 +750,19 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st } // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符) -// 如果未配置 mapping,返回 true(允许所有模型) +// 如果未配置 mapping,返回 true(允许所有模型)。 +// +// 例外:OpenAI OAuth 账号(Codex 上游)的空映射不再视为「允许所有」—— +// 转发阶段 normalizeOpenAIModelForUpstream 会把未知模型原样透传,Codex 上游 +// 对 deepseek-*/glm-* 等第三方模型别名必然返回不可重试的 400,导致请求 +// 卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。 +// 此处的可服务判定与转发阶段的归一化行为对齐,见 isOpenAIOAuthServableModel。 func (a *Account) IsModelSupported(requestedModel string) bool { mapping := a.GetModelMapping() if len(mapping) == 0 { + if a.IsOpenAIOAuth() && !a.IsOpenAIPassthroughEnabled() { + return isOpenAIOAuthServableModel(requestedModel) + } return true // 无映射 = 允许所有 } if mappingSupportsRequestedModel(mapping, requestedModel) { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 7cec521221..196ecbc86d 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -20,6 +20,34 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo return mappedModel } +// isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否 +// 服务请求模型。与转发阶段 normalizeOpenAIModelForUpstream → normalizeCodexModel +// 的行为对齐:只有会被归一到已知 Codex 模型集合的请求(含 gpt-image-* 与 +// 推理后缀变体),或 /v1/messages 调度下有默认映射兜底的 claude-* 系列, +// 才视为可服务。其余模型(deepseek-*/glm-* 等第三方别名)原样透传必然被 +// Codex 上游以不可重试的 400 拒绝,应在调度阶段就跳过该账号(#3662)。 +func isOpenAIOAuthServableModel(requestedModel string) bool { + model := strings.TrimSpace(requestedModel) + if model == "" { + return true // 空模型交由上层必填校验处理 + } + // /v1/messages 调度:claude-* 系列由分组/全局默认映射兜底,见 resolveOpenAIForwardModel。 + if claudeMessagesDispatchFamily(model) != "" { + return true + } + if _, ok := normalizeKnownCodexModel(model); ok { + return true + } + // 兜底剥离 -low/-high 等推理后缀(gpt-5.4-high → gpt-5.4)后再试一次, + // 与 /v1/messages 入站的 routingModel 归一化保持一致。 + if normalized := NormalizeOpenAICompatRequestedModel(model); normalized != model { + if _, ok := normalizeKnownCodexModel(normalized); ok { + return true + } + } + return false +} + // resolveOpenAICompactForwardModel determines the compact-only upstream model // for /responses/compact requests. It never affects normal /responses traffic. // When no compact-specific mapping matches, the input model is returned as-is. diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go new file mode 100644 index 0000000000..550eb298c7 --- /dev/null +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -0,0 +1,103 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func newOpenAIOAuthAccountForModelTest() *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + } +} + +func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) { + account := newOpenAIOAuthAccountForModelTest() + + servable := []string{ + "", // 空模型交由上层必填校验 + "gpt-5.4", + "gpt-5.4-high", // 推理后缀变体 + "gpt-5.3-codex", + "gpt-5.3-codex-xhigh", + "gpt-5.1-codex-mini", + "gpt-5", + "codex-mini-latest", + "gpt5.3codexspark", // 别名拼写归一化 + "gpt-image-1", // 图像生成模型 + "claude-sonnet-4-6", // /v1/messages 调度默认映射兜底 + "claude-3-opus-20240229", + } + for _, model := range servable { + require.True(t, account.IsModelSupported(model), "expected %q to be servable by empty-mapping OpenAI OAuth account", model) + } +} + +func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testing.T) { + account := newOpenAIOAuthAccountForModelTest() + + // Codex 上游必然以不可重试的 400 拒绝这些模型;调度阶段就应跳过该账号, + // 让显式声明支持的 API Key 账号接手(#3662)。 + foreign := []string{ + "deepseek-v4", + "deepseek-chat", + "glm-4.7", + "kimi-k2", + "gemini-3.0-pro", + "grok-4", + "qwen3-max", + } + for _, model := range foreign { + require.False(t, account.IsModelSupported(model), "expected %q to be rejected by empty-mapping OpenAI OAuth account", model) + } +} + +func TestIsModelSupported_OpenAIOAuthExplicitMappingUnchanged(t *testing.T) { + account := newOpenAIOAuthAccountForModelTest() + account.Credentials = map[string]any{ + "model_mapping": map[string]any{"deepseek-v4": "gpt-5.4"}, + } + + // 显式映射沿用原有语义:命中映射即支持,未命中即不支持。 + require.True(t, account.IsModelSupported("deepseek-v4")) + require.False(t, account.IsModelSupported("glm-4.7")) +} + +func TestIsModelSupported_OpenAIOAuthPassthroughAllowsAll(t *testing.T) { + account := newOpenAIOAuthAccountForModelTest() + account.Extra = map[string]any{"openai_passthrough": true} + + // 透传模式仅替换认证,模型语义由上游决定,保持"允许所有"。 + require.True(t, account.IsModelSupported("deepseek-v4")) +} + +func TestIsModelSupported_OpenAIAPIKeyEmptyMappingAllowsAll(t *testing.T) { + account := &Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + // API Key 账号(第三方 OpenAI 兼容上游)可服务任意别名,语义不变。 + require.True(t, account.IsModelSupported("deepseek-v4")) + require.True(t, account.IsModelSupported("gpt-5.4")) +} + +func TestIsModelSupported_NonOpenAIPlatformsUnchanged(t *testing.T) { + anthropic := &Account{ID: 3, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + require.True(t, anthropic.IsModelSupported("claude-sonnet-4-6")) + require.True(t, anthropic.IsModelSupported("deepseek-v4")) +} + +func TestIsOpenAIOAuthServableModel(t *testing.T) { + require.True(t, isOpenAIOAuthServableModel("gpt-5.4-high")) + require.True(t, isOpenAIOAuthServableModel(" gpt-5.3-codex ")) + require.True(t, isOpenAIOAuthServableModel("claude-3-5-haiku-20241022")) + require.False(t, isOpenAIOAuthServableModel("claude-unknown-family")) // 无 opus/sonnet/haiku 关键字,无默认映射兜底 + require.False(t, isOpenAIOAuthServableModel("deepseek-v4")) +} From 9b75c7b76c7d384662360e3fd80a2bd853fc956c Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 14:19:58 +0800 Subject: [PATCH 04/29] =?UTF-8?q?fix(scheduler):=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=BC=82=E6=97=8F=E5=8E=82=E5=95=86=E5=89=8D=E7=BC=80=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=8C=E5=85=BC=E5=AE=B9=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=BA=A7=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现白名单守卫有误伤面:渠道级模型映射在账号选定之后才改写 请求体模型名,调度过滤看到的是改写前的原始别名。若按 Codex 白名单 判定,「渠道映射自定义别名 → gpt-5.x + 空映射 OAuth 账号」的既有 部署会被误判为 model_not_found。 改为保守 fail-open 黑名单:空映射默认仍允许,仅排除 deepseek/glm/ kimi/qwen/gemini/grok 等明确异族厂商前缀(Codex 上游绝无可能服务, 透传必然换来不可重试 400)。前缀分类先例见 ResolveThinkingProtocol。 --- backend/internal/service/account.go | 10 ++-- .../internal/service/openai_model_mapping.go | 60 +++++++++++++------ .../openai_oauth_model_support_test.go | 18 ++++-- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index bed15d9c8d..d099f93979 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -752,11 +752,11 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符) // 如果未配置 mapping,返回 true(允许所有模型)。 // -// 例外:OpenAI OAuth 账号(Codex 上游)的空映射不再视为「允许所有」—— -// 转发阶段 normalizeOpenAIModelForUpstream 会把未知模型原样透传,Codex 上游 -// 对 deepseek-*/glm-* 等第三方模型别名必然返回不可重试的 400,导致请求 -// 卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。 -// 此处的可服务判定与转发阶段的归一化行为对齐,见 isOpenAIOAuthServableModel。 +// 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商 +// 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream +// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致 +// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。 +// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。 func (a *Account) IsModelSupported(requestedModel string) bool { mapping := a.GetModelMapping() if len(mapping) == 0 { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 196ecbc86d..32c4ec55f6 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -20,32 +20,54 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo return mappedModel } +// openAIOAuthForeignModelPrefixes 列出明确属于其他厂商家族的模型名前缀。 +// Codex 上游不可能服务这些模型:转发阶段 normalizeOpenAIModelForUpstream +// 对未知模型原样透传,上游必然返回不可重试的 400。 +// +// 采用保守黑名单而非 Codex 模型白名单:未知/自定义别名保持「允许」, +// 以兼容渠道级模型映射等「账号选定之后才改写模型名」的部署方式 +// (调度过滤看到的是改写前的原始模型名)。前缀分类的先例见 +// ResolveThinkingProtocol(thinking_protocol.go)。 +var openAIOAuthForeignModelPrefixes = []string{ + "deepseek", + "glm-", + "kimi-", + "moonshot", + "qwen", + "qwq-", + "minimax", + "gemini-", + "gemma-", + "grok-", + "doubao-", + "hunyuan-", + "llama", + "meta-llama", + "mistral", + "mixtral", + "baichuan", + "ernie-", + "step-", + "seed-", + "yi-", +} + // isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否 -// 服务请求模型。与转发阶段 normalizeOpenAIModelForUpstream → normalizeCodexModel -// 的行为对齐:只有会被归一到已知 Codex 模型集合的请求(含 gpt-image-* 与 -// 推理后缀变体),或 /v1/messages 调度下有默认映射兜底的 claude-* 系列, -// 才视为可服务。其余模型(deepseek-*/glm-* 等第三方别名)原样透传必然被 -// Codex 上游以不可重试的 400 拒绝,应在调度阶段就跳过该账号(#3662)。 +// 服务请求模型。空映射默认仍是「允许」,仅排除明确属于其他厂商家族的模型 +// (deepseek-*/glm-* 等)——这类请求原样透传必然被 Codex 上游以不可重试的 +// 400 拒绝,且不触发 failover,应在调度阶段就跳过该账号,把请求让给 +// 显式声明支持该模型的账号(#3662)。 func isOpenAIOAuthServableModel(requestedModel string) bool { - model := strings.TrimSpace(requestedModel) + model := strings.ToLower(lastOpenAIModelSegment(requestedModel)) if model == "" { return true // 空模型交由上层必填校验处理 } - // /v1/messages 调度:claude-* 系列由分组/全局默认映射兜底,见 resolveOpenAIForwardModel。 - if claudeMessagesDispatchFamily(model) != "" { - return true - } - if _, ok := normalizeKnownCodexModel(model); ok { - return true - } - // 兜底剥离 -low/-high 等推理后缀(gpt-5.4-high → gpt-5.4)后再试一次, - // 与 /v1/messages 入站的 routingModel 归一化保持一致。 - if normalized := NormalizeOpenAICompatRequestedModel(model); normalized != model { - if _, ok := normalizeKnownCodexModel(normalized); ok { - return true + for _, prefix := range openAIOAuthForeignModelPrefixes { + if strings.HasPrefix(model, prefix) { + return false } } - return false + return true } // resolveOpenAICompactForwardModel determines the compact-only upstream model diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go index 550eb298c7..b08c42a7ea 100644 --- a/backend/internal/service/openai_oauth_model_support_test.go +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -24,14 +24,15 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) { "gpt-5.4", "gpt-5.4-high", // 推理后缀变体 "gpt-5.3-codex", - "gpt-5.3-codex-xhigh", "gpt-5.1-codex-mini", "gpt-5", "codex-mini-latest", - "gpt5.3codexspark", // 别名拼写归一化 + "gpt5.3codexspark", // 别名拼写 "gpt-image-1", // 图像生成模型 "claude-sonnet-4-6", // /v1/messages 调度默认映射兜底 "claude-3-opus-20240229", + "gpt-4o", // 保守 fail-open:非黑名单模型保持允许 + "my-custom-alias", // 自定义别名可能由渠道级映射在转发前改写,保持允许 } for _, model := range servable { require.True(t, account.IsModelSupported(model), "expected %q to be servable by empty-mapping OpenAI OAuth account", model) @@ -41,16 +42,20 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) { func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testing.T) { account := newOpenAIOAuthAccountForModelTest() - // Codex 上游必然以不可重试的 400 拒绝这些模型;调度阶段就应跳过该账号, - // 让显式声明支持的 API Key 账号接手(#3662)。 + // Codex 上游必然以不可重试的 400 拒绝这些厂商家族;调度阶段就应跳过 + // 该账号,让显式声明支持的 API Key 账号接手(#3662)。 foreign := []string{ "deepseek-v4", "deepseek-chat", "glm-4.7", "kimi-k2", + "moonshot-v1-128k", "gemini-3.0-pro", "grok-4", "qwen3-max", + "minimax-m2.5", + "llama-3.3-70b", + "provider/deepseek-v4", // vendor/model 形式取最后一段判定 } for _, model := range foreign { require.False(t, account.IsModelSupported(model), "expected %q to be rejected by empty-mapping OpenAI OAuth account", model) @@ -98,6 +103,7 @@ func TestIsOpenAIOAuthServableModel(t *testing.T) { require.True(t, isOpenAIOAuthServableModel("gpt-5.4-high")) require.True(t, isOpenAIOAuthServableModel(" gpt-5.3-codex ")) require.True(t, isOpenAIOAuthServableModel("claude-3-5-haiku-20241022")) - require.False(t, isOpenAIOAuthServableModel("claude-unknown-family")) // 无 opus/sonnet/haiku 关键字,无默认映射兜底 - require.False(t, isOpenAIOAuthServableModel("deepseek-v4")) + require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许 + require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感 + require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking")) } From fa01aec80d101fa3a28639b1b6324e6fdfc1d777 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 15:00:00 +0800 Subject: [PATCH 05/29] =?UTF-8?q?fix:=20=E5=89=8D=E7=BC=80=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=8A=A0=E8=BF=9E=E5=AD=97=E7=AC=A6=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=20thinking=5Fprotocol.go=20=E9=A3=8E=E6=A0=BC=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepseek → deepseek-、moonshot → moonshot-、qwen → qwen-/qwen2-/qwen3-/qwen4-、 minimax → minimax-、llama → llama-/llama2-/llama3-、mistral → mistral-、 mixtral → mixtral-。避免无连字符前缀误伤假想的连写模型名。 --- .../internal/service/openai_model_mapping.go | 21 ++++++++++++------- .../openai_oauth_model_support_test.go | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 32c4ec55f6..cb7a8ca84b 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -29,23 +29,28 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo // (调度过滤看到的是改写前的原始模型名)。前缀分类的先例见 // ResolveThinkingProtocol(thinking_protocol.go)。 var openAIOAuthForeignModelPrefixes = []string{ - "deepseek", + "deepseek-", "glm-", "kimi-", - "moonshot", - "qwen", + "moonshot-", + "qwen-", + "qwen2-", + "qwen3-", + "qwen4-", "qwq-", - "minimax", + "minimax-", "gemini-", "gemma-", "grok-", "doubao-", "hunyuan-", - "llama", + "llama-", + "llama2-", + "llama3-", "meta-llama", - "mistral", - "mixtral", - "baichuan", + "mistral-", + "mixtral-", + "baichuan-", "ernie-", "step-", "seed-", diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go index b08c42a7ea..1350dcd3bf 100644 --- a/backend/internal/service/openai_oauth_model_support_test.go +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -106,4 +106,5 @@ func TestIsOpenAIOAuthServableModel(t *testing.T) { require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许 require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感 require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking")) + require.True(t, isOpenAIOAuthServableModel("deepseekcoder")) // 无连字符 → 非黑名单前缀,保持允许 } From 13e773ef5e7908b0af0f2938295775b38a26eaaa Mon Sep 17 00:00:00 2001 From: liminge Date: Tue, 7 Jul 2026 19:49:29 +0800 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20Codex=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=A8=A1=E5=9E=8B=E6=B8=85=E5=8D=95=EF=BC=88manifest?= =?UTF-8?q?=EF=BC=89=E9=80=8F=E4=BC=A0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:Codex CLI / Codex App 会从 provider 的 GET {base_url}/models?client_version=... (自定义 provider 模式)或 GET /backend-api/codex/models(chatgpt_base_url 模式) 刷新模型选单,期望 ChatGPT Codex manifest 格式({"models":[{slug,...}]})。 sub2api 此前只提供 OpenAI 兼容格式的 /v1/models,Codex 客户端解析失败后静默 回落到本地缓存,导致指向 sub2api 的 Codex 客户端模型选单永久冻结在切换 provider 当天的状态,新模型(如 gpt-5.6 系列)永远不会出现。 方案:新增 manifest 透传——用组内可调度 OAuth 账号的凭据向 chatgpt.com/backend-api/codex/models 实时转发请求,响应体与 ETag 原样透传。 不在网关侧解析或维护模型清单:manifest schema 随 Codex 客户端版本演进, 透传保证网关无需跟进 schema 变化,且返回的始终是账号真实的模型权限 (区别于静态 DefaultModels 的"理论列表")。 路由: - GET /backend-api/codex/models(新增) - GET /v1/models 带 client_version 查询参数且组平台为 openai 时分发到 manifest 透传(client_version 是 Codex 客户端的天然指纹,普通 OpenAI 客户端不携带);其余请求保持原有 OpenAI 格式行为不变。 上游失败时按 fast-fail 返回错误,不伪造列表;Codex 客户端自身会回落缓存。 Co-Authored-By: Claude Fable 5 --- .../handler/openai_codex_models_handler.go | 53 +++++++ backend/internal/server/routes/gateway.go | 12 +- .../routes/gateway_codex_models_test.go | 22 +++ .../service/openai_codex_models_service.go | 107 ++++++++++++++ .../openai_codex_models_service_test.go | 138 ++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/openai_codex_models_handler.go create mode 100644 backend/internal/server/routes/gateway_codex_models_test.go create mode 100644 backend/internal/service/openai_codex_models_service.go create mode 100644 backend/internal/service/openai_codex_models_service_test.go diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go new file mode 100644 index 0000000000..e64c555d14 --- /dev/null +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -0,0 +1,53 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// CodexModels serves the Codex models manifest for Codex clients. +// +// Codex CLI and the Codex desktop app refresh their model picker from +// GET {base_url}/models?client_version=... (custom provider mode) or +// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land +// here. The manifest is proxied verbatim from the ChatGPT backend with a +// schedulable OAuth account's credentials, so clients pointed at the gateway +// see the account's real, always-current model entitlements instead of a +// frozen local cache. +func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + if apiKey.Group.Platform != service.PlatformOpenAI { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups") + return + } + + account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, "", "") + if err != nil { + h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI accounts") + return + } + + manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match")) + if err != nil { + h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) + return + } + + if manifest.ETag != "" { + c.Header("ETag", manifest.ETag) + } + if manifest.NotModified { + c.Status(http.StatusNotModified) + return + } + c.Data(http.StatusOK, "application/json", manifest.Body) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index d22e339c75..ba5b4f61d1 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -121,7 +121,16 @@ func RegisterGatewayRoutes( } h.Gateway.CountTokens(c) }) - gateway.GET("/models", h.Gateway.Models) + // Codex CLI / Codex app refresh their model picker from the provider's + // /models endpoint with a client_version query and expect the ChatGPT + // Codex manifest format; other clients keep the OpenAI-style list. + gateway.GET("/models", func(c *gin.Context) { + if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" { + h.OpenAIGateway.CodexModels(c) + return + } + h.Gateway.Models(c) + }) gateway.GET("/usage", h.Gateway.Usage) // OpenAI Responses API: auto-route based on group platform gateway.POST("/responses", func(c *gin.Context) { @@ -214,6 +223,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) + codexDirect.GET("/models", h.OpenAIGateway.CodexModels) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go new file mode 100644 index 0000000000..74af755919 --- /dev/null +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -0,0 +1,22 @@ +package routes + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter() + + registered := make(map[string]bool) + for _, route := range router.Routes() { + if route.Method == http.MethodGet { + registered[route.Path] = true + } + } + + require.True(t, registered["/backend-api/codex/models"], "GET /backend-api/codex/models should be registered") + require.True(t, registered["/v1/models"], "GET /v1/models should be registered") +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go new file mode 100644 index 0000000000..8a919fa2b0 --- /dev/null +++ b/backend/internal/service/openai_codex_models_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +// chatgptCodexModelsURL is the ChatGPT Codex models manifest endpoint. +// Package-level variable so tests can point it at a stub server. +var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" + +const codexModelsManifestBodyLimit int64 = 8 << 20 + +// CodexModelsManifest carries the raw upstream manifest payload plus caching +// metadata so handlers can pass both through to the client untouched. +type CodexModelsManifest struct { + Body []byte + ETag string + NotModified bool +} + +// FetchCodexModelsManifest fetches the live Codex models manifest from the +// ChatGPT backend using the account's OAuth credentials. +// +// The response body is passed through verbatim: the manifest schema evolves +// with Codex client releases, and interpreting it here would force the gateway +// to chase upstream changes. Passing it through keeps the gateway +// schema-agnostic and always reflects the account's real entitlements. +func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { + if account == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") + } + credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err) + } + accessToken := credAccount.GetOpenAIAccessToken() + if accessToken == "" { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token") + } + + clientVersion = strings.TrimSpace(clientVersion) + if clientVersion == "" { + clientVersion = openAICodexProbeVersion + } + requestURL := chatgptCodexModelsURL + "?client_version=" + url.QueryEscape(clientVersion) + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Originator", "codex_cli_rs") + req.Header.Set("Version", clientVersion) + req.Header.Set("User-Agent", codexCLIUserAgent) + if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + setOpenAIChatGPTAccountHeaders(req.Header, credAccount) + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: 15 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + }) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotModified { + return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = resp.Status + } + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err) + } + return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil +} diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go new file mode 100644 index 0000000000..c9eae35629 --- /dev/null +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func newCodexModelsTestAccount() *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "test-access-token", + "chatgpt_account_id": "acc-123", + }, + } +} + +func TestFetchCodexModelsManifestPassthrough(t *testing.T) { + manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}` + + var gotAuth, gotAccountID, gotOriginator, gotClientVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAccountID = r.Header.Get("chatgpt-account-id") + gotOriginator = r.Header.Get("Originator") + gotClientVersion = r.URL.Query().Get("client_version") + w.Header().Set("ETag", `W/"abc123"`) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(manifestBody)) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "") + if err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + + if string(manifest.Body) != manifestBody { + t.Errorf("body not passed through verbatim: got %q", manifest.Body) + } + if manifest.ETag != `W/"abc123"` { + t.Errorf("etag not passed through: got %q", manifest.ETag) + } + if gotAuth != "Bearer test-access-token" { + t.Errorf("authorization header: got %q", gotAuth) + } + if gotAccountID != "acc-123" { + t.Errorf("chatgpt-account-id header: got %q", gotAccountID) + } + if gotOriginator != "codex_cli_rs" { + t.Errorf("originator header: got %q", gotOriginator) + } + if gotClientVersion != "0.137.0" { + t.Errorf("client_version query: got %q", gotClientVersion) + } +} + +func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { + var gotClientVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClientVersion = r.URL.Query().Get("client_version") + _, _ = w.Write([]byte(`{"models":[]}`)) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + if gotClientVersion != openAICodexProbeVersion { + t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion) + } +} + +func TestFetchCodexModelsManifestNotModified(t *testing.T) { + var gotIfNoneMatch string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotIfNoneMatch = r.Header.Get("If-None-Match") + w.Header().Set("ETag", `W/"abc123"`) + w.WriteHeader(http.StatusNotModified) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`) + if err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + if !manifest.NotModified { + t.Error("expected NotModified to be true") + } + if gotIfNoneMatch != `W/"abc123"` { + t.Errorf("if-none-match header: got %q", gotIfNoneMatch) + } +} + +func TestFetchCodexModelsManifestUpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil { + t.Fatal("expected error for upstream 500, got nil") + } +} + +func TestFetchCodexModelsManifestMissingToken(t *testing.T) { + account := newCodexModelsTestAccount() + delete(account.Credentials, "access_token") + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil { + t.Fatal("expected error for missing access token, got nil") + } +} From 3866da508ff1c28e2f4743145d8d1d7e739bc3b6 Mon Sep 17 00:00:00 2001 From: zhengshan Date: Tue, 7 Jul 2026 23:17:13 +0800 Subject: [PATCH 07/29] =?UTF-8?q?fix(ratelimit):=20Anthropic=20=E6=97=A0?= =?UTF-8?q?=20reset=20=E5=A4=B4=E7=9A=84=20429=20=E4=B9=9F=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E5=85=9C=E5=BA=95=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 Anthropic 429 若响应头无窗口重置时间(如 Extra usage required), 被视为"非真实限流"直接跳过标记。后果:账号永不冷却,调度器让每个 请求反复撞同一批持续 429 的账号——failover 预算烧尽后客户端稳定 收到 429(生产实测:一次请求 1.3s 内连换 4 账号全 429,而 DB 中 这些账号的限流状态毫无更新)。 改为与其他平台一致走 apply429FallbackRateLimit 秒级兜底回避: - 默认 5s,管理端 RateLimit429CooldownSettings 可调 1~7200s - 管理端关闭该设置即恢复旧行为(不标记) - 日志 reason 标记为 anthropic_no_reset_time 便于运营区分 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/rate_limit_429_cooldown_test.go | 39 +++++++++++++++++++ backend/internal/service/ratelimit_service.go | 7 +++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/rate_limit_429_cooldown_test.go b/backend/internal/service/rate_limit_429_cooldown_test.go index fb7e0dd7af..96fc199096 100644 --- a/backend/internal/service/rate_limit_429_cooldown_test.go +++ b/backend/internal/service/rate_limit_429_cooldown_test.go @@ -97,6 +97,45 @@ func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) { require.Zero(t, accountRepo.rateLimitCalls) } +// Anthropic 无 reset 头的 429(如 Extra usage required)也应走兜底冷却, +// 否则账号永不冷却,调度器会让每个请求反复撞同一批 429 账号(旋转木马)。 +func TestHandle429_AnthropicNoResetTimeUsesFallbackCooldown(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 45, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"Extra usage required"}}`)) + after := time.Now() + + require.Equal(t, 1, accountRepo.rateLimitCalls) + require.Equal(t, int64(45), accountRepo.lastRateLimitID) + require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second))) +} + +// 管理端关闭兜底冷却时,Anthropic 无 reset 头的 429 保持旧行为:不标记账号。 +func TestHandle429_AnthropicNoResetTimeFallbackDisabledSkipsMark(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 46, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"Extra usage required"}}`)) + + require.Zero(t, accountRepo.rateLimitCalls) +} + func TestHandle429_FallbackUsesDefaultSecondsWhenSettingServiceMissing(t *testing.T) { accountRepo := &rateLimit429AccountRepoStub{} cfg := &config.Config{} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 50d38e7d13..100b240785 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -994,12 +994,15 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head } // Anthropic 平台:没有限流重置时间的 429 可能是非真实限流(如 Extra usage required), - // 不标记账号限流状态,直接透传错误给客户端 + // 不适合按 5h/7d 窗口长时间封禁;但完全不标记会导致账号永不冷却, + // 调度器让每个请求反复撞同一批持续 429 的账号(failover 预算被白白烧掉, + // 客户端稳定收到 429)。因此同样走可配置的秒级兜底回避,管理端可调大或关闭。 if account.Platform == PlatformAnthropic { - slog.Warn("rate_limit_429_no_reset_time_skipped", + slog.Warn("rate_limit_429_no_reset_time", "account_id", account.ID, "platform", account.Platform, "reason", "no rate limit reset time in headers, likely not a real rate limit") + s.apply429FallbackRateLimit(ctx, account, "anthropic_no_reset_time") return } From 5aba53d542e82bf86439c13d1340b8d17666e57d Mon Sep 17 00:00:00 2001 From: Eyre921 <98458308+Eyre921@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:51:51 +0000 Subject: [PATCH 08/29] =?UTF-8?q?fix(ops):=20=E8=AE=B0=E5=BD=95=E5=9B=BA?= =?UTF-8?q?=E5=8C=96=20200=20SSE=20=E6=B5=81=E4=B8=8A=E7=9A=84=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E9=94=99=E8=AF=AF=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E5=86=85=E9=99=90=E6=B5=81=E4=B8=8D=E8=BF=9B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式请求一旦 flush 了 keepalive ping,HTTP 状态码即固化为 200;此后 出现的错误(等待并发槽位超时后回退的限流、Wait 后二次计费校验失败、 流开始后才无可用账号等)只能就地以 SSE error 帧回传。而 ops_error_logger 以 status>=400 为采集触发条件,这类挂在 200 流上的失败此前会在错误看板里 完全隐形——客户端能收到 rate_limit_error,但网关侧没有任何错误记录可供排障。 - service: 新增 OpsStreamError 上下文 + MarkOpsStreamError/GetOpsStreamError, 采用「首个标记生效」保留根因错误,避免被后续通用兜底帧覆盖。 - handler: handleStreamingAwareError 在 streamStarted 分支标记流内错误。 - handler: OpsErrorLoggerMiddleware 在 status<400 且无上游错误上下文时, 据标记补记一条错误日志;分级用 IntendedStatus(如并发限流 429), StatusCode 仍记 wire 的 200。上游透传错误已由 upstream-context 分支落库, 故此路径不重复记录。 - 补充单测覆盖补记、no-op、skip_monitoring 跳过与首个标记生效。 --- backend/internal/handler/gateway_handler.go | 5 + backend/internal/handler/ops_error_logger.go | 136 ++++++++++++++++++ .../internal/handler/ops_error_logger_test.go | 91 ++++++++++++ .../internal/service/ops_upstream_context.go | 52 +++++++ 4 files changed, 284 insertions(+) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 0caa7f718b..d67c24eb98 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1629,6 +1629,11 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) // handleStreamingAwareError handles errors that may occur after streaming has started func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { if streamStarted { + // 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。 + // 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集, + // 这类挂在 200 流上的失败(如并发限流回退)不会进错误看板。 + service.MarkOpsStreamError(c, errType, message, status) + // /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于 // response.completed/failed/incomplete/cancelled 集合。 // Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。 diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 8cd9d02ba0..5a1e57ff7d 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -590,6 +590,10 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } if !hasUpstreamContext { + // 没有上游错误上下文,但网关可能在已固化的 200 流上就地补发了 SSE 错误帧 + // (如 ping 等待后并发超限、Wait 后二次计费校验失败)。这类失败若不在此补记, + // 会因 wire 状态码为 200 而在错误看板里彻底隐形。 + logOpsStreamError(c, ops, status) return } @@ -999,6 +1003,138 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } +// logOpsStreamError 记录一次挂在已固化 HTTP 200 SSE 流上的就地错误。 +// 由于 wire 状态码停留在 200,常规的 status>=400 捕获路径永远不会触发; +// handleStreamingAwareError 通过 service.MarkOpsStreamError 标记这类错误, +// 此函数据此补记一条错误日志,让并发限流/流内失败在错误看板里可见。 +// +// 仅在 status<400 且不存在上游错误上下文时调用:上游透传错误已由中间件的 +// upstream-context 分支落库,无需在此重复记录。 +func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) { + streamErr, ok := service.GetOpsStreamError(c) + if !ok { + return + } + + // 命中 skip_monitoring=true 透传规则的请求跳过落库,与其它分支一致。 + if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { + if skip, _ := v.(bool); skip { + return + } + } + + // 复用与 status>=400 分支相同的设置过滤(context canceled / 无可用账号等)。 + if shouldSkipOpsErrorLog(c.Request.Context(), ops, streamErr.Message, streamErr.Message, c.Request.URL.Path) { + return + } + + // 分级用「本应返回的状态码」(如并发限流 429),wire 状态码缺省时回退。 + classifyStatus := streamErr.IntendedStatus + if classifyStatus <= 0 { + classifyStatus = wireStatus + } + normalizedType := normalizeOpsErrorType(streamErr.ErrType, "") + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, streamErr.Message, "", classifyStatus) + + apiKey := getOpsAPIKey(c) + clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string) + + model, _ := c.Get(opsModelKey) + var modelName string + if s, ok := model.(string); ok { + modelName = s + } + accountIDV, _ := c.Get(opsAccountIDKey) + var accountID *int64 + if v, ok := accountIDV.(int64); ok && v > 0 { + accountID = &v + } + + fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path) + platform := resolveOpsPlatform(apiKey, fallbackPlatform) + + requestID := c.Writer.Header().Get("X-Request-Id") + if requestID == "" { + requestID = c.Writer.Header().Get("x-request-id") + } + + entry := &service.OpsInsertErrorLogInput{ + RequestID: requestID, + ClientRequestID: clientRequestID, + + AccountID: accountID, + Platform: platform, + Model: modelName, + RequestPath: func() string { + if c.Request != nil && c.Request.URL != nil { + return c.Request.URL.Path + } + return "" + }(), + // 就地 SSE 错误只出现在流式请求上。 + Stream: true, + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, platform), + RequestedModel: modelName, + UpstreamModel: func() string { + if v, ok := c.Get(opsUpstreamModelKey); ok { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + } + return "" + }(), + RequestType: func() *int16 { + if v, ok := c.Get(opsRequestTypeKey); ok { + switch t := v.(type) { + case int16: + return &t + case int: + v16 := int16(t) + return &v16 + } + } + return nil + }(), + UserAgent: c.GetHeader("User-Agent"), + + ErrorPhase: phase, + ErrorType: normalizedType, + Severity: classifyOpsSeverity(normalizedType, classifyStatus), + StatusCode: wireStatus, + IsBusinessLimited: isBusinessLimited, + IsCountTokens: isCountTokensRequest(c), + + ErrorMessage: streamErr.Message, + ErrorBody: "", + ErrorSource: errorSource, + ErrorOwner: errorOwner, + + CreatedAt: time.Now(), + } + applyOpsLatencyFieldsFromContext(c, entry) + + if apiKey != nil { + entry.APIKeyID = &apiKey.ID + entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8) + if apiKey.User != nil { + entry.UserID = &apiKey.User.ID + } + if apiKey.GroupID != nil { + entry.GroupID = apiKey.GroupID + } + if apiKey.Group != nil && apiKey.Group.Platform != "" { + entry.Platform = apiKey.Group.Platform + } + } + + if clientIP := strings.TrimSpace(ip.GetClientIP(c)); clientIP != "" { + entry.ClientIP = &clientIP + } + + enqueueOpsErrorLog(ops, entry) +} + // isCountTokensRequest checks if the request is a count_tokens request func isCountTokensRequest(c *gin.Context) bool { if c == nil || c.Request == nil || c.Request.URL == nil { diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index cf1685f2a4..89bdd938c1 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -139,6 +139,97 @@ func TestOpsErrorLoggerMiddleware_DoesNotBreakOuterMiddlewares(t *testing.T) { require.Equal(t, http.StatusNoContent, rec.Code) } +// setupOpsErrorLogTestQueue 阻止 enqueueOpsErrorLog 启动真实 worker,改用可检查的测试队列。 +func setupOpsErrorLogTestQueue(t *testing.T, size int) { + t.Helper() + resetOpsErrorLoggerStateForTest(t) + opsErrorLogOnce.Do(func() {}) + opsErrorLogMu.Lock() + opsErrorLogQueue = make(chan opsErrorLogJob, size) + opsErrorLogMu.Unlock() +} + +// 就地(in-band) SSE 错误挂在已固化的 HTTP 200 流上:wire 状态码为 200, +// 常规 status>=400 采集路径不会触发。logOpsStreamError 必须据 MarkOpsStreamError +// 补记一条错误日志,且用 IntendedStatus(429) 分级、StatusCode 仍记 wire 的 200。 +func TestLogOpsStreamError_RecordsInBandConcurrencyLimit(t *testing.T) { + setupOpsErrorLogTestQueue(t, 4) + + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Set(opsModelKey, "test-model") + + service.MarkOpsStreamError(c, "rate_limit_error", + "Concurrency limit exceeded for account, please retry later", http.StatusTooManyRequests) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + logOpsStreamError(c, ops, http.StatusOK) + + require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal()) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + + job := <-opsErrorLogQueue + require.NotNil(t, job.entry) + require.Equal(t, "rate_limit_error", job.entry.ErrorType) + require.Equal(t, "request", job.entry.ErrorPhase) + require.True(t, job.entry.IsBusinessLimited) + require.True(t, job.entry.Stream) + require.Equal(t, http.StatusOK, job.entry.StatusCode) // wire 状态码保持 200 + require.Equal(t, "P1", job.entry.Severity) // 用 IntendedStatus 429 分级 + require.Equal(t, "test-model", job.entry.Model) + require.Equal(t, "Concurrency limit exceeded for account, please retry later", job.entry.ErrorMessage) +} + +// 未标记流内错误时 logOpsStreamError 必须是 no-op(不误记正常的 200 流)。 +func TestLogOpsStreamError_NoopWhenNotMarked(t *testing.T) { + setupOpsErrorLogTestQueue(t, 4) + + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + logOpsStreamError(c, ops, http.StatusOK) + + require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal()) +} + +// 命中 skip_monitoring=true 透传规则时不落库,与其它采集分支一致。 +func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) { + setupOpsErrorLogTestQueue(t, 4) + + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway) + c.Set(service.OpsSkipPassthroughKey, true) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + logOpsStreamError(c, ops, http.StatusOK) + + require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal()) +} + +// MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。 +func TestMarkOpsStreamError_FirstWins(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + service.MarkOpsStreamError(c, "rate_limit_error", "Concurrency limit exceeded for account", http.StatusTooManyRequests) + service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway) + + se, ok := service.GetOpsStreamError(c) + require.True(t, ok) + require.Equal(t, "rate_limit_error", se.ErrType) + require.Equal(t, "Concurrency limit exceeded for account", se.Message) + require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus) +} + func TestIsKnownOpsErrorType(t *testing.T) { known := []string{ "invalid_request_error", diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 4a1e36aa6f..6389f1ecb5 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -32,6 +32,12 @@ const ( // ops_error_logger 中间件检查此 key,为 true 时跳过错误记录。 OpsSkipPassthroughKey = "ops_skip_passthrough" + // OpsStreamErrorKey 保存 handleStreamingAwareError 在「响应已固化为 HTTP 200 的 SSE 流」 + // 上就地(in-band)补发错误帧时记录的 OpsStreamError。因为 wire 状态码停留在 200, + // ops_error_logger 的 status>=400 采集路径永远不会触发,这类流内失败 + //(例如等待并发槽位超时后回退的限流、Wait 后二次计费校验失败)本会在错误看板里隐形。 + OpsStreamErrorKey = "ops_stream_error" + // Client-side configuration denials should remain visible in ops_error_logs, // but should be excluded from SLA/error-rate calculations. // ResponseCommittedKey 由 handleErrorResponse 系列函数在写完 HTTP 错误响应后设置。 @@ -87,6 +93,52 @@ func HasOpsClientBusinessLimited(c *gin.Context) bool { return marked } +// OpsStreamError 描述网关在「响应状态已固化为 200」之后(keepalive ping 或部分数据 +// 已 flush)就地以 SSE error 帧形式返回的错误。由于 HTTP 状态码停留在 200, +// 而 ops_error_logger 以 status>=400 为采集触发条件,这类流内失败 +// (并发限流回退、Wait 后二次计费校验失败、流开始后才无可用账号等)本会在错误看板里 +// 完全隐形。handler.handleStreamingAwareError 负责标记,ops_error_logger 中间件在 +// status<400 分支消费它并补记一条错误日志。 +type OpsStreamError struct { + // ErrType 是写入 SSE 帧的对客错误类型(如 rate_limit_error / upstream_error / api_error)。 + ErrType string + // Message 是写入 SSE 帧的对客错误消息。 + Message string + // IntendedStatus 是流若未固化本应返回的 HTTP 状态码(如并发限流的 429)。 + // 仅用于错误分级(severity/classification);实际 wire 状态码仍为 200。 + IntendedStatus int +} + +// MarkOpsStreamError 记录一次就地 SSE 错误,供 ops 日志采集。 +// 采用「首个标记生效」策略:同一请求若先后补发多帧(如上游透传错误后又追加通用兜底帧), +// 保留最先记录的根因错误,而不是被后续的 "Upstream request failed" 覆盖。 +func MarkOpsStreamError(c *gin.Context, errType, message string, intendedStatus int) { + if c == nil { + return + } + if _, exists := c.Get(OpsStreamErrorKey); exists { + return + } + c.Set(OpsStreamErrorKey, OpsStreamError{ + ErrType: strings.TrimSpace(errType), + Message: strings.TrimSpace(message), + IntendedStatus: intendedStatus, + }) +} + +// GetOpsStreamError 返回本请求记录的就地 SSE 错误(若有)。 +func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) { + if c == nil { + return OpsStreamError{}, false + } + v, ok := c.Get(OpsStreamErrorKey) + if !ok { + return OpsStreamError{}, false + } + se, ok := v.(OpsStreamError) + return se, ok +} + // SetOpsUpstreamError is the exported wrapper for setOpsUpstreamError, used by // handler-layer code (e.g. failover-exhausted paths) that needs to record the // original upstream status code before mapping it to a client-facing code. From 890cc2bb980486af7409dc13136979151382baf8 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Tue, 7 Jul 2026 12:00:36 +0800 Subject: [PATCH 09/29] fix: clarify Grok media pricing controls --- .../src/i18n/locales/en/admin/overview.ts | 12 ++++++ .../src/i18n/locales/zh/admin/overview.ts | 11 ++++++ frontend/src/views/admin/GroupsView.vue | 37 ++++++++++--------- .../__tests__/groupsImagePricing.spec.ts | 10 +++++ .../src/views/admin/groupsImagePricing.ts | 5 +++ 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 57e7f21137..7f69b3fa16 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -841,6 +841,18 @@ export default { finalPricePreview: 'Final per-image price preview', notConfigured: 'Not configured' }, + mediaPricing: { + title: 'Image / Video Generation Pricing', + description: + 'Configure Grok image and video generation access plus base media prices. Leave empty to use default prices.', + allowImageGeneration: 'Allow image and video generation for this group', + independentMultiplier: 'Use independent media multiplier', + imageMultiplier: 'Media multiplier', + modeHint: + 'By default, Grok media billing uses media price × current effective group multiplier. Independent mode uses media price × media multiplier. One video generation is billed as one media unit.', + finalPricePreview: 'Final per-media-unit price preview', + notConfigured: 'Not configured' + }, peakRate: { enable: 'Enable peak rate multiplier', peakStart: 'Peak start', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index c15cc52990..cebf7e760e 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -919,6 +919,17 @@ export default { finalPricePreview: '最终单张价格预览', notConfigured: '未配置' }, + mediaPricing: { + title: '图片/视频生成计费', + description: '配置 Grok 图片和视频生成能力及媒体基础单价,留空则使用默认价格', + allowImageGeneration: '允许当前分组生图和视频生成', + independentMultiplier: '媒体倍率独立', + imageMultiplier: '媒体独立倍率', + modeHint: + '默认关闭独立倍率时,Grok 媒体费用 = 媒体价格 × 当前分组有效倍率;开启独立倍率后,Grok 媒体费用 = 媒体价格 × 媒体独立倍率。一次视频生成按 1 个媒体单位计费。', + finalPricePreview: '最终单次媒体价格预览', + notConfigured: '未配置' + }, peakRate: { enable: '启用高峰倍率', peakStart: '高峰开始', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 0700f127b8..b5c8334785 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -787,7 +787,7 @@ - +
- {{ t("admin.groups.imagePricing.title") }} + {{ t(imagePricingI18nKey(createForm.platform, "title")) }}

- {{ t("admin.groups.imagePricing.description") }} + {{ t(imagePricingI18nKey(createForm.platform, "description")) }}

- {{ t("admin.groups.imagePricing.modeHint") }} + {{ t(imagePricingI18nKey(createForm.platform, "modeHint")) }}

- {{ t("admin.groups.imagePricing.finalPricePreview") }} + {{ t(imagePricingI18nKey(createForm.platform, "finalPricePreview")) }}
- +
- {{ t("admin.groups.imagePricing.title") }} + {{ t(imagePricingI18nKey(editForm.platform, "title")) }}

- {{ t("admin.groups.imagePricing.description") }} + {{ t(imagePricingI18nKey(editForm.platform, "description")) }}

- {{ t("admin.groups.imagePricing.modeHint") }} + {{ t(imagePricingI18nKey(editForm.platform, "modeHint")) }}

- {{ t("admin.groups.imagePricing.finalPricePreview") }} + {{ t(imagePricingI18nKey(editForm.platform, "finalPricePreview")) }}
{ it("keeps non-media group platforms out of the image pricing controls", () => { expect(supportsImagePricingPlatform("anthropic")).toBe(false); }); + + it("uses media pricing copy for Grok groups only", () => { + expect(imagePricingI18nKey("grok", "title")).toBe( + "admin.groups.mediaPricing.title", + ); + expect(imagePricingI18nKey("openai", "title")).toBe( + "admin.groups.imagePricing.title", + ); + }); }); diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts index 1a2c5170ce..b899fb3637 100644 --- a/frontend/src/views/admin/groupsImagePricing.ts +++ b/frontend/src/views/admin/groupsImagePricing.ts @@ -7,3 +7,8 @@ export const imagePricingPlatforms = new Set([ export const supportsImagePricingPlatform = (platform: string): boolean => imagePricingPlatforms.has(platform); + +export const imagePricingI18nKey = (platform: string, key: string): string => + platform === "grok" + ? `admin.groups.mediaPricing.${key}` + : `admin.groups.imagePricing.${key}`; From 4d702e3234db0184b73d7fc2cb20d9e58efc0f98 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Tue, 7 Jul 2026 12:00:36 +0800 Subject: [PATCH 10/29] fix: split Grok image and video pricing --- backend/ent/group.go | 68 ++- backend/ent/group/group.go | 44 ++ backend/ent/group/where.go | 225 +++++++++ backend/ent/group_create.go | 444 +++++++++++++++++ backend/ent/group_update.go | 304 ++++++++++++ backend/ent/migrate/schema.go | 7 +- backend/ent/mutation.go | 464 +++++++++++++++++- backend/ent/runtime/runtime.go | 32 +- backend/ent/schema/group.go | 19 + .../internal/handler/admin/group_handler.go | 20 + backend/internal/handler/dto/mappers.go | 5 + backend/internal/handler/dto/types.go | 5 + backend/internal/handler/usage_handler.go | 2 +- .../usage_handler_request_type_test.go | 12 + backend/internal/repository/api_key_repo.go | 10 + backend/internal/repository/group_repo.go | 25 + .../migrations_schema_integration_test.go | 1 + backend/internal/repository/usage_log_repo.go | 4 +- .../usage_log_repo_request_type_test.go | 11 +- backend/internal/server/api_contract_test.go | 5 + backend/internal/service/admin_group.go | 33 ++ backend/internal/service/admin_service.go | 10 + .../service/admin_service_group_test.go | 91 ++++ .../internal/service/api_key_auth_cache.go | 5 + .../service/api_key_auth_cache_impl.go | 12 +- backend/internal/service/billing_service.go | 63 +++ .../internal/service/billing_service_test.go | 14 + backend/internal/service/channel.go | 10 + .../internal/service/gateway_usage_billing.go | 12 +- backend/internal/service/grok_media.go | 15 +- backend/internal/service/group.go | 20 + .../service/image_billing_multiplier.go | 10 + .../internal/service/media_price_config.go | 31 ++ .../service/openai_gateway_grok_test.go | 12 +- .../openai_gateway_record_usage_test.go | 260 +++++++++- .../service/openai_gateway_service.go | 2 + .../internal/service/openai_gateway_usage.go | 121 ++++- .../service/video_billing_resolution.go | 22 + .../170_add_grok_video_pricing_controls.sql | 16 + ...1_allow_video_usage_without_image_size.sql | 17 + .../components/admin/usage/UsageFilters.vue | 3 +- .../src/i18n/locales/en/admin/channels.ts | 1 + .../src/i18n/locales/en/admin/overview.ts | 10 + .../src/i18n/locales/en/admin/resources.ts | 1 + frontend/src/i18n/locales/en/dashboard.ts | 1 + .../src/i18n/locales/zh/admin/channels.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 10 + .../src/i18n/locales/zh/admin/resources.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 1 + frontend/src/types/index.ts | 15 + frontend/src/utils/billingMode.ts | 5 +- frontend/src/views/admin/GroupsView.vue | 270 +++++++++- .../__tests__/groupsImagePricing.spec.ts | 15 +- .../src/views/admin/groupsImagePricing.ts | 12 +- frontend/src/views/user/UsageView.vue | 1 + 55 files changed, 2758 insertions(+), 72 deletions(-) create mode 100644 backend/internal/service/media_price_config.go create mode 100644 backend/internal/service/video_billing_resolution.go create mode 100644 backend/migrations/170_add_grok_video_pricing_controls.sql create mode 100644 backend/migrations/171_allow_video_usage_without_image_size.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 2a0eb4d3ac..5bec594977 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -73,6 +73,16 @@ type Group struct { BatchImageDiscountMultiplier float64 `json:"batch_image_discount_multiplier,omitempty"` // 批量图片生成冻结价格比例,按普通生图原价乘以该比例冻结,结算后释放差额 BatchImageHoldMultiplier float64 `json:"batch_image_hold_multiplier,omitempty"` + // 视频生成是否使用独立倍率;false 表示共享分组有效倍率 + VideoRateIndependent bool `json:"video_rate_independent,omitempty"` + // 视频生成独立倍率,仅 video_rate_independent=true 时生效 + VideoRateMultiplier float64 `json:"video_rate_multiplier,omitempty"` + // VideoPrice480p holds the value of the "video_price_480p" field. + VideoPrice480p *float64 `json:"video_price_480p,omitempty"` + // VideoPrice720p holds the value of the "video_price_720p" field. + VideoPrice720p *float64 `json:"video_price_720p,omitempty"` + // VideoPrice1080p holds the value of the "video_price_1080p" field. + VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID @@ -211,9 +221,9 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier: + case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -412,6 +422,39 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.BatchImageHoldMultiplier = value.Float64 } + case group.FieldVideoRateIndependent: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field video_rate_independent", values[i]) + } else if value.Valid { + _m.VideoRateIndependent = value.Bool + } + case group.FieldVideoRateMultiplier: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field video_rate_multiplier", values[i]) + } else if value.Valid { + _m.VideoRateMultiplier = value.Float64 + } + case group.FieldVideoPrice480p: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field video_price_480p", values[i]) + } else if value.Valid { + _m.VideoPrice480p = new(float64) + *_m.VideoPrice480p = value.Float64 + } + case group.FieldVideoPrice720p: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field video_price_720p", values[i]) + } else if value.Valid { + _m.VideoPrice720p = new(float64) + *_m.VideoPrice720p = value.Float64 + } + case group.FieldVideoPrice1080p: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field video_price_1080p", values[i]) + } else if value.Valid { + _m.VideoPrice1080p = new(float64) + *_m.VideoPrice1080p = value.Float64 + } case group.FieldClaudeCodeOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field claude_code_only", values[i]) @@ -685,6 +728,27 @@ func (_m *Group) String() string { builder.WriteString("batch_image_hold_multiplier=") builder.WriteString(fmt.Sprintf("%v", _m.BatchImageHoldMultiplier)) builder.WriteString(", ") + builder.WriteString("video_rate_independent=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoRateIndependent)) + builder.WriteString(", ") + builder.WriteString("video_rate_multiplier=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoRateMultiplier)) + builder.WriteString(", ") + if v := _m.VideoPrice480p; v != nil { + builder.WriteString("video_price_480p=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.VideoPrice720p; v != nil { + builder.WriteString("video_price_720p=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.VideoPrice1080p; v != nil { + builder.WriteString("video_price_1080p=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("claude_code_only=") builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 540ce8f9f5..769c63e6b1 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -70,6 +70,16 @@ const ( FieldBatchImageDiscountMultiplier = "batch_image_discount_multiplier" // FieldBatchImageHoldMultiplier holds the string denoting the batch_image_hold_multiplier field in the database. FieldBatchImageHoldMultiplier = "batch_image_hold_multiplier" + // FieldVideoRateIndependent holds the string denoting the video_rate_independent field in the database. + FieldVideoRateIndependent = "video_rate_independent" + // FieldVideoRateMultiplier holds the string denoting the video_rate_multiplier field in the database. + FieldVideoRateMultiplier = "video_rate_multiplier" + // FieldVideoPrice480p holds the string denoting the video_price_480p field in the database. + FieldVideoPrice480p = "video_price_480p" + // FieldVideoPrice720p holds the string denoting the video_price_720p field in the database. + FieldVideoPrice720p = "video_price_720p" + // FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database. + FieldVideoPrice1080p = "video_price_1080p" // FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database. FieldClaudeCodeOnly = "claude_code_only" // FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database. @@ -202,6 +212,11 @@ var Columns = []string{ FieldImagePrice4k, FieldBatchImageDiscountMultiplier, FieldBatchImageHoldMultiplier, + FieldVideoRateIndependent, + FieldVideoRateMultiplier, + FieldVideoPrice480p, + FieldVideoPrice720p, + FieldVideoPrice1080p, FieldClaudeCodeOnly, FieldFallbackGroupID, FieldFallbackGroupIDOnInvalidRequest, @@ -296,6 +311,10 @@ var ( DefaultBatchImageDiscountMultiplier float64 // DefaultBatchImageHoldMultiplier holds the default value on creation for the "batch_image_hold_multiplier" field. DefaultBatchImageHoldMultiplier float64 + // DefaultVideoRateIndependent holds the default value on creation for the "video_rate_independent" field. + DefaultVideoRateIndependent bool + // DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field. + DefaultVideoRateMultiplier float64 // DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field. DefaultClaudeCodeOnly bool // DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field. @@ -467,6 +486,31 @@ func ByBatchImageHoldMultiplier(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldBatchImageHoldMultiplier, opts...).ToFunc() } +// ByVideoRateIndependent orders the results by the video_rate_independent field. +func ByVideoRateIndependent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoRateIndependent, opts...).ToFunc() +} + +// ByVideoRateMultiplier orders the results by the video_rate_multiplier field. +func ByVideoRateMultiplier(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoRateMultiplier, opts...).ToFunc() +} + +// ByVideoPrice480p orders the results by the video_price_480p field. +func ByVideoPrice480p(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoPrice480p, opts...).ToFunc() +} + +// ByVideoPrice720p orders the results by the video_price_720p field. +func ByVideoPrice720p(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoPrice720p, opts...).ToFunc() +} + +// ByVideoPrice1080p orders the results by the video_price_1080p field. +func ByVideoPrice1080p(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoPrice1080p, opts...).ToFunc() +} + // ByClaudeCodeOnly orders the results by the claude_code_only field. func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index a76d3a8783..5a9d92d0f4 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -190,6 +190,31 @@ func BatchImageHoldMultiplier(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldBatchImageHoldMultiplier, v)) } +// VideoRateIndependent applies equality check predicate on the "video_rate_independent" field. It's identical to VideoRateIndependentEQ. +func VideoRateIndependent(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoRateIndependent, v)) +} + +// VideoRateMultiplier applies equality check predicate on the "video_rate_multiplier" field. It's identical to VideoRateMultiplierEQ. +func VideoRateMultiplier(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoRateMultiplier, v)) +} + +// VideoPrice480p applies equality check predicate on the "video_price_480p" field. It's identical to VideoPrice480pEQ. +func VideoPrice480p(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice480p, v)) +} + +// VideoPrice720p applies equality check predicate on the "video_price_720p" field. It's identical to VideoPrice720pEQ. +func VideoPrice720p(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice720p, v)) +} + +// VideoPrice1080p applies equality check predicate on the "video_price_1080p" field. It's identical to VideoPrice1080pEQ. +func VideoPrice1080p(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v)) +} + // ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ. func ClaudeCodeOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) @@ -1430,6 +1455,206 @@ func BatchImageHoldMultiplierLTE(v float64) predicate.Group { return predicate.Group(sql.FieldLTE(FieldBatchImageHoldMultiplier, v)) } +// VideoRateIndependentEQ applies the EQ predicate on the "video_rate_independent" field. +func VideoRateIndependentEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoRateIndependent, v)) +} + +// VideoRateIndependentNEQ applies the NEQ predicate on the "video_rate_independent" field. +func VideoRateIndependentNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldVideoRateIndependent, v)) +} + +// VideoRateMultiplierEQ applies the EQ predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoRateMultiplier, v)) +} + +// VideoRateMultiplierNEQ applies the NEQ predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldVideoRateMultiplier, v)) +} + +// VideoRateMultiplierIn applies the In predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldVideoRateMultiplier, vs...)) +} + +// VideoRateMultiplierNotIn applies the NotIn predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldVideoRateMultiplier, vs...)) +} + +// VideoRateMultiplierGT applies the GT predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldVideoRateMultiplier, v)) +} + +// VideoRateMultiplierGTE applies the GTE predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldVideoRateMultiplier, v)) +} + +// VideoRateMultiplierLT applies the LT predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldVideoRateMultiplier, v)) +} + +// VideoRateMultiplierLTE applies the LTE predicate on the "video_rate_multiplier" field. +func VideoRateMultiplierLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldVideoRateMultiplier, v)) +} + +// VideoPrice480pEQ applies the EQ predicate on the "video_price_480p" field. +func VideoPrice480pEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice480p, v)) +} + +// VideoPrice480pNEQ applies the NEQ predicate on the "video_price_480p" field. +func VideoPrice480pNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldVideoPrice480p, v)) +} + +// VideoPrice480pIn applies the In predicate on the "video_price_480p" field. +func VideoPrice480pIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldVideoPrice480p, vs...)) +} + +// VideoPrice480pNotIn applies the NotIn predicate on the "video_price_480p" field. +func VideoPrice480pNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldVideoPrice480p, vs...)) +} + +// VideoPrice480pGT applies the GT predicate on the "video_price_480p" field. +func VideoPrice480pGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldVideoPrice480p, v)) +} + +// VideoPrice480pGTE applies the GTE predicate on the "video_price_480p" field. +func VideoPrice480pGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldVideoPrice480p, v)) +} + +// VideoPrice480pLT applies the LT predicate on the "video_price_480p" field. +func VideoPrice480pLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldVideoPrice480p, v)) +} + +// VideoPrice480pLTE applies the LTE predicate on the "video_price_480p" field. +func VideoPrice480pLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldVideoPrice480p, v)) +} + +// VideoPrice480pIsNil applies the IsNil predicate on the "video_price_480p" field. +func VideoPrice480pIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldVideoPrice480p)) +} + +// VideoPrice480pNotNil applies the NotNil predicate on the "video_price_480p" field. +func VideoPrice480pNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldVideoPrice480p)) +} + +// VideoPrice720pEQ applies the EQ predicate on the "video_price_720p" field. +func VideoPrice720pEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice720p, v)) +} + +// VideoPrice720pNEQ applies the NEQ predicate on the "video_price_720p" field. +func VideoPrice720pNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldVideoPrice720p, v)) +} + +// VideoPrice720pIn applies the In predicate on the "video_price_720p" field. +func VideoPrice720pIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldVideoPrice720p, vs...)) +} + +// VideoPrice720pNotIn applies the NotIn predicate on the "video_price_720p" field. +func VideoPrice720pNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldVideoPrice720p, vs...)) +} + +// VideoPrice720pGT applies the GT predicate on the "video_price_720p" field. +func VideoPrice720pGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldVideoPrice720p, v)) +} + +// VideoPrice720pGTE applies the GTE predicate on the "video_price_720p" field. +func VideoPrice720pGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldVideoPrice720p, v)) +} + +// VideoPrice720pLT applies the LT predicate on the "video_price_720p" field. +func VideoPrice720pLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldVideoPrice720p, v)) +} + +// VideoPrice720pLTE applies the LTE predicate on the "video_price_720p" field. +func VideoPrice720pLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldVideoPrice720p, v)) +} + +// VideoPrice720pIsNil applies the IsNil predicate on the "video_price_720p" field. +func VideoPrice720pIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldVideoPrice720p)) +} + +// VideoPrice720pNotNil applies the NotNil predicate on the "video_price_720p" field. +func VideoPrice720pNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldVideoPrice720p)) +} + +// VideoPrice1080pEQ applies the EQ predicate on the "video_price_1080p" field. +func VideoPrice1080pEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pNEQ applies the NEQ predicate on the "video_price_1080p" field. +func VideoPrice1080pNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pIn applies the In predicate on the "video_price_1080p" field. +func VideoPrice1080pIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldVideoPrice1080p, vs...)) +} + +// VideoPrice1080pNotIn applies the NotIn predicate on the "video_price_1080p" field. +func VideoPrice1080pNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldVideoPrice1080p, vs...)) +} + +// VideoPrice1080pGT applies the GT predicate on the "video_price_1080p" field. +func VideoPrice1080pGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pGTE applies the GTE predicate on the "video_price_1080p" field. +func VideoPrice1080pGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pLT applies the LT predicate on the "video_price_1080p" field. +func VideoPrice1080pLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pLTE applies the LTE predicate on the "video_price_1080p" field. +func VideoPrice1080pLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldVideoPrice1080p, v)) +} + +// VideoPrice1080pIsNil applies the IsNil predicate on the "video_price_1080p" field. +func VideoPrice1080pIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldVideoPrice1080p)) +} + +// VideoPrice1080pNotNil applies the NotNil predicate on the "video_price_1080p" field. +func VideoPrice1080pNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p)) +} + // ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field. func ClaudeCodeOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 9c635847d0..2a6c18e67d 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -399,6 +399,76 @@ func (_c *GroupCreate) SetNillableBatchImageHoldMultiplier(v *float64) *GroupCre return _c } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (_c *GroupCreate) SetVideoRateIndependent(v bool) *GroupCreate { + _c.mutation.SetVideoRateIndependent(v) + return _c +} + +// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil. +func (_c *GroupCreate) SetNillableVideoRateIndependent(v *bool) *GroupCreate { + if v != nil { + _c.SetVideoRateIndependent(*v) + } + return _c +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (_c *GroupCreate) SetVideoRateMultiplier(v float64) *GroupCreate { + _c.mutation.SetVideoRateMultiplier(v) + return _c +} + +// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil. +func (_c *GroupCreate) SetNillableVideoRateMultiplier(v *float64) *GroupCreate { + if v != nil { + _c.SetVideoRateMultiplier(*v) + } + return _c +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (_c *GroupCreate) SetVideoPrice480p(v float64) *GroupCreate { + _c.mutation.SetVideoPrice480p(v) + return _c +} + +// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil. +func (_c *GroupCreate) SetNillableVideoPrice480p(v *float64) *GroupCreate { + if v != nil { + _c.SetVideoPrice480p(*v) + } + return _c +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (_c *GroupCreate) SetVideoPrice720p(v float64) *GroupCreate { + _c.mutation.SetVideoPrice720p(v) + return _c +} + +// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil. +func (_c *GroupCreate) SetNillableVideoPrice720p(v *float64) *GroupCreate { + if v != nil { + _c.SetVideoPrice720p(*v) + } + return _c +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (_c *GroupCreate) SetVideoPrice1080p(v float64) *GroupCreate { + _c.mutation.SetVideoPrice1080p(v) + return _c +} + +// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil. +func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate { + if v != nil { + _c.SetVideoPrice1080p(*v) + } + return _c +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate { _c.mutation.SetClaudeCodeOnly(v) @@ -798,6 +868,14 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultBatchImageHoldMultiplier _c.mutation.SetBatchImageHoldMultiplier(v) } + if _, ok := _c.mutation.VideoRateIndependent(); !ok { + v := group.DefaultVideoRateIndependent + _c.mutation.SetVideoRateIndependent(v) + } + if _, ok := _c.mutation.VideoRateMultiplier(); !ok { + v := group.DefaultVideoRateMultiplier + _c.mutation.SetVideoRateMultiplier(v) + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { v := group.DefaultClaudeCodeOnly _c.mutation.SetClaudeCodeOnly(v) @@ -938,6 +1016,12 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.BatchImageHoldMultiplier(); !ok { return &ValidationError{Name: "batch_image_hold_multiplier", err: errors.New(`ent: missing required field "Group.batch_image_hold_multiplier"`)} } + if _, ok := _c.mutation.VideoRateIndependent(); !ok { + return &ValidationError{Name: "video_rate_independent", err: errors.New(`ent: missing required field "Group.video_rate_independent"`)} + } + if _, ok := _c.mutation.VideoRateMultiplier(); !ok { + return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)} + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)} } @@ -1114,6 +1198,26 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value) _node.BatchImageHoldMultiplier = value } + if value, ok := _c.mutation.VideoRateIndependent(); ok { + _spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value) + _node.VideoRateIndependent = value + } + if value, ok := _c.mutation.VideoRateMultiplier(); ok { + _spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value) + _node.VideoRateMultiplier = value + } + if value, ok := _c.mutation.VideoPrice480p(); ok { + _spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value) + _node.VideoPrice480p = &value + } + if value, ok := _c.mutation.VideoPrice720p(); ok { + _spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value) + _node.VideoPrice720p = &value + } + if value, ok := _c.mutation.VideoPrice1080p(); ok { + _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) + _node.VideoPrice1080p = &value + } if value, ok := _c.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) _node.ClaudeCodeOnly = value @@ -1762,6 +1866,108 @@ func (u *GroupUpsert) AddBatchImageHoldMultiplier(v float64) *GroupUpsert { return u } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (u *GroupUpsert) SetVideoRateIndependent(v bool) *GroupUpsert { + u.Set(group.FieldVideoRateIndependent, v) + return u +} + +// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoRateIndependent() *GroupUpsert { + u.SetExcluded(group.FieldVideoRateIndependent) + return u +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (u *GroupUpsert) SetVideoRateMultiplier(v float64) *GroupUpsert { + u.Set(group.FieldVideoRateMultiplier, v) + return u +} + +// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoRateMultiplier() *GroupUpsert { + u.SetExcluded(group.FieldVideoRateMultiplier) + return u +} + +// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field. +func (u *GroupUpsert) AddVideoRateMultiplier(v float64) *GroupUpsert { + u.Add(group.FieldVideoRateMultiplier, v) + return u +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (u *GroupUpsert) SetVideoPrice480p(v float64) *GroupUpsert { + u.Set(group.FieldVideoPrice480p, v) + return u +} + +// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoPrice480p() *GroupUpsert { + u.SetExcluded(group.FieldVideoPrice480p) + return u +} + +// AddVideoPrice480p adds v to the "video_price_480p" field. +func (u *GroupUpsert) AddVideoPrice480p(v float64) *GroupUpsert { + u.Add(group.FieldVideoPrice480p, v) + return u +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (u *GroupUpsert) ClearVideoPrice480p() *GroupUpsert { + u.SetNull(group.FieldVideoPrice480p) + return u +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (u *GroupUpsert) SetVideoPrice720p(v float64) *GroupUpsert { + u.Set(group.FieldVideoPrice720p, v) + return u +} + +// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoPrice720p() *GroupUpsert { + u.SetExcluded(group.FieldVideoPrice720p) + return u +} + +// AddVideoPrice720p adds v to the "video_price_720p" field. +func (u *GroupUpsert) AddVideoPrice720p(v float64) *GroupUpsert { + u.Add(group.FieldVideoPrice720p, v) + return u +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (u *GroupUpsert) ClearVideoPrice720p() *GroupUpsert { + u.SetNull(group.FieldVideoPrice720p) + return u +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (u *GroupUpsert) SetVideoPrice1080p(v float64) *GroupUpsert { + u.Set(group.FieldVideoPrice1080p, v) + return u +} + +// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoPrice1080p() *GroupUpsert { + u.SetExcluded(group.FieldVideoPrice1080p) + return u +} + +// AddVideoPrice1080p adds v to the "video_price_1080p" field. +func (u *GroupUpsert) AddVideoPrice1080p(v float64) *GroupUpsert { + u.Add(group.FieldVideoPrice1080p, v) + return u +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert { + u.SetNull(group.FieldVideoPrice1080p) + return u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert { u.Set(group.FieldClaudeCodeOnly, v) @@ -2533,6 +2739,125 @@ func (u *GroupUpsertOne) UpdateBatchImageHoldMultiplier() *GroupUpsertOne { }) } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (u *GroupUpsertOne) SetVideoRateIndependent(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoRateIndependent(v) + }) +} + +// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoRateIndependent() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoRateIndependent() + }) +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (u *GroupUpsertOne) SetVideoRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoRateMultiplier(v) + }) +} + +// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field. +func (u *GroupUpsertOne) AddVideoRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddVideoRateMultiplier(v) + }) +} + +// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoRateMultiplier() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoRateMultiplier() + }) +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (u *GroupUpsertOne) SetVideoPrice480p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice480p(v) + }) +} + +// AddVideoPrice480p adds v to the "video_price_480p" field. +func (u *GroupUpsertOne) AddVideoPrice480p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice480p(v) + }) +} + +// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoPrice480p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice480p() + }) +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (u *GroupUpsertOne) ClearVideoPrice480p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice480p() + }) +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (u *GroupUpsertOne) SetVideoPrice720p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice720p(v) + }) +} + +// AddVideoPrice720p adds v to the "video_price_720p" field. +func (u *GroupUpsertOne) AddVideoPrice720p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice720p(v) + }) +} + +// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoPrice720p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice720p() + }) +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (u *GroupUpsertOne) ClearVideoPrice720p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice720p() + }) +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (u *GroupUpsertOne) SetVideoPrice1080p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice1080p(v) + }) +} + +// AddVideoPrice1080p adds v to the "video_price_1080p" field. +func (u *GroupUpsertOne) AddVideoPrice1080p(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice1080p(v) + }) +} + +// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoPrice1080p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice1080p() + }) +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice1080p() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -3507,6 +3832,125 @@ func (u *GroupUpsertBulk) UpdateBatchImageHoldMultiplier() *GroupUpsertBulk { }) } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (u *GroupUpsertBulk) SetVideoRateIndependent(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoRateIndependent(v) + }) +} + +// UpdateVideoRateIndependent sets the "video_rate_independent" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoRateIndependent() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoRateIndependent() + }) +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (u *GroupUpsertBulk) SetVideoRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoRateMultiplier(v) + }) +} + +// AddVideoRateMultiplier adds v to the "video_rate_multiplier" field. +func (u *GroupUpsertBulk) AddVideoRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddVideoRateMultiplier(v) + }) +} + +// UpdateVideoRateMultiplier sets the "video_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoRateMultiplier() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoRateMultiplier() + }) +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (u *GroupUpsertBulk) SetVideoPrice480p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice480p(v) + }) +} + +// AddVideoPrice480p adds v to the "video_price_480p" field. +func (u *GroupUpsertBulk) AddVideoPrice480p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice480p(v) + }) +} + +// UpdateVideoPrice480p sets the "video_price_480p" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoPrice480p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice480p() + }) +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (u *GroupUpsertBulk) ClearVideoPrice480p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice480p() + }) +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (u *GroupUpsertBulk) SetVideoPrice720p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice720p(v) + }) +} + +// AddVideoPrice720p adds v to the "video_price_720p" field. +func (u *GroupUpsertBulk) AddVideoPrice720p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice720p(v) + }) +} + +// UpdateVideoPrice720p sets the "video_price_720p" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoPrice720p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice720p() + }) +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (u *GroupUpsertBulk) ClearVideoPrice720p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice720p() + }) +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (u *GroupUpsertBulk) SetVideoPrice1080p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoPrice1080p(v) + }) +} + +// AddVideoPrice1080p adds v to the "video_price_1080p" field. +func (u *GroupUpsertBulk) AddVideoPrice1080p(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddVideoPrice1080p(v) + }) +} + +// UpdateVideoPrice1080p sets the "video_price_1080p" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoPrice1080p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoPrice1080p() + }) +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoPrice1080p() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 6f1831b1ea..3bb18d3e1a 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -524,6 +524,122 @@ func (_u *GroupUpdate) AddBatchImageHoldMultiplier(v float64) *GroupUpdate { return _u } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (_u *GroupUpdate) SetVideoRateIndependent(v bool) *GroupUpdate { + _u.mutation.SetVideoRateIndependent(v) + return _u +} + +// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableVideoRateIndependent(v *bool) *GroupUpdate { + if v != nil { + _u.SetVideoRateIndependent(*v) + } + return _u +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (_u *GroupUpdate) SetVideoRateMultiplier(v float64) *GroupUpdate { + _u.mutation.ResetVideoRateMultiplier() + _u.mutation.SetVideoRateMultiplier(v) + return _u +} + +// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableVideoRateMultiplier(v *float64) *GroupUpdate { + if v != nil { + _u.SetVideoRateMultiplier(*v) + } + return _u +} + +// AddVideoRateMultiplier adds value to the "video_rate_multiplier" field. +func (_u *GroupUpdate) AddVideoRateMultiplier(v float64) *GroupUpdate { + _u.mutation.AddVideoRateMultiplier(v) + return _u +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (_u *GroupUpdate) SetVideoPrice480p(v float64) *GroupUpdate { + _u.mutation.ResetVideoPrice480p() + _u.mutation.SetVideoPrice480p(v) + return _u +} + +// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableVideoPrice480p(v *float64) *GroupUpdate { + if v != nil { + _u.SetVideoPrice480p(*v) + } + return _u +} + +// AddVideoPrice480p adds value to the "video_price_480p" field. +func (_u *GroupUpdate) AddVideoPrice480p(v float64) *GroupUpdate { + _u.mutation.AddVideoPrice480p(v) + return _u +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (_u *GroupUpdate) ClearVideoPrice480p() *GroupUpdate { + _u.mutation.ClearVideoPrice480p() + return _u +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (_u *GroupUpdate) SetVideoPrice720p(v float64) *GroupUpdate { + _u.mutation.ResetVideoPrice720p() + _u.mutation.SetVideoPrice720p(v) + return _u +} + +// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableVideoPrice720p(v *float64) *GroupUpdate { + if v != nil { + _u.SetVideoPrice720p(*v) + } + return _u +} + +// AddVideoPrice720p adds value to the "video_price_720p" field. +func (_u *GroupUpdate) AddVideoPrice720p(v float64) *GroupUpdate { + _u.mutation.AddVideoPrice720p(v) + return _u +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (_u *GroupUpdate) ClearVideoPrice720p() *GroupUpdate { + _u.mutation.ClearVideoPrice720p() + return _u +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (_u *GroupUpdate) SetVideoPrice1080p(v float64) *GroupUpdate { + _u.mutation.ResetVideoPrice1080p() + _u.mutation.SetVideoPrice1080p(v) + return _u +} + +// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableVideoPrice1080p(v *float64) *GroupUpdate { + if v != nil { + _u.SetVideoPrice1080p(*v) + } + return _u +} + +// AddVideoPrice1080p adds value to the "video_price_1080p" field. +func (_u *GroupUpdate) AddVideoPrice1080p(v float64) *GroupUpdate { + _u.mutation.AddVideoPrice1080p(v) + return _u +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate { + _u.mutation.ClearVideoPrice1080p() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate { _u.mutation.SetClaudeCodeOnly(v) @@ -1223,6 +1339,42 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedBatchImageHoldMultiplier(); ok { _spec.AddField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value) } + if value, ok := _u.mutation.VideoRateIndependent(); ok { + _spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value) + } + if value, ok := _u.mutation.VideoRateMultiplier(); ok { + _spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoRateMultiplier(); ok { + _spec.AddField(group.FieldVideoRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.VideoPrice480p(); ok { + _spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice480p(); ok { + _spec.AddField(group.FieldVideoPrice480p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice480pCleared() { + _spec.ClearField(group.FieldVideoPrice480p, field.TypeFloat64) + } + if value, ok := _u.mutation.VideoPrice720p(); ok { + _spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice720p(); ok { + _spec.AddField(group.FieldVideoPrice720p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice720pCleared() { + _spec.ClearField(group.FieldVideoPrice720p, field.TypeFloat64) + } + if value, ok := _u.mutation.VideoPrice1080p(); ok { + _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice1080p(); ok { + _spec.AddField(group.FieldVideoPrice1080p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice1080pCleared() { + _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } @@ -2096,6 +2248,122 @@ func (_u *GroupUpdateOne) AddBatchImageHoldMultiplier(v float64) *GroupUpdateOne return _u } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (_u *GroupUpdateOne) SetVideoRateIndependent(v bool) *GroupUpdateOne { + _u.mutation.SetVideoRateIndependent(v) + return _u +} + +// SetNillableVideoRateIndependent sets the "video_rate_independent" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableVideoRateIndependent(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetVideoRateIndependent(*v) + } + return _u +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (_u *GroupUpdateOne) SetVideoRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.ResetVideoRateMultiplier() + _u.mutation.SetVideoRateMultiplier(v) + return _u +} + +// SetNillableVideoRateMultiplier sets the "video_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableVideoRateMultiplier(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetVideoRateMultiplier(*v) + } + return _u +} + +// AddVideoRateMultiplier adds value to the "video_rate_multiplier" field. +func (_u *GroupUpdateOne) AddVideoRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.AddVideoRateMultiplier(v) + return _u +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (_u *GroupUpdateOne) SetVideoPrice480p(v float64) *GroupUpdateOne { + _u.mutation.ResetVideoPrice480p() + _u.mutation.SetVideoPrice480p(v) + return _u +} + +// SetNillableVideoPrice480p sets the "video_price_480p" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableVideoPrice480p(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetVideoPrice480p(*v) + } + return _u +} + +// AddVideoPrice480p adds value to the "video_price_480p" field. +func (_u *GroupUpdateOne) AddVideoPrice480p(v float64) *GroupUpdateOne { + _u.mutation.AddVideoPrice480p(v) + return _u +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (_u *GroupUpdateOne) ClearVideoPrice480p() *GroupUpdateOne { + _u.mutation.ClearVideoPrice480p() + return _u +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (_u *GroupUpdateOne) SetVideoPrice720p(v float64) *GroupUpdateOne { + _u.mutation.ResetVideoPrice720p() + _u.mutation.SetVideoPrice720p(v) + return _u +} + +// SetNillableVideoPrice720p sets the "video_price_720p" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableVideoPrice720p(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetVideoPrice720p(*v) + } + return _u +} + +// AddVideoPrice720p adds value to the "video_price_720p" field. +func (_u *GroupUpdateOne) AddVideoPrice720p(v float64) *GroupUpdateOne { + _u.mutation.AddVideoPrice720p(v) + return _u +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (_u *GroupUpdateOne) ClearVideoPrice720p() *GroupUpdateOne { + _u.mutation.ClearVideoPrice720p() + return _u +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (_u *GroupUpdateOne) SetVideoPrice1080p(v float64) *GroupUpdateOne { + _u.mutation.ResetVideoPrice1080p() + _u.mutation.SetVideoPrice1080p(v) + return _u +} + +// SetNillableVideoPrice1080p sets the "video_price_1080p" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableVideoPrice1080p(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetVideoPrice1080p(*v) + } + return _u +} + +// AddVideoPrice1080p adds value to the "video_price_1080p" field. +func (_u *GroupUpdateOne) AddVideoPrice1080p(v float64) *GroupUpdateOne { + _u.mutation.AddVideoPrice1080p(v) + return _u +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne { + _u.mutation.ClearVideoPrice1080p() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne { _u.mutation.SetClaudeCodeOnly(v) @@ -2825,6 +3093,42 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedBatchImageHoldMultiplier(); ok { _spec.AddField(group.FieldBatchImageHoldMultiplier, field.TypeFloat64, value) } + if value, ok := _u.mutation.VideoRateIndependent(); ok { + _spec.SetField(group.FieldVideoRateIndependent, field.TypeBool, value) + } + if value, ok := _u.mutation.VideoRateMultiplier(); ok { + _spec.SetField(group.FieldVideoRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoRateMultiplier(); ok { + _spec.AddField(group.FieldVideoRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.VideoPrice480p(); ok { + _spec.SetField(group.FieldVideoPrice480p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice480p(); ok { + _spec.AddField(group.FieldVideoPrice480p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice480pCleared() { + _spec.ClearField(group.FieldVideoPrice480p, field.TypeFloat64) + } + if value, ok := _u.mutation.VideoPrice720p(); ok { + _spec.SetField(group.FieldVideoPrice720p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice720p(); ok { + _spec.AddField(group.FieldVideoPrice720p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice720pCleared() { + _spec.ClearField(group.FieldVideoPrice720p, field.TypeFloat64) + } + if value, ok := _u.mutation.VideoPrice1080p(); ok { + _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedVideoPrice1080p(); ok { + _spec.AddField(group.FieldVideoPrice1080p, field.TypeFloat64, value) + } + if _u.mutation.VideoPrice1080pCleared() { + _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index a584cbe39d..edae57d212 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -860,6 +860,11 @@ var ( {Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "batch_image_discount_multiplier", Type: field.TypeFloat64, Default: 0.5, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "batch_image_hold_multiplier", Type: field.TypeFloat64, Default: 0.6, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, + {Name: "video_rate_independent", Type: field.TypeBool, Default: false}, + {Name: "video_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, + {Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, {Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true}, @@ -910,7 +915,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[35]}, + Columns: []*schema.Column{GroupsColumns[40]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 987ec4146b..07f8ce623e 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -20833,6 +20833,15 @@ type GroupMutation struct { addbatch_image_discount_multiplier *float64 batch_image_hold_multiplier *float64 addbatch_image_hold_multiplier *float64 + video_rate_independent *bool + video_rate_multiplier *float64 + addvideo_rate_multiplier *float64 + video_price_480p *float64 + addvideo_price_480p *float64 + video_price_720p *float64 + addvideo_price_720p *float64 + video_price_1080p *float64 + addvideo_price_1080p *float64 claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -22297,6 +22306,308 @@ func (m *GroupMutation) ResetBatchImageHoldMultiplier() { m.addbatch_image_hold_multiplier = nil } +// SetVideoRateIndependent sets the "video_rate_independent" field. +func (m *GroupMutation) SetVideoRateIndependent(b bool) { + m.video_rate_independent = &b +} + +// VideoRateIndependent returns the value of the "video_rate_independent" field in the mutation. +func (m *GroupMutation) VideoRateIndependent() (r bool, exists bool) { + v := m.video_rate_independent + if v == nil { + return + } + return *v, true +} + +// OldVideoRateIndependent returns the old "video_rate_independent" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoRateIndependent(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoRateIndependent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoRateIndependent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoRateIndependent: %w", err) + } + return oldValue.VideoRateIndependent, nil +} + +// ResetVideoRateIndependent resets all changes to the "video_rate_independent" field. +func (m *GroupMutation) ResetVideoRateIndependent() { + m.video_rate_independent = nil +} + +// SetVideoRateMultiplier sets the "video_rate_multiplier" field. +func (m *GroupMutation) SetVideoRateMultiplier(f float64) { + m.video_rate_multiplier = &f + m.addvideo_rate_multiplier = nil +} + +// VideoRateMultiplier returns the value of the "video_rate_multiplier" field in the mutation. +func (m *GroupMutation) VideoRateMultiplier() (r float64, exists bool) { + v := m.video_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// OldVideoRateMultiplier returns the old "video_rate_multiplier" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoRateMultiplier(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoRateMultiplier is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoRateMultiplier requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoRateMultiplier: %w", err) + } + return oldValue.VideoRateMultiplier, nil +} + +// AddVideoRateMultiplier adds f to the "video_rate_multiplier" field. +func (m *GroupMutation) AddVideoRateMultiplier(f float64) { + if m.addvideo_rate_multiplier != nil { + *m.addvideo_rate_multiplier += f + } else { + m.addvideo_rate_multiplier = &f + } +} + +// AddedVideoRateMultiplier returns the value that was added to the "video_rate_multiplier" field in this mutation. +func (m *GroupMutation) AddedVideoRateMultiplier() (r float64, exists bool) { + v := m.addvideo_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// ResetVideoRateMultiplier resets all changes to the "video_rate_multiplier" field. +func (m *GroupMutation) ResetVideoRateMultiplier() { + m.video_rate_multiplier = nil + m.addvideo_rate_multiplier = nil +} + +// SetVideoPrice480p sets the "video_price_480p" field. +func (m *GroupMutation) SetVideoPrice480p(f float64) { + m.video_price_480p = &f + m.addvideo_price_480p = nil +} + +// VideoPrice480p returns the value of the "video_price_480p" field in the mutation. +func (m *GroupMutation) VideoPrice480p() (r float64, exists bool) { + v := m.video_price_480p + if v == nil { + return + } + return *v, true +} + +// OldVideoPrice480p returns the old "video_price_480p" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoPrice480p(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoPrice480p is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoPrice480p requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoPrice480p: %w", err) + } + return oldValue.VideoPrice480p, nil +} + +// AddVideoPrice480p adds f to the "video_price_480p" field. +func (m *GroupMutation) AddVideoPrice480p(f float64) { + if m.addvideo_price_480p != nil { + *m.addvideo_price_480p += f + } else { + m.addvideo_price_480p = &f + } +} + +// AddedVideoPrice480p returns the value that was added to the "video_price_480p" field in this mutation. +func (m *GroupMutation) AddedVideoPrice480p() (r float64, exists bool) { + v := m.addvideo_price_480p + if v == nil { + return + } + return *v, true +} + +// ClearVideoPrice480p clears the value of the "video_price_480p" field. +func (m *GroupMutation) ClearVideoPrice480p() { + m.video_price_480p = nil + m.addvideo_price_480p = nil + m.clearedFields[group.FieldVideoPrice480p] = struct{}{} +} + +// VideoPrice480pCleared returns if the "video_price_480p" field was cleared in this mutation. +func (m *GroupMutation) VideoPrice480pCleared() bool { + _, ok := m.clearedFields[group.FieldVideoPrice480p] + return ok +} + +// ResetVideoPrice480p resets all changes to the "video_price_480p" field. +func (m *GroupMutation) ResetVideoPrice480p() { + m.video_price_480p = nil + m.addvideo_price_480p = nil + delete(m.clearedFields, group.FieldVideoPrice480p) +} + +// SetVideoPrice720p sets the "video_price_720p" field. +func (m *GroupMutation) SetVideoPrice720p(f float64) { + m.video_price_720p = &f + m.addvideo_price_720p = nil +} + +// VideoPrice720p returns the value of the "video_price_720p" field in the mutation. +func (m *GroupMutation) VideoPrice720p() (r float64, exists bool) { + v := m.video_price_720p + if v == nil { + return + } + return *v, true +} + +// OldVideoPrice720p returns the old "video_price_720p" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoPrice720p(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoPrice720p is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoPrice720p requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoPrice720p: %w", err) + } + return oldValue.VideoPrice720p, nil +} + +// AddVideoPrice720p adds f to the "video_price_720p" field. +func (m *GroupMutation) AddVideoPrice720p(f float64) { + if m.addvideo_price_720p != nil { + *m.addvideo_price_720p += f + } else { + m.addvideo_price_720p = &f + } +} + +// AddedVideoPrice720p returns the value that was added to the "video_price_720p" field in this mutation. +func (m *GroupMutation) AddedVideoPrice720p() (r float64, exists bool) { + v := m.addvideo_price_720p + if v == nil { + return + } + return *v, true +} + +// ClearVideoPrice720p clears the value of the "video_price_720p" field. +func (m *GroupMutation) ClearVideoPrice720p() { + m.video_price_720p = nil + m.addvideo_price_720p = nil + m.clearedFields[group.FieldVideoPrice720p] = struct{}{} +} + +// VideoPrice720pCleared returns if the "video_price_720p" field was cleared in this mutation. +func (m *GroupMutation) VideoPrice720pCleared() bool { + _, ok := m.clearedFields[group.FieldVideoPrice720p] + return ok +} + +// ResetVideoPrice720p resets all changes to the "video_price_720p" field. +func (m *GroupMutation) ResetVideoPrice720p() { + m.video_price_720p = nil + m.addvideo_price_720p = nil + delete(m.clearedFields, group.FieldVideoPrice720p) +} + +// SetVideoPrice1080p sets the "video_price_1080p" field. +func (m *GroupMutation) SetVideoPrice1080p(f float64) { + m.video_price_1080p = &f + m.addvideo_price_1080p = nil +} + +// VideoPrice1080p returns the value of the "video_price_1080p" field in the mutation. +func (m *GroupMutation) VideoPrice1080p() (r float64, exists bool) { + v := m.video_price_1080p + if v == nil { + return + } + return *v, true +} + +// OldVideoPrice1080p returns the old "video_price_1080p" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoPrice1080p(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoPrice1080p is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoPrice1080p requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoPrice1080p: %w", err) + } + return oldValue.VideoPrice1080p, nil +} + +// AddVideoPrice1080p adds f to the "video_price_1080p" field. +func (m *GroupMutation) AddVideoPrice1080p(f float64) { + if m.addvideo_price_1080p != nil { + *m.addvideo_price_1080p += f + } else { + m.addvideo_price_1080p = &f + } +} + +// AddedVideoPrice1080p returns the value that was added to the "video_price_1080p" field in this mutation. +func (m *GroupMutation) AddedVideoPrice1080p() (r float64, exists bool) { + v := m.addvideo_price_1080p + if v == nil { + return + } + return *v, true +} + +// ClearVideoPrice1080p clears the value of the "video_price_1080p" field. +func (m *GroupMutation) ClearVideoPrice1080p() { + m.video_price_1080p = nil + m.addvideo_price_1080p = nil + m.clearedFields[group.FieldVideoPrice1080p] = struct{}{} +} + +// VideoPrice1080pCleared returns if the "video_price_1080p" field was cleared in this mutation. +func (m *GroupMutation) VideoPrice1080pCleared() bool { + _, ok := m.clearedFields[group.FieldVideoPrice1080p] + return ok +} + +// ResetVideoPrice1080p resets all changes to the "video_price_1080p" field. +func (m *GroupMutation) ResetVideoPrice1080p() { + m.video_price_1080p = nil + m.addvideo_price_1080p = nil + delete(m.clearedFields, group.FieldVideoPrice1080p) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (m *GroupMutation) SetClaudeCodeOnly(b bool) { m.claude_code_only = &b @@ -23331,7 +23642,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 42) + fields := make([]string, 0, 47) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -23413,6 +23724,21 @@ func (m *GroupMutation) Fields() []string { if m.batch_image_hold_multiplier != nil { fields = append(fields, group.FieldBatchImageHoldMultiplier) } + if m.video_rate_independent != nil { + fields = append(fields, group.FieldVideoRateIndependent) + } + if m.video_rate_multiplier != nil { + fields = append(fields, group.FieldVideoRateMultiplier) + } + if m.video_price_480p != nil { + fields = append(fields, group.FieldVideoPrice480p) + } + if m.video_price_720p != nil { + fields = append(fields, group.FieldVideoPrice720p) + } + if m.video_price_1080p != nil { + fields = append(fields, group.FieldVideoPrice1080p) + } if m.claude_code_only != nil { fields = append(fields, group.FieldClaudeCodeOnly) } @@ -23520,6 +23846,16 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.BatchImageDiscountMultiplier() case group.FieldBatchImageHoldMultiplier: return m.BatchImageHoldMultiplier() + case group.FieldVideoRateIndependent: + return m.VideoRateIndependent() + case group.FieldVideoRateMultiplier: + return m.VideoRateMultiplier() + case group.FieldVideoPrice480p: + return m.VideoPrice480p() + case group.FieldVideoPrice720p: + return m.VideoPrice720p() + case group.FieldVideoPrice1080p: + return m.VideoPrice1080p() case group.FieldClaudeCodeOnly: return m.ClaudeCodeOnly() case group.FieldFallbackGroupID: @@ -23613,6 +23949,16 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldBatchImageDiscountMultiplier(ctx) case group.FieldBatchImageHoldMultiplier: return m.OldBatchImageHoldMultiplier(ctx) + case group.FieldVideoRateIndependent: + return m.OldVideoRateIndependent(ctx) + case group.FieldVideoRateMultiplier: + return m.OldVideoRateMultiplier(ctx) + case group.FieldVideoPrice480p: + return m.OldVideoPrice480p(ctx) + case group.FieldVideoPrice720p: + return m.OldVideoPrice720p(ctx) + case group.FieldVideoPrice1080p: + return m.OldVideoPrice1080p(ctx) case group.FieldClaudeCodeOnly: return m.OldClaudeCodeOnly(ctx) case group.FieldFallbackGroupID: @@ -23841,6 +24187,41 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetBatchImageHoldMultiplier(v) return nil + case group.FieldVideoRateIndependent: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoRateIndependent(v) + return nil + case group.FieldVideoRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoRateMultiplier(v) + return nil + case group.FieldVideoPrice480p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoPrice480p(v) + return nil + case group.FieldVideoPrice720p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoPrice720p(v) + return nil + case group.FieldVideoPrice1080p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoPrice1080p(v) + return nil case group.FieldClaudeCodeOnly: v, ok := value.(bool) if !ok { @@ -23990,6 +24371,18 @@ func (m *GroupMutation) AddedFields() []string { if m.addbatch_image_hold_multiplier != nil { fields = append(fields, group.FieldBatchImageHoldMultiplier) } + if m.addvideo_rate_multiplier != nil { + fields = append(fields, group.FieldVideoRateMultiplier) + } + if m.addvideo_price_480p != nil { + fields = append(fields, group.FieldVideoPrice480p) + } + if m.addvideo_price_720p != nil { + fields = append(fields, group.FieldVideoPrice720p) + } + if m.addvideo_price_1080p != nil { + fields = append(fields, group.FieldVideoPrice1080p) + } if m.addfallback_group_id != nil { fields = append(fields, group.FieldFallbackGroupID) } @@ -24034,6 +24427,14 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedBatchImageDiscountMultiplier() case group.FieldBatchImageHoldMultiplier: return m.AddedBatchImageHoldMultiplier() + case group.FieldVideoRateMultiplier: + return m.AddedVideoRateMultiplier() + case group.FieldVideoPrice480p: + return m.AddedVideoPrice480p() + case group.FieldVideoPrice720p: + return m.AddedVideoPrice720p() + case group.FieldVideoPrice1080p: + return m.AddedVideoPrice1080p() case group.FieldFallbackGroupID: return m.AddedFallbackGroupID() case group.FieldFallbackGroupIDOnInvalidRequest: @@ -24135,6 +24536,34 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddBatchImageHoldMultiplier(v) return nil + case group.FieldVideoRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoRateMultiplier(v) + return nil + case group.FieldVideoPrice480p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoPrice480p(v) + return nil + case group.FieldVideoPrice720p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoPrice720p(v) + return nil + case group.FieldVideoPrice1080p: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoPrice1080p(v) + return nil case group.FieldFallbackGroupID: v, ok := value.(int64) if !ok { @@ -24195,6 +24624,15 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldImagePrice4k) { fields = append(fields, group.FieldImagePrice4k) } + if m.FieldCleared(group.FieldVideoPrice480p) { + fields = append(fields, group.FieldVideoPrice480p) + } + if m.FieldCleared(group.FieldVideoPrice720p) { + fields = append(fields, group.FieldVideoPrice720p) + } + if m.FieldCleared(group.FieldVideoPrice1080p) { + fields = append(fields, group.FieldVideoPrice1080p) + } if m.FieldCleared(group.FieldFallbackGroupID) { fields = append(fields, group.FieldFallbackGroupID) } @@ -24242,6 +24680,15 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldImagePrice4k: m.ClearImagePrice4k() return nil + case group.FieldVideoPrice480p: + m.ClearVideoPrice480p() + return nil + case group.FieldVideoPrice720p: + m.ClearVideoPrice720p() + return nil + case group.FieldVideoPrice1080p: + m.ClearVideoPrice1080p() + return nil case group.FieldFallbackGroupID: m.ClearFallbackGroupID() return nil @@ -24340,6 +24787,21 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldBatchImageHoldMultiplier: m.ResetBatchImageHoldMultiplier() return nil + case group.FieldVideoRateIndependent: + m.ResetVideoRateIndependent() + return nil + case group.FieldVideoRateMultiplier: + m.ResetVideoRateMultiplier() + return nil + case group.FieldVideoPrice480p: + m.ResetVideoPrice480p() + return nil + case group.FieldVideoPrice720p: + m.ResetVideoPrice720p() + return nil + case group.FieldVideoPrice1080p: + m.ResetVideoPrice1080p() + return nil case group.FieldClaudeCodeOnly: m.ResetClaudeCodeOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 2c05c01c52..ddde08d23d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1035,54 +1035,62 @@ func init() { groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor() // group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field. group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64) + // groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field. + groupDescVideoRateIndependent := groupFields[24].Descriptor() + // group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field. + group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool) + // groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field. + groupDescVideoRateMultiplier := groupFields[25].Descriptor() + // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. + group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[24].Descriptor() + groupDescClaudeCodeOnly := groupFields[29].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[28].Descriptor() + groupDescModelRoutingEnabled := groupFields[33].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[29].Descriptor() + groupDescMcpXMLInject := groupFields[34].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[30].Descriptor() + groupDescSupportedModelScopes := groupFields[35].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[31].Descriptor() + groupDescSortOrder := groupFields[36].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[32].Descriptor() + groupDescAllowMessagesDispatch := groupFields[37].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[33].Descriptor() + groupDescRequireOauthOnly := groupFields[38].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[34].Descriptor() + groupDescRequirePrivacySet := groupFields[39].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[35].Descriptor() + groupDescDefaultMappedModel := groupFields[40].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[36].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[41].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[37].Descriptor() + groupDescModelsListConfig := groupFields[42].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[38].Descriptor() + groupDescRpmLimit := groupFields[43].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index d675ca52f1..b104609a1b 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -123,6 +123,25 @@ func (Group) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(0.6). Comment("批量图片生成冻结价格比例,按普通生图原价乘以该比例冻结,结算后释放差额"), + field.Bool("video_rate_independent"). + Default(false). + Comment("视频生成是否使用独立倍率;false 表示共享分组有效倍率"), + field.Float("video_rate_multiplier"). + SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). + Default(1.0). + Comment("视频生成独立倍率,仅 video_rate_independent=true 时生效"), + field.Float("video_price_480p"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.Float("video_price_720p"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.Float("video_price_1080p"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), // Claude Code 客户端限制 (added by migration 029) field.Bool("claude_code_only"). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 4595adeb24..56a0b29ed0 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -98,6 +98,8 @@ type CreateGroupRequest struct { ImageRateMultiplier *float64 `json:"image_rate_multiplier"` BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier *float64 `json:"video_rate_multiplier"` PeakRateEnabled bool `json:"peak_rate_enabled"` PeakStart string `json:"peak_start"` PeakEnd string `json:"peak_end"` @@ -105,6 +107,9 @@ type CreateGroupRequest struct { ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` + VideoPrice480P *float64 `json:"video_price_480p"` + VideoPrice720P *float64 `json:"video_price_720p"` + VideoPrice1080P *float64 `json:"video_price_1080p"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` @@ -146,6 +151,8 @@ type UpdateGroupRequest struct { ImageRateMultiplier *float64 `json:"image_rate_multiplier"` BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` + VideoRateIndependent *bool `json:"video_rate_independent"` + VideoRateMultiplier *float64 `json:"video_rate_multiplier"` PeakRateEnabled *bool `json:"peak_rate_enabled"` PeakStart *string `json:"peak_start"` PeakEnd *string `json:"peak_end"` @@ -153,6 +160,9 @@ type UpdateGroupRequest struct { ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` + VideoPrice480P *float64 `json:"video_price_480p"` + VideoPrice720P *float64 `json:"video_price_720p"` + VideoPrice1080P *float64 `json:"video_price_1080p"` ClaudeCodeOnly *bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` @@ -312,6 +322,8 @@ func (h *GroupHandler) Create(c *gin.Context) { ImageRateMultiplier: req.ImageRateMultiplier, BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: req.BatchImageHoldMultiplier, + VideoRateIndependent: req.VideoRateIndependent, + VideoRateMultiplier: req.VideoRateMultiplier, PeakRateEnabled: req.PeakRateEnabled, PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, @@ -319,6 +331,9 @@ func (h *GroupHandler) Create(c *gin.Context) { ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, + VideoPrice480P: req.VideoPrice480P, + VideoPrice720P: req.VideoPrice720P, + VideoPrice1080P: req.VideoPrice1080P, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, @@ -375,6 +390,8 @@ func (h *GroupHandler) Update(c *gin.Context) { ImageRateMultiplier: req.ImageRateMultiplier, BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: req.BatchImageHoldMultiplier, + VideoRateIndependent: req.VideoRateIndependent, + VideoRateMultiplier: req.VideoRateMultiplier, PeakRateEnabled: req.PeakRateEnabled, PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, @@ -382,6 +399,9 @@ func (h *GroupHandler) Update(c *gin.Context) { ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, + VideoPrice480P: req.VideoPrice480P, + VideoPrice720P: req.VideoPrice720P, + VideoPrice1080P: req.VideoPrice1080P, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 03e4c97309..559afdfd9f 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -186,6 +186,8 @@ func groupFromServiceBase(g *service.Group) Group { ImageRateMultiplier: g.ImageRateMultiplier, BatchImageDiscountMultiplier: g.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: g.BatchImageHoldMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, PeakRateEnabled: g.PeakRateEnabled, PeakStart: g.PeakStart, PeakEnd: g.PeakEnd, @@ -193,6 +195,9 @@ func groupFromServiceBase(g *service.Group) Group { ImagePrice1K: g.ImagePrice1K, ImagePrice2K: g.ImagePrice2K, ImagePrice4K: g.ImagePrice4K, + VideoPrice480P: g.VideoPrice480P, + VideoPrice720P: g.VideoPrice720P, + VideoPrice1080P: g.VideoPrice1080P, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 286d2d5459..dc993ce6e1 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -106,6 +106,8 @@ type Group struct { ImageRateMultiplier float64 `json:"image_rate_multiplier"` BatchImageDiscountMultiplier float64 `json:"batch_image_discount_multiplier"` BatchImageHoldMultiplier float64 `json:"batch_image_hold_multiplier"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` // 高峰时段倍率配置 PeakRateEnabled bool `json:"peak_rate_enabled"` PeakStart string `json:"peak_start"` @@ -114,6 +116,9 @@ type Group struct { ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` + VideoPrice480P *float64 `json:"video_price_480p"` + VideoPrice720P *float64 `json:"video_price_720p"` + VideoPrice1080P *float64 `json:"video_price_1080p"` // Claude Code 客户端限制 ClaudeCodeOnly bool `json:"claude_code_only"` diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index be6dc917bb..45f72ae306 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -138,7 +138,7 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool) } billingMode := strings.TrimSpace(c.Query("billing_mode")) - if billingMode != "" && !service.BillingMode(billingMode).IsValid() { + if billingMode != "" && !service.BillingMode(billingMode).IsValidUsageFilter() { response.BadRequest(c, "Invalid billing_mode") return nil, false } diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index 1dcb1b83a4..8dc9a8b442 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -162,6 +162,18 @@ func TestUserUsageListInvalidBillingMode(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) } +func TestUserUsageListAllowsVideoBillingMode(t *testing.T) { + repo := &userUsageRepoCapture{} + router := newUserUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/usage?billing_mode=video", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "video", repo.listFilters.BillingMode) +} + func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) { ipAddress := "203.0.113.10" upstreamModel := "upstream-private-model" diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 877fc90353..2145b1cc57 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -183,6 +183,11 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, + group.FieldVideoRateIndependent, + group.FieldVideoRateMultiplier, + group.FieldVideoPrice480p, + group.FieldVideoPrice720p, + group.FieldVideoPrice1080p, group.FieldClaudeCodeOnly, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, @@ -807,6 +812,11 @@ func groupEntityToService(g *dbent.Group) *service.Group { ImagePrice4K: g.ImagePrice4k, BatchImageDiscountMultiplier: g.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: g.BatchImageHoldMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, + VideoPrice480P: g.VideoPrice480p, + VideoPrice720P: g.VideoPrice720p, + VideoPrice1080P: g.VideoPrice1080p, DefaultValidityDays: g.DefaultValidityDays, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 0aab6b0c05..37529c60be 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -58,6 +58,11 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetNillableImagePrice4k(groupIn.ImagePrice4K). SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier). SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier). + SetVideoRateIndependent(groupIn.VideoRateIndependent). + SetVideoRateMultiplier(groupIn.VideoRateMultiplier). + SetNillableVideoPrice480p(groupIn.VideoPrice480P). + SetNillableVideoPrice720p(groupIn.VideoPrice720P). + SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetNillableFallbackGroupID(groupIn.FallbackGroupID). @@ -143,6 +148,11 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetNillableImagePrice4k(groupIn.ImagePrice4K). SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier). SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier). + SetVideoRateIndependent(groupIn.VideoRateIndependent). + SetVideoRateMultiplier(groupIn.VideoRateMultiplier). + SetNillableVideoPrice480p(groupIn.VideoPrice480P). + SetNillableVideoPrice720p(groupIn.VideoPrice720P). + SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). @@ -190,6 +200,21 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er } else { builder = builder.ClearImagePrice4k() } + if groupIn.VideoPrice480P != nil { + builder = builder.SetVideoPrice480p(*groupIn.VideoPrice480P) + } else { + builder = builder.ClearVideoPrice480p() + } + if groupIn.VideoPrice720P != nil { + builder = builder.SetVideoPrice720p(*groupIn.VideoPrice720P) + } else { + builder = builder.ClearVideoPrice720p() + } + if groupIn.VideoPrice1080P != nil { + builder = builder.SetVideoPrice1080p(*groupIn.VideoPrice1080P) + } else { + builder = builder.ClearVideoPrice1080p() + } // 处理 FallbackGroupID:nil 时清除,否则设置 if groupIn.FallbackGroupID != nil { diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index d39ac39cba..4e30291b82 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -66,6 +66,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { "usage_logs", "usage_logs_image_billing_size_check", "image_count", + "billing_mode = 'video'", "image_size IS NOT NULL", "'1K'", "'2K'", diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index cbdc863750..341bdff57d 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -80,7 +80,9 @@ func appendUsageLogBillingModeWhereConditionWithAlias(conditions []string, args placeholder := fmt.Sprintf("$%d", len(args)+1) switch service.BillingMode(mode) { case service.BillingModeImage: - conditions = append(conditions, fmt.Sprintf("(%s = %s OR COALESCE(%s, 0) > 0)", column("billing_mode"), placeholder, column("image_count"))) + conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) > 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count"))) + case service.BillingModeVideo: + conditions = append(conditions, fmt.Sprintf("%s = %s", column("billing_mode"), placeholder)) case service.BillingModeToken: conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) <= 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count"))) default: diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index e4d4e9a4fa..4a32557e71 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -281,9 +281,14 @@ func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) { wantCondition string }{ { - name: "image includes legacy image rows", + name: "image includes explicit image and legacy image rows", billingMode: string(service.BillingModeImage), - wantCondition: "(billing_mode = $1 OR COALESCE(image_count, 0) > 0)", + wantCondition: "(billing_mode = $1 OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) > 0))", + }, + { + name: "video remains exact", + billingMode: string(service.BillingModeVideo), + wantCondition: "billing_mode = $1", }, { name: "token includes legacy non-image rows", @@ -309,7 +314,7 @@ func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) { func TestAppendUsageLogBillingModeWhereConditionWithAlias(t *testing.T) { conditions, args := appendUsageLogBillingModeWhereConditionWithAlias(nil, nil, string(service.BillingModeImage), "ul") - require.Equal(t, []string{"(ul.billing_mode = $1 OR COALESCE(ul.image_count, 0) > 0)"}, conditions) + require.Equal(t, []string{"(ul.billing_mode = $1 OR ((ul.billing_mode IS NULL OR ul.billing_mode = '') AND COALESCE(ul.image_count, 0) > 0))"}, conditions) require.Equal(t, []any{string(service.BillingModeImage)}, args) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 278e654834..78e3183107 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -361,12 +361,17 @@ func TestAPIContracts(t *testing.T) { "image_price_1k": null, "image_price_2k": null, "image_price_4k": null, + "video_price_480p": null, + "video_price_720p": null, + "video_price_1080p": null, "allow_image_generation": false, "allow_batch_image_generation": false, "batch_image_discount_multiplier": 0, "batch_image_hold_multiplier": 0, "image_rate_independent": false, "image_rate_multiplier": 0, + "video_rate_independent": false, + "video_rate_multiplier": 0, "claude_code_only": false, "allow_messages_dispatch": false, "fallback_group_id": null, diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 43f7508722..c85056d623 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -153,6 +153,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn imagePrice1K := normalizePrice(input.ImagePrice1K) imagePrice2K := normalizePrice(input.ImagePrice2K) imagePrice4K := normalizePrice(input.ImagePrice4K) + videoPrice480P := normalizePrice(input.VideoPrice480P) + videoPrice720P := normalizePrice(input.VideoPrice720P) + videoPrice1080P := normalizePrice(input.VideoPrice1080P) imageRateMultiplier := 1.0 if input.ImageRateMultiplier != nil { if *input.ImageRateMultiplier < 0 { @@ -179,6 +182,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if batchImageHoldMultiplier < batchImageDiscountMultiplier { return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") } + videoRateMultiplier := 1.0 + if input.VideoRateMultiplier != nil { + if *input.VideoRateMultiplier < 0 { + return nil, errors.New("video_rate_multiplier must be >= 0") + } + videoRateMultiplier = *input.VideoRateMultiplier + } peakRateMultiplier := 1.0 if input.PeakRateMultiplier != nil { @@ -265,6 +275,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ImageRateMultiplier: imageRateMultiplier, BatchImageDiscountMultiplier: batchImageDiscountMultiplier, BatchImageHoldMultiplier: batchImageHoldMultiplier, + VideoRateIndependent: input.VideoRateIndependent, + VideoRateMultiplier: videoRateMultiplier, PeakRateEnabled: peakRateEnabled, PeakStart: peakStart, PeakEnd: peakEnd, @@ -272,6 +284,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, ImagePrice4K: imagePrice4K, + VideoPrice480P: videoPrice480P, + VideoPrice720P: videoPrice720P, + VideoPrice1080P: videoPrice1080P, ClaudeCodeOnly: input.ClaudeCodeOnly, FallbackGroupID: input.FallbackGroupID, FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, @@ -482,6 +497,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier { return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") } + if input.VideoRateIndependent != nil { + group.VideoRateIndependent = *input.VideoRateIndependent + } + if input.VideoRateMultiplier != nil { + if *input.VideoRateMultiplier < 0 { + return nil, errors.New("video_rate_multiplier must be >= 0") + } + group.VideoRateMultiplier = *input.VideoRateMultiplier + } if input.PeakRateEnabled != nil { group.PeakRateEnabled = *input.PeakRateEnabled } @@ -510,6 +534,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.ImagePrice4K != nil { group.ImagePrice4K = normalizePrice(input.ImagePrice4K) } + if input.VideoPrice480P != nil { + group.VideoPrice480P = normalizePrice(input.VideoPrice480P) + } + if input.VideoPrice720P != nil { + group.VideoPrice720P = normalizePrice(input.VideoPrice720P) + } + if input.VideoPrice1080P != nil { + group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P) + } // Claude Code 客户端限制 if input.ClaudeCodeOnly != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index beffca8e41..b6377123b1 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -201,6 +201,8 @@ type CreateGroupInput struct { ImageRateMultiplier *float64 BatchImageDiscountMultiplier *float64 BatchImageHoldMultiplier *float64 + VideoRateIndependent bool + VideoRateMultiplier *float64 // 高峰时段倍率配置(PeakRateMultiplier 为 nil 时按 1.0 处理) PeakRateEnabled bool PeakStart string @@ -209,6 +211,9 @@ type CreateGroupInput struct { ImagePrice1K *float64 ImagePrice2K *float64 ImagePrice4K *float64 + VideoPrice480P *float64 + VideoPrice720P *float64 + VideoPrice1080P *float64 ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) @@ -250,6 +255,8 @@ type UpdateGroupInput struct { ImageRateMultiplier *float64 BatchImageDiscountMultiplier *float64 BatchImageHoldMultiplier *float64 + VideoRateIndependent *bool + VideoRateMultiplier *float64 // 高峰时段倍率配置(nil 表示不修改) PeakRateEnabled *bool PeakStart *string @@ -258,6 +265,9 @@ type UpdateGroupInput struct { ImagePrice1K *float64 ImagePrice2K *float64 ImagePrice4K *float64 + VideoPrice480P *float64 + VideoPrice720P *float64 + VideoPrice1080P *float64 ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index d2e3c49996..7731b33bb6 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -174,6 +174,42 @@ func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { require.InDelta(t, 0.30, *repo.created.ImagePrice4K, 0.0001) } +func TestAdminService_CreateGroup_WithVideoPricing(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + price480P := 0.08 + price720P := 0.12 + price1080P := 0.18 + videoMultiplier := 0.75 + + input := &CreateGroupInput{ + Name: "grok-video", + Description: "Grok video group", + Platform: PlatformGrok, + RateMultiplier: 1.0, + VideoRateIndependent: true, + VideoRateMultiplier: &videoMultiplier, + VideoPrice480P: &price480P, + VideoPrice720P: &price720P, + VideoPrice1080P: &price1080P, + } + + group, err := svc.CreateGroup(context.Background(), input) + require.NoError(t, err) + require.NotNil(t, group) + + require.NotNil(t, repo.created) + require.True(t, repo.created.VideoRateIndependent) + require.InDelta(t, 0.75, repo.created.VideoRateMultiplier, 1e-12) + require.NotNil(t, repo.created.VideoPrice480P) + require.NotNil(t, repo.created.VideoPrice720P) + require.NotNil(t, repo.created.VideoPrice1080P) + require.InDelta(t, 0.08, *repo.created.VideoPrice480P, 0.0001) + require.InDelta(t, 0.12, *repo.created.VideoPrice720P, 0.0001) + require.InDelta(t, 0.18, *repo.created.VideoPrice1080P, 0.0001) +} + // TestAdminService_CreateGroup_NilImagePricing 测试 ImagePrice 为 nil 时正常创建 func TestAdminService_CreateGroup_NilImagePricing(t *testing.T) { repo := &groupRepoStubForAdmin{} @@ -307,6 +343,42 @@ func TestAdminService_UpdateGroup_WithImagePricing(t *testing.T) { require.InDelta(t, 0.36, *repo.updated.ImagePrice4K, 0.0001) } +func TestAdminService_UpdateGroup_WithVideoPricing(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-grok", + Platform: PlatformGrok, + Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + price480P := 0.09 + price720P := 0.13 + price1080P := 0.19 + videoMultiplier := 0.6 + independent := true + + input := &UpdateGroupInput{ + VideoRateIndependent: &independent, + VideoRateMultiplier: &videoMultiplier, + VideoPrice480P: &price480P, + VideoPrice720P: &price720P, + VideoPrice1080P: &price1080P, + } + + group, err := svc.UpdateGroup(context.Background(), 1, input) + require.NoError(t, err) + require.NotNil(t, group) + + require.NotNil(t, repo.updated) + require.True(t, repo.updated.VideoRateIndependent) + require.InDelta(t, 0.6, repo.updated.VideoRateMultiplier, 1e-12) + require.InDelta(t, 0.09, *repo.updated.VideoPrice480P, 0.0001) + require.InDelta(t, 0.13, *repo.updated.VideoPrice720P, 0.0001) + require.InDelta(t, 0.19, *repo.updated.VideoPrice1080P, 0.0001) +} + // TestAdminService_UpdateGroup_PartialImagePricing 测试仅更新部分 ImagePrice 字段 func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) { oldPrice2K := 0.15 @@ -542,6 +614,25 @@ func TestAdminService_GroupBatchImagePricingValidation(t *testing.T) { } } +func TestAdminService_UpdateGroup_RejectsNegativeVideoRateMultiplier(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformGrok, + Status: StatusActive, + VideoRateMultiplier: 1, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + negative := -0.1 + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + VideoRateMultiplier: &negative, + }) + require.Error(t, err) + require.Nil(t, repo.updated) +} + func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 6f927ff3b8..11b5246a1d 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -73,6 +73,11 @@ type APIKeyAuthGroupSnapshot struct { ImagePrice1K *float64 `json:"image_price_1k,omitempty"` ImagePrice2K *float64 `json:"image_price_2k,omitempty"` ImagePrice4K *float64 `json:"image_price_4k,omitempty"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` + VideoPrice480P *float64 `json:"video_price_480p,omitempty"` + VideoPrice720P *float64 `json:"video_price_720p,omitempty"` + VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index f3da3df493..539c7375d9 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 13 // v13: include group peak rate fields +const apiKeyAuthSnapshotVersion = 14 // v14: include group video pricing fields type apiKeyAuthCacheConfig struct { l1Size int @@ -265,6 +265,11 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) ImagePrice1K: apiKey.Group.ImagePrice1K, ImagePrice2K: apiKey.Group.ImagePrice2K, ImagePrice4K: apiKey.Group.ImagePrice4K, + VideoRateIndependent: apiKey.Group.VideoRateIndependent, + VideoRateMultiplier: apiKey.Group.VideoRateMultiplier, + VideoPrice480P: apiKey.Group.VideoPrice480P, + VideoPrice720P: apiKey.Group.VideoPrice720P, + VideoPrice1080P: apiKey.Group.VideoPrice1080P, ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly, FallbackGroupID: apiKey.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest, @@ -343,6 +348,11 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho ImagePrice1K: snapshot.Group.ImagePrice1K, ImagePrice2K: snapshot.Group.ImagePrice2K, ImagePrice4K: snapshot.Group.ImagePrice4K, + VideoRateIndependent: snapshot.Group.VideoRateIndependent, + VideoRateMultiplier: snapshot.Group.VideoRateMultiplier, + VideoPrice480P: snapshot.Group.VideoPrice480P, + VideoPrice720P: snapshot.Group.VideoPrice720P, + VideoPrice1080P: snapshot.Group.VideoPrice1080P, ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly, FallbackGroupID: snapshot.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 4c265aed3c..289cf45354 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1228,6 +1228,13 @@ type ImagePriceConfig struct { Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值) } +// VideoPriceConfig 视频生成计费配置。 +type VideoPriceConfig struct { + Price480P *float64 // 480p 视频价格(nil 表示使用默认值) + Price720P *float64 // 720p 视频价格(nil 表示使用默认值) + Price1080P *float64 // 1080p 视频价格(nil 表示使用默认值) +} + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" @@ -1259,6 +1266,33 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag } } +// CalculateVideoCost 计算视频生成费用。 +// model: 请求的模型名称(用于获取默认价格) +// resolution: 视频分辨率 "480p", "720p", "1080p" +// videoCount: 生成的视频数量 +// groupConfig: 分组配置的价格(可能为 nil,表示使用默认值) +// rateMultiplier: 费率倍数 +func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown { + if videoCount <= 0 { + return &CostBreakdown{} + } + resolution = NormalizeVideoBillingResolutionOrDefault(resolution) + + unitPrice := s.getVideoUnitPrice(model, resolution, groupConfig) + totalCost := unitPrice * float64(videoCount) + + if rateMultiplier < 0 { + rateMultiplier = 0 + } + actualCost := totalCost * rateMultiplier + + return &CostBreakdown{ + TotalCost: totalCost, + ActualCost: actualCost, + BillingMode: string(BillingModeVideo), + } +} + // getImageUnitPrice 获取图片单价 func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 { // 优先使用分组配置的价格 @@ -1283,6 +1317,27 @@ func (s *BillingService) getImageUnitPrice(model string, imageSize string, group return s.getDefaultImagePrice(model, imageSize) } +func (s *BillingService) getVideoUnitPrice(model string, resolution string, groupConfig *VideoPriceConfig) float64 { + if groupConfig != nil { + switch resolution { + case VideoBillingResolution480P: + if groupConfig.Price480P != nil { + return *groupConfig.Price480P + } + case VideoBillingResolution720P: + if groupConfig.Price720P != nil { + return *groupConfig.Price720P + } + case VideoBillingResolution1080P: + if groupConfig.Price1080P != nil { + return *groupConfig.Price1080P + } + } + } + + return s.getDefaultVideoPrice(model, resolution) +} + // getDefaultImagePrice 获取 LiteLLM 默认图片价格 func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 { basePrice := 0.0 @@ -1310,3 +1365,11 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl return basePrice } + +func (s *BillingService) getDefaultVideoPrice(model string, resolution string) float64 { + _ = resolution + // The bundled LiteLLM schema does not expose an output video generation price. + // Keep the historical model default as the fallback, while letting group-level + // video prices override it independently from image prices. + return s.getDefaultImagePrice(model, ImageBillingSize2K) +} diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 92c143c6ff..bafa4430a0 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -872,6 +872,20 @@ func TestCalculateImageCost(t *testing.T) { require.InDelta(t, 0.134*3, cost.ActualCost, 1e-10) } +func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) { + svc := newTestBillingService() + + imagePrice := 0.4 + videoPrice := 0.08 + imageCost := svc.CalculateImageCost("grok-imagine-video", "2K", 1, &ImagePriceConfig{Price2K: &imagePrice}, 1.0) + videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, &VideoPriceConfig{Price480P: &videoPrice}, 0.5) + + require.InDelta(t, 0.4, imageCost.TotalCost, 1e-10) + require.InDelta(t, 0.08, videoCost.TotalCost, 1e-10) + require.InDelta(t, 0.04, videoCost.ActualCost, 1e-10) + require.Equal(t, string(BillingModeVideo), videoCost.BillingMode) +} + func TestIsModelSupported(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 88ed2df79c..1fd9e57068 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -14,6 +14,7 @@ const ( BillingModeToken BillingMode = "token" // 按 token 区间计费 BillingModePerRequest BillingMode = "per_request" // 按次计费(支持上下文窗口分层) BillingModeImage BillingMode = "image" // 图片计费(当前按次,预留 token 计费) + BillingModeVideo BillingMode = "video" // 视频生成计费(按视频生成次数) ) // IsValid 检查 BillingMode 是否为合法值 @@ -25,6 +26,15 @@ func (m BillingMode) IsValid() bool { return false } +// IsValidUsageFilter 检查 BillingMode 是否可用于使用记录筛选。 +func (m BillingMode) IsValidUsageFilter() bool { + switch m { + case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo, "": + return true + } + return false +} + const ( BillingModelSourceRequested = "requested" BillingModelSourceUpstream = "upstream" diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 21685caae8..8a95915981 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -796,6 +796,10 @@ func (s *GatewayService) calculateImageCost( multiplier float64, ) *CostBreakdown { sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) + groupConfig := imagePriceConfigFromAPIKey(apiKey) + if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) + } if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { tokens := UsageTokens{ InputTokens: result.Usage.InputTokens, @@ -821,14 +825,6 @@ func (s *GatewayService) calculateImageCost( return cost } - var groupConfig *ImagePriceConfig - if apiKey.Group != nil { - groupConfig = &ImagePriceConfig{ - Price1K: apiKey.Group.ImagePrice1K, - Price2K: apiKey.Group.ImagePrice2K, - Price4K: apiKey.Group.ImagePrice4K, - } - } return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 09d70eb0bd..70e439e46e 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -48,6 +48,7 @@ type GrokMediaRequestInfo struct { N int Size string SizeTier string + Resolution string InputImageURLs []string MaskImageURL string Uploads []OpenAIImagesUpload @@ -114,6 +115,7 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo info.Prompt = strings.TrimSpace(info.Prompt) info.Size = strings.TrimSpace(info.Size) info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) + info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution) if info.N <= 0 { info.N = 1 } @@ -127,6 +129,7 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { info.Model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) + info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String()) if n := gjson.GetBytes(body, "n"); n.Exists() && n.Type == gjson.Number { info.N = int(n.Int()) } @@ -226,6 +229,8 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM info.Prompt = value case "size": info.Size = value + case "resolution": + info.Resolution = value case "n": if n, err := strconv.Atoi(value); err == nil { info.N = n @@ -363,6 +368,8 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( ImageSize: usage.ImageSize, ImageInputSize: usage.ImageInputSize, ImageOutputSizes: usage.ImageOutputSizes, + VideoCount: usage.VideoCount, + VideoResolution: usage.VideoResolution, }, nil } @@ -471,6 +478,8 @@ type grokMediaUsageMetadata struct { ImageSize string ImageInputSize string ImageOutputSizes []string + VideoCount int + VideoResolution string } func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMediaRequestInfo, responseBody []byte) grokMediaUsageMetadata { @@ -491,10 +500,10 @@ func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMedi meta.ImageOutputSizes = collectOpenAIResponseImageOutputSizesFromJSONBytes(responseBody) case GrokMediaEndpointVideosGenerations: meta.ResponseID = extractGrokMediaVideoRequestID(responseBody) - // Video generation is one billable media unit; the legacy usage schema stores it in ImageCount. + meta.VideoCount = 1 + meta.VideoResolution = requestInfo.Resolution + // Keep the legacy media-unit counter populated for existing usage displays. meta.ImageCount = 1 - meta.ImageSize = requestInfo.SizeTier - meta.ImageInputSize = requestInfo.Size } return meta } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index e3a1697b57..a61e356a01 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -45,6 +45,11 @@ type Group struct { ImagePrice4K *float64 BatchImageDiscountMultiplier float64 BatchImageHoldMultiplier float64 + VideoRateIndependent bool + VideoRateMultiplier float64 + VideoPrice480P *float64 + VideoPrice720P *float64 + VideoPrice1080P *float64 // Claude Code 客户端限制 ClaudeCodeOnly bool @@ -125,6 +130,21 @@ func (g *Group) GetImagePrice(imageSize string) *float64 { } } +// GetVideoPrice 根据 resolution 返回对应的视频生成价格。 +// 如果分组未配置价格,返回 nil(调用方应使用默认值)。 +func (g *Group) GetVideoPrice(resolution string) *float64 { + switch NormalizeVideoBillingResolutionOrDefault(resolution) { + case VideoBillingResolution480P: + return g.VideoPrice480P + case VideoBillingResolution720P: + return g.VideoPrice720P + case VideoBillingResolution1080P: + return g.VideoPrice1080P + default: + return g.VideoPrice480P + } +} + // IsGroupContextValid reports whether a group from context has the fields required for routing decisions. func IsGroupContextValid(group *Group) bool { if group == nil { diff --git a/backend/internal/service/image_billing_multiplier.go b/backend/internal/service/image_billing_multiplier.go index 23ec5ac104..6b7172c2a0 100644 --- a/backend/internal/service/image_billing_multiplier.go +++ b/backend/internal/service/image_billing_multiplier.go @@ -9,3 +9,13 @@ func resolveImageRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64 } return effectiveGroupMultiplier } + +func resolveVideoRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64) float64 { + if apiKey != nil && apiKey.Group != nil && apiKey.Group.VideoRateIndependent { + if apiKey.Group.VideoRateMultiplier < 0 { + return 0 + } + return apiKey.Group.VideoRateMultiplier + } + return effectiveGroupMultiplier +} diff --git a/backend/internal/service/media_price_config.go b/backend/internal/service/media_price_config.go new file mode 100644 index 0000000000..ed84998906 --- /dev/null +++ b/backend/internal/service/media_price_config.go @@ -0,0 +1,31 @@ +package service + +func imagePriceConfigFromAPIKey(apiKey *APIKey) *ImagePriceConfig { + if apiKey == nil || apiKey.Group == nil { + return nil + } + return &ImagePriceConfig{ + Price1K: apiKey.Group.ImagePrice1K, + Price2K: apiKey.Group.ImagePrice2K, + Price4K: apiKey.Group.ImagePrice4K, + } +} + +func apiKeyHasConfiguredImagePrice(apiKey *APIKey, imageSize string) bool { + return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetImagePrice(imageSize) != nil +} + +func videoPriceConfigFromAPIKey(apiKey *APIKey) *VideoPriceConfig { + if apiKey == nil || apiKey.Group == nil { + return nil + } + return &VideoPriceConfig{ + Price480P: apiKey.Group.VideoPrice480P, + Price720P: apiKey.Group.VideoPrice720P, + Price1080P: apiKey.Group.VideoPrice1080P, + } +} + +func apiKeyHasConfiguredVideoPrice(apiKey *APIKey, resolution string) bool { + return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetVideoPrice(resolution) != nil +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index f6aa4b6cd1..7f348f217a 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -201,6 +201,13 @@ func TestParseGrokMediaRequestBuildsMultipartModerationBody(t *testing.T) { require.True(t, strings.HasPrefix(gjson.GetBytes(moderationBody, "images.0.image_url").String(), "data:image/")) } +func TestParseGrokMediaVideoRequestResolution(t *testing.T) { + info := ParseGrokMediaRequest("application/json", []byte(`{"model":"grok-imagine-video","prompt":"waves","resolution":"720p"}`)) + + require.Equal(t, "grok-imagine-video", info.Model) + require.Equal(t, "720p", info.Resolution) +} + func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { tests := []struct { name string @@ -330,7 +337,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves"}`) + body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p"}`) c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -364,6 +371,9 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) require.Equal(t, 3, result.Usage.InputTokens) require.Equal(t, 4, result.Usage.OutputTokens) require.Equal(t, 1, result.ImageCount) + require.Empty(t, result.ImageSize) + require.Equal(t, 1, result.VideoCount) + require.Equal(t, VideoBillingResolution720P, result.VideoResolution) } func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 697d89e81c..a59852d576 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1803,8 +1803,9 @@ func TestOpenAIGatewayServiceRecordUsage_ImageIndependentMultiplierUsesImageRate require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) } -func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) { - mediaPrice2K := 0.4 +func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { + imagePrice2K := 0.4 + videoPrice480P := 0.08 groupID := int64(126) usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} @@ -1812,14 +1813,14 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) { err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "video-request-123", - ResponseID: "video-request-123", - Model: "grok-imagine-video-1.5", - BillingModel: "grok-imagine-video-1.5", - // The usage schema has no separate video count; video generation is billed as one media unit. - ImageCount: 1, - ImageSize: ImageBillingSize2K, - Duration: time.Second, + RequestID: "video-request-123", + ResponseID: "video-request-123", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution480P, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10126, @@ -1830,7 +1831,10 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) { RateMultiplier: 0.15, ImageRateIndependent: true, ImageRateMultiplier: 0.5, - ImagePrice2K: &mediaPrice2K, + ImagePrice2K: &imagePrice2K, + VideoRateIndependent: true, + VideoRateMultiplier: 0.25, + VideoPrice480P: &videoPrice480P, }, }, User: &User{ID: 20126}, @@ -1841,14 +1845,197 @@ func TestGrokVideoMediaBillingUsesImageRateMultiplier(t *testing.T) { require.NotNil(t, usageRepo.lastLog) require.Equal(t, "grok-imagine-video-1.5", usageRepo.lastLog.Model) require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.InDelta(t, 0.08, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.02, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.25, usageRepo.lastLog.RateMultiplier, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { + groupID := int64(127) + channelPrice := 0.201 + groupImagePrice2K := 0.021 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "grok-imagine-image-quality", channelPrice) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_image_group_price", + Model: "grok-imagine-image-quality", + BillingModel: "grok-imagine-image-quality", + ImageCount: 1, + ImageSize: ImageBillingSize2K, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10127, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + ImageRateIndependent: true, + ImageRateMultiplier: 1, + ImagePrice2K: &groupImagePrice2K, + }, + }, + User: &User{ID: 20127}, + Account: &Account{ID: 30127, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize) - require.InDelta(t, 0.4, usageRepo.lastLog.TotalCost, 1e-12) - require.InDelta(t, 0.2, usageRepo.lastLog.ActualCost, 1e-12) - require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12) + require.InDelta(t, 0.021, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.021, usageRepo.lastLog.ActualCost, 1e-12) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) } +func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePrice(t *testing.T) { + groupID := int64(128) + channelPrice := 0.201 + groupVideoPrice720P := 0.037 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video", channelPrice) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_video_group_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10128, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + VideoRateIndependent: true, + VideoRateMultiplier: 1, + VideoPrice720P: &groupVideoPrice720P, + }, + }, + User: &User{ID: 20128}, + Account: &Account{ID: 30128, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.InDelta(t, 0.037, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.037, usageRepo.lastLog.ActualCost, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_HydratesGroupImagePriceWhenAuthSnapshotOmitsIt(t *testing.T) { + groupID := int64(130) + groupImagePrice2K := 0.021 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + channelService := &ChannelService{groupRepo: &openAIMediaPriceGroupRepoStub{group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + ImagePrice2K: &groupImagePrice2K, + }}} + channelCache := newEmptyChannelCache() + channelCache.loadedAt = time.Now() + channelService.cache.Store(channelCache) + svc.channelService = channelService + refreshed := svc.apiKeyWithFreshGroupMediaPricing(context.Background(), &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}}) + require.NotNil(t, refreshed.Group.ImagePrice2K) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_image_hydrated_price", + Model: "grok-imagine-image-quality", + BillingModel: "grok-imagine-image-quality", + ImageCount: 1, + ImageSize: ImageBillingSize2K, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10130, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 20130}, + Account: &Account{ID: 30130, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.021, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.021, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshotOmitsIt(t *testing.T) { + groupID := int64(131) + groupVideoPrice720P := 0.037 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + channelService := &ChannelService{groupRepo: &openAIMediaPriceGroupRepoStub{group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + VideoPrice720P: &groupVideoPrice720P, + }}} + channelCache := newEmptyChannelCache() + channelCache.loadedAt = time.Now() + channelService.cache.Store(channelCache) + svc.channelService = channelService + refreshed := svc.apiKeyWithFreshGroupMediaPricing(context.Background(), &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}}) + require.NotNil(t, refreshed.Group.VideoPrice720P) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_video_hydrated_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10131, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 20131}, + Account: &Account{ID: 30131, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.InDelta(t, 0.037, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.037, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) { groupID := int64(123) usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} @@ -1960,6 +2147,19 @@ func newOpenAITokenImageChannelPricingResolverForTest(t *testing.T, groupID int6 return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) } +type openAIMediaPriceGroupRepoStub struct { + GroupRepository + group *Group + err error +} + +func (s *openAIMediaPriceGroupRepoStub) GetByIDLite(context.Context, int64) (*Group, error) { + if s.err != nil { + return nil, s.err + } + return s.group, nil +} + func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCount(t *testing.T) { groupID := int64(126) billingService := NewBillingService(&config.Config{}, nil) @@ -2023,6 +2223,38 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( require.InDelta(t, 0.80, cost.ActualCost, 1e-12) } +func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { + groupID := int64(129) + channelPrice := 0.25 + groupImagePrice2K := 0.021 + + svc := &GatewayService{ + billingService: NewBillingService(&config.Config{}, nil), + resolver: newOpenAIImageChannelPricingResolverForTest(t, groupID, "gemini-image", channelPrice), + } + + cost := svc.calculateRecordUsageCost( + context.Background(), + &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: ImageBillingSize2K}, + &APIKey{ + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + ImagePrice2K: &groupImagePrice2K, + }, + }, + "gemini-image", + 1.0, + 1.0, + nil, + ) + + require.NotNil(t, cost) + require.Equal(t, string(BillingModeImage), cost.BillingMode) + require.InDelta(t, 0.042, cost.TotalCost, 1e-12) + require.InDelta(t, 0.042, cost.ActualCost, 1e-12) +} + func TestRecordUsageMarksCyberRequestType(t *testing.T) { logStub := &openAIRecordUsageLogRepoStub{inserted: true} userStub := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index c1d1670e07..822a5f65d3 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -242,6 +242,8 @@ type OpenAIForwardResult struct { ImageOutputSizes []string ImageSizeSource string ImageSizeBreakdown map[string]int + VideoCount int + VideoResolution string wsReplayInput []json.RawMessage wsReplayInputExists bool diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 58b6029ffa..569a7a74ba 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -115,7 +115,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec user := input.User account := input.Account subscription := input.Subscription - ApplyOpenAIImageBillingResolution(result) + if !isGrokVideoUsageResult(result, nil) { + ApplyOpenAIImageBillingResolution(result) + } // 计算实际的新输入token(减去缓存读取的token) // 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费 @@ -148,7 +150,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) + baseMultiplier := multiplier + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, timezone.Now()) + videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier) var cost *CostBreakdown var err error @@ -174,7 +178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) } - cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, tokens, serviceTier) + cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, tokens, serviceTier) if err != nil { if !isUsagePricingUnavailableError(err) { return err @@ -238,6 +242,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), ImageSizeBreakdown: result.ImageSizeBreakdown, } + isVideoUsage := isGrokVideoUsageResult(result, billingModels) if cost != nil { usageLog.InputCost = cost.InputCost usageLog.OutputCost = cost.OutputCost @@ -247,7 +252,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec usageLog.TotalCost = cost.TotalCost usageLog.ActualCost = cost.ActualCost } - if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) { + if isVideoUsage && (cost == nil || cost.BillingMode != string(BillingModeToken)) { + usageLog.RateMultiplier = videoMultiplier + } else if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) { usageLog.RateMultiplier = imageMultiplier } else { usageLog.RateMultiplier = multiplier @@ -269,6 +276,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if cost != nil && cost.BillingMode != "" { billingMode := cost.BillingMode usageLog.BillingMode = &billingMode + } else if isVideoUsage { + billingMode := string(BillingModeVideo) + usageLog.BillingMode = &billingMode } else if result.ImageCount > 0 { billingMode := string(BillingModeImage) usageLog.BillingMode = &billingMode @@ -346,10 +356,16 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( billingModels []string, multiplier float64, imageMultiplier float64, + videoMultiplier float64, tokens UsageTokens, serviceTier string, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) + if isGrokVideoUsageResult(result, billingModels) { + if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { + return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil + } + } if result != nil && result.ImageCount > 0 { // 渠道定价为 token 计费时走 token 路径,否则走图片计费 if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { @@ -377,6 +393,24 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } +func isGrokVideoBillingModel(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-imagine-video") +} + +func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) bool { + if result == nil || result.VideoCount <= 0 { + return false + } + candidates := append([]string{}, billingModels...) + candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel) + for _, candidate := range candidates { + if isGrokVideoBillingModel(candidate) { + return true + } + } + return false +} + func isUsagePricingUnavailableError(err error) bool { if err == nil { return false @@ -420,6 +454,17 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( multiplier float64, ) *CostBreakdown { sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) + groupConfig := imagePriceConfigFromAPIKey(apiKey) + if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) + } + if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { + apiKey = refreshed + groupConfig = imagePriceConfigFromAPIKey(apiKey) + if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) + } + } if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { gid := apiKey.Group.ID @@ -439,15 +484,69 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err) } - var groupConfig *ImagePriceConfig - if apiKey != nil && apiKey.Group != nil { - groupConfig = &ImagePriceConfig{ - Price1K: apiKey.Group.ImagePrice1K, - Price2K: apiKey.Group.ImagePrice2K, - Price4K: apiKey.Group.ImagePrice4K, + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) +} + +func (s *OpenAIGatewayService) calculateOpenAIVideoCost( + ctx context.Context, + billingModel string, + apiKey *APIKey, + result *OpenAIForwardResult, + multiplier float64, +) *CostBreakdown { + videoCount := result.VideoCount + if videoCount <= 0 { + videoCount = 1 + } + resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution) + groupConfig := videoPriceConfigFromAPIKey(apiKey) + if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + } + if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { + apiKey = refreshed + groupConfig = videoPriceConfigFromAPIKey(apiKey) + if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) } } - return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) + if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && + (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + RequestCount: videoCount, + SizeTier: resolution, + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, + }) + if err == nil { + cost.BillingMode = string(BillingModeVideo) + return cost + } + logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err) + } + + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) +} + +func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey { + if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 { + return apiKey + } + if s == nil || s.channelService == nil || s.channelService.groupRepo == nil { + return apiKey + } + group, err := s.channelService.groupRepo.GetByIDLite(ctx, *apiKey.GroupID) + if err != nil || group == nil { + return apiKey + } + clone := *apiKey + clone.Group = group + return &clone } func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { diff --git a/backend/internal/service/video_billing_resolution.go b/backend/internal/service/video_billing_resolution.go new file mode 100644 index 0000000000..bca068f097 --- /dev/null +++ b/backend/internal/service/video_billing_resolution.go @@ -0,0 +1,22 @@ +package service + +import "strings" + +const ( + VideoBillingResolution480P = "480p" + VideoBillingResolution720P = "720p" + VideoBillingResolution1080P = "1080p" +) + +func NormalizeVideoBillingResolutionOrDefault(resolution string) string { + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "480", "480p", "sd": + return VideoBillingResolution480P + case "720", "720p", "hd": + return VideoBillingResolution720P + case "1080", "1080p", "full_hd", "full-hd", "fhd": + return VideoBillingResolution1080P + default: + return VideoBillingResolution480P + } +} diff --git a/backend/migrations/170_add_grok_video_pricing_controls.sql b/backend/migrations/170_add_grok_video_pricing_controls.sql new file mode 100644 index 0000000000..9c798a9945 --- /dev/null +++ b/backend/migrations/170_add_grok_video_pricing_controls.sql @@ -0,0 +1,16 @@ +-- Add independent group pricing controls for Grok video generation. +-- Video prices intentionally do not backfill from image prices: image and video +-- generation must be priced separately. + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS video_rate_independent BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS video_rate_multiplier DECIMAL(10,4) NOT NULL DEFAULT 1.0, + ADD COLUMN IF NOT EXISTS video_price_480p DECIMAL(20,8), + ADD COLUMN IF NOT EXISTS video_price_720p DECIMAL(20,8), + ADD COLUMN IF NOT EXISTS video_price_1080p DECIMAL(20,8); + +COMMENT ON COLUMN groups.video_rate_independent IS '视频生成是否使用独立倍率;false 表示共享分组有效倍率'; +COMMENT ON COLUMN groups.video_rate_multiplier IS '视频生成独立倍率,仅 video_rate_independent=true 时生效'; +COMMENT ON COLUMN groups.video_price_480p IS '480p 视频生成单价 (USD),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_720p IS '720p 视频生成单价 (USD),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_1080p IS '1080p 视频生成单价 (USD),Grok 平台使用'; diff --git a/backend/migrations/171_allow_video_usage_without_image_size.sql b/backend/migrations/171_allow_video_usage_without_image_size.sql new file mode 100644 index 0000000000..767a15c00e --- /dev/null +++ b/backend/migrations/171_allow_video_usage_without_image_size.sql @@ -0,0 +1,17 @@ +-- Grok video generation stores billing_mode='video' and keeps image_count=1 +-- only as a legacy media-unit counter. It must not be forced to carry an +-- image_size, because video pricing uses video_resolution/request metadata. + +ALTER TABLE usage_logs + DROP CONSTRAINT IF EXISTS usage_logs_image_billing_size_check; + +ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_image_billing_size_check + CHECK ( + image_count <= 0 + OR billing_mode = 'video' + OR ( + image_size IS NOT NULL + AND image_size IN ('1K', '2K', '4K', 'mixed') + ) + ) NOT VALID; diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index bb63d9b8ee..2c8127609d 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -294,7 +294,8 @@ const billingModeOptions = ref([ { value: null, label: t('admin.usage.allBillingModes') }, { value: 'token', label: t('admin.usage.billingModeToken') }, { value: 'per_request', label: t('admin.usage.billingModePerRequest') }, - { value: 'image', label: t('admin.usage.billingModeImage') } + { value: 'image', label: t('admin.usage.billingModeImage') }, + { value: 'video', label: t('admin.usage.billingModeVideo') } ]) const emitChange = () => emit('change') diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 399b9f3208..07a24d0fd5 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -26,6 +26,7 @@ export default { billingModeToken: 'Per Token', billingModePerRequest: 'Per Request', billingModeImage: 'Per Image', + billingModeVideo: 'Per Video', inputPrice: 'Input', outputPrice: 'Output', cacheWritePrice: 'Cache Write', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 7f69b3fa16..545135d3a1 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -853,6 +853,16 @@ export default { finalPricePreview: 'Final per-media-unit price preview', notConfigured: 'Not configured' }, + videoPricing: { + title: 'Video Generation Pricing', + description: 'Configure Grok video generation base prices. Leave empty to use default video prices.', + independentMultiplier: 'Use independent video multiplier', + videoMultiplier: 'Video multiplier', + modeHint: + 'By default, video billing uses video price × current effective group multiplier. Independent mode uses video price × video multiplier.', + finalPricePreview: 'Final per-video price preview', + notConfigured: 'Not configured' + }, peakRate: { enable: 'Enable peak rate multiplier', peakStart: 'Peak start', diff --git a/frontend/src/i18n/locales/en/admin/resources.ts b/frontend/src/i18n/locales/en/admin/resources.ts index 30b05e5e6b..c6c42d4494 100644 --- a/frontend/src/i18n/locales/en/admin/resources.ts +++ b/frontend/src/i18n/locales/en/admin/resources.ts @@ -477,6 +477,7 @@ export default { billingModeToken: 'Token', billingModePerRequest: 'Per Request', billingModeImage: 'Image', + billingModeVideo: 'Video', allBillingModes: 'All Billing Modes', ipAddress: 'IP', clickToViewBalance: 'Click to view balance history', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index a9c7c750c8..d91c326878 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -477,6 +477,7 @@ export default { billingModeToken: 'Per Token', billingModePerRequest: 'Per Request', billingModeImage: 'Per Image', + billingModeVideo: 'Per Video', inputPrice: 'Input', outputPrice: 'Output', cacheWritePrice: 'Cache Write', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 036f3f77e0..93890e25b0 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -26,6 +26,7 @@ export default { billingModeToken: '按 Token', billingModePerRequest: '按次', billingModeImage: '按图片', + billingModeVideo: '按视频', inputPrice: '输入', outputPrice: '输出', cacheWritePrice: '缓存写入', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index cebf7e760e..b56fcde5d0 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -930,6 +930,16 @@ export default { finalPricePreview: '最终单次媒体价格预览', notConfigured: '未配置' }, + videoPricing: { + title: '视频生成计费', + description: '配置 Grok 视频生成基础单价,留空则使用默认视频价格', + independentMultiplier: '视频倍率独立', + videoMultiplier: '视频独立倍率', + modeHint: + '默认关闭独立倍率时,视频费用 = 视频价格 × 当前分组有效倍率;开启独立倍率后,视频费用 = 视频价格 × 视频独立倍率。', + finalPricePreview: '最终单次视频价格预览', + notConfigured: '未配置' + }, peakRate: { enable: '启用高峰倍率', peakStart: '高峰开始', diff --git a/frontend/src/i18n/locales/zh/admin/resources.ts b/frontend/src/i18n/locales/zh/admin/resources.ts index ec4753cf4b..8398130d84 100644 --- a/frontend/src/i18n/locales/zh/admin/resources.ts +++ b/frontend/src/i18n/locales/zh/admin/resources.ts @@ -538,6 +538,7 @@ export default { billingModeToken: '按量', billingModePerRequest: '按次', billingModeImage: '按次(图片)', + billingModeVideo: '按次(视频)', allBillingModes: '全部计费模式', ipAddress: 'IP', clickToViewBalance: '点击查看充值记录', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 2ce3eda5bb..17eac4ea91 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -482,6 +482,7 @@ export default { billingModeToken: '按 Token', billingModePerRequest: '按次', billingModeImage: '按图片', + billingModeVideo: '按视频', inputPrice: '输入', outputPrice: '输出', cacheWritePrice: '缓存写入', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8465332287..f59c4c7253 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -526,6 +526,11 @@ export interface Group { image_price_1k: number | null image_price_2k: number | null image_price_4k: number | null + video_rate_independent: boolean + video_rate_multiplier: number + video_price_480p: number | null + video_price_720p: number | null + video_price_1080p: number | null // 高峰时段倍率配置 peak_rate_enabled: boolean peak_start: string @@ -653,6 +658,11 @@ export interface CreateGroupRequest { image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null + video_rate_independent?: boolean + video_rate_multiplier?: number + video_price_480p?: number | null + video_price_720p?: number | null + video_price_1080p?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string @@ -695,6 +705,11 @@ export interface UpdateGroupRequest { image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null + video_rate_independent?: boolean + video_rate_multiplier?: number + video_price_480p?: number | null + video_price_720p?: number | null + video_price_1080p?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string diff --git a/frontend/src/utils/billingMode.ts b/frontend/src/utils/billingMode.ts index 0db770f758..1ba03ed377 100644 --- a/frontend/src/utils/billingMode.ts +++ b/frontend/src/utils/billingMode.ts @@ -1,11 +1,13 @@ export const BILLING_MODE_TOKEN = 'token' export const BILLING_MODE_PER_REQUEST = 'per_request' export const BILLING_MODE_IMAGE = 'image' +export const BILLING_MODE_VIDEO = 'video' export function getBillingModeLabel(mode: string | null | undefined, t: (key: string) => string): string { switch (mode) { case BILLING_MODE_PER_REQUEST: return t('admin.usage.billingModePerRequest') case BILLING_MODE_IMAGE: return t('admin.usage.billingModeImage') + case BILLING_MODE_VIDEO: return t('admin.usage.billingModeVideo') default: return t('admin.usage.billingModeToken') } } @@ -14,6 +16,7 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin switch (mode) { case BILLING_MODE_PER_REQUEST: return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' case BILLING_MODE_IMAGE: return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' + case BILLING_MODE_VIDEO: return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' } } @@ -25,7 +28,7 @@ interface ImageBillingRow { } export function isImageUsage(row: Pick | null | undefined): boolean { - return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN + return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN && row?.billing_mode !== BILLING_MODE_VIDEO } export function getDisplayBillingMode(row: Pick | null | undefined): string | null | undefined { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index b5c8334785..389d4f9ea3 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -787,7 +787,7 @@
- +
+ +
+ +

+ {{ t(videoPricingI18nKey("description")) }} +

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ {{ t(videoPricingI18nKey("modeHint")) }} +

+
+
+ {{ t(videoPricingI18nKey("finalPricePreview")) }} +
+
+
+ {{ item.label }}: {{ item.value }} +
+
+
+
+
@@ -2174,7 +2266,7 @@
- +
+ +
+ +

+ {{ t(videoPricingI18nKey("description")) }} +

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ {{ t(videoPricingI18nKey("modeHint")) }} +

+
+
+ {{ t(videoPricingI18nKey("finalPricePreview")) }} +
+
+
+ {{ item.label }}: {{ item.value }} +
+
+
+
+
@@ -3315,6 +3499,8 @@ import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModel import { imagePricingI18nKey, supportsImagePricingPlatform, + supportsVideoPricingPlatform, + videoPricingI18nKey, } from "./groupsImagePricing"; const { t } = useI18n(); @@ -3657,6 +3843,12 @@ const createForm = reactive({ image_price_1k: null as number | null, image_price_2k: null as number | null, image_price_4k: null as number | null, + // 视频生成计费配置(仅 Grok 平台) + video_rate_independent: false, + video_rate_multiplier: 1, + video_price_480p: null as number | null, + video_price_720p: null as number | null, + video_price_1080p: null as number | null, // 高峰时段倍率配置 peak_rate_enabled: false, peak_start: "", @@ -3996,6 +4188,12 @@ const editForm = reactive({ image_price_1k: null as number | null, image_price_2k: null as number | null, image_price_4k: null as number | null, + // 视频生成计费配置(仅 Grok 平台) + video_rate_independent: false, + video_rate_multiplier: 1, + video_price_480p: null as number | null, + video_price_720p: null as number | null, + video_price_1080p: null as number | null, // 高峰时段倍率配置 peak_rate_enabled: false, peak_start: "", @@ -4045,12 +4243,27 @@ type ImagePricingFormState = { peak_rate_multiplier: number; }; +type VideoPricingFormState = { + rate_multiplier: number; + video_rate_independent: boolean; + video_rate_multiplier: number; + video_price_480p: number | string | null; + video_price_720p: number | string | null; + video_price_1080p: number | string | null; +}; + const imagePricingTiers = [ { key: "image_price_1k", label: "1K" }, { key: "image_price_2k", label: "2K" }, { key: "image_price_4k", label: "4K" }, ] as const; +const videoPricingTiers = [ + { key: "video_price_480p", label: "480p" }, + { key: "video_price_720p", label: "720p" }, + { key: "video_price_1080p", label: "1080p" }, +] as const; + const normalizePreviewNumber = (value: number | string | null | undefined, fallback = 0) => { if (value === null || value === undefined || value === "") { return fallback; @@ -4070,6 +4283,17 @@ const formatImagePricePreview = (value: number | string | null | undefined) => { return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`; }; +const formatVideoPricePreview = (value: number | string | null | undefined) => { + if (value === null || value === undefined || value === "") { + return t("admin.groups.videoPricing.notConfigured"); + } + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + return t("admin.groups.videoPricing.notConfigured"); + } + return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`; +}; + const buildImageFinalPricePreview = (form: ImagePricingFormState) => { const imageMultiplier = form.image_rate_independent ? normalizePreviewNumber(form.image_rate_multiplier, 1) @@ -4086,12 +4310,33 @@ const buildImageFinalPricePreview = (form: ImagePricingFormState) => { }); }; +const buildVideoFinalPricePreview = (form: VideoPricingFormState) => { + const multiplier = form.video_rate_independent + ? normalizePreviewNumber(form.video_rate_multiplier, 1) + : normalizePreviewNumber(form.rate_multiplier, 1); + return videoPricingTiers.map((tier) => { + const basePrice = normalizePreviewNumber(form[tier.key]); + return { + label: tier.label, + value: basePrice > 0 + ? formatVideoPricePreview(basePrice * multiplier) + : t("admin.groups.videoPricing.notConfigured"), + }; + }); +}; + const createImageFinalPricePreview = computed(() => buildImageFinalPricePreview(createForm), ); const editImageFinalPricePreview = computed(() => buildImageFinalPricePreview(editForm), ); +const createVideoFinalPricePreview = computed(() => + buildVideoFinalPricePreview(createForm), +); +const editVideoFinalPricePreview = computed(() => + buildVideoFinalPricePreview(editForm), +); const resetDisabledBatchImagePricing = ( form: Pick< @@ -4293,6 +4538,11 @@ const closeCreateModal = () => { createForm.image_price_1k = null; createForm.image_price_2k = null; createForm.image_price_4k = null; + createForm.video_rate_independent = false; + createForm.video_rate_multiplier = 1; + createForm.video_price_480p = null; + createForm.video_price_720p = null; + createForm.video_price_1080p = null; createForm.peak_rate_enabled = false; createForm.peak_start = ""; createForm.peak_end = ""; @@ -4393,6 +4643,9 @@ const handleCreateGroup = async () => { requestData.batch_image_hold_multiplier = normalizeRateMultiplier( requestData.batch_image_hold_multiplier, ); + requestData.video_rate_multiplier = normalizeRateMultiplier( + requestData.video_rate_multiplier, + ); requestData.peak_rate_enabled = createForm.peak_rate_enabled; requestData.peak_start = createForm.peak_start; requestData.peak_end = createForm.peak_end; @@ -4441,6 +4694,11 @@ const handleEdit = async (group: AdminGroup) => { editForm.image_price_1k = group.image_price_1k; editForm.image_price_2k = group.image_price_2k; editForm.image_price_4k = group.image_price_4k; + editForm.video_rate_independent = group.video_rate_independent ?? false; + editForm.video_rate_multiplier = group.video_rate_multiplier ?? 1; + editForm.video_price_480p = group.video_price_480p; + editForm.video_price_720p = group.video_price_720p; + editForm.video_price_1080p = group.video_price_1080p; editForm.peak_rate_enabled = group.peak_rate_enabled ?? false; editForm.peak_start = group.peak_start ?? ""; editForm.peak_end = group.peak_end ?? ""; @@ -4493,6 +4751,11 @@ const closeEditModal = () => { editForm.peak_start = ""; editForm.peak_end = ""; editForm.peak_rate_multiplier = 1.0; + editForm.video_rate_independent = false; + editForm.video_rate_multiplier = 1; + editForm.video_price_480p = null; + editForm.video_price_720p = null; + editForm.video_price_1080p = null; resetMessagesDispatchFormState(editForm); resetModelsListState(editModelsListState); }; @@ -4558,6 +4821,9 @@ const handleUpdateGroup = async () => { payload.batch_image_hold_multiplier = normalizeRateMultiplier( payload.batch_image_hold_multiplier, ); + payload.video_rate_multiplier = normalizeRateMultiplier( + payload.video_rate_multiplier, + ); payload.peak_rate_enabled = editForm.peak_rate_enabled; payload.peak_start = editForm.peak_start; payload.peak_end = editForm.peak_end; diff --git a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts index f8579cf8af..09bc6d5ebb 100644 --- a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts @@ -4,24 +4,29 @@ import { imagePricingPlatforms, imagePricingI18nKey, supportsImagePricingPlatform, + supportsVideoPricingPlatform, + videoPricingI18nKey, } from "../groupsImagePricing"; describe("groups image pricing platform support", () => { - it("includes Grok media groups", () => { + it("includes Grok image groups", () => { expect(supportsImagePricingPlatform("grok")).toBe(true); expect(imagePricingPlatforms.has("grok")).toBe(true); }); + it("enables video pricing controls for Grok only", () => { + expect(supportsVideoPricingPlatform("grok")).toBe(true); + expect(supportsVideoPricingPlatform("openai")).toBe(false); + }); + it("keeps non-media group platforms out of the image pricing controls", () => { expect(supportsImagePricingPlatform("anthropic")).toBe(false); }); - it("uses media pricing copy for Grok groups only", () => { + it("keeps image and video pricing copy separate", () => { expect(imagePricingI18nKey("grok", "title")).toBe( - "admin.groups.mediaPricing.title", - ); - expect(imagePricingI18nKey("openai", "title")).toBe( "admin.groups.imagePricing.title", ); + expect(videoPricingI18nKey("title")).toBe("admin.groups.videoPricing.title"); }); }); diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts index b899fb3637..beff1fc0f5 100644 --- a/frontend/src/views/admin/groupsImagePricing.ts +++ b/frontend/src/views/admin/groupsImagePricing.ts @@ -8,7 +8,11 @@ export const imagePricingPlatforms = new Set([ export const supportsImagePricingPlatform = (platform: string): boolean => imagePricingPlatforms.has(platform); -export const imagePricingI18nKey = (platform: string, key: string): string => - platform === "grok" - ? `admin.groups.mediaPricing.${key}` - : `admin.groups.imagePricing.${key}`; +export const supportsVideoPricingPlatform = (platform: string): boolean => + platform === "grok"; + +export const imagePricingI18nKey = (_platform: string, key: string): string => + `admin.groups.imagePricing.${key}`; + +export const videoPricingI18nKey = (key: string): string => + `admin.groups.videoPricing.${key}`; diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index eb2b124839..7ccc348471 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -389,6 +389,7 @@ const billingModeOptions = computed(() => [ { value: 'token', label: t('admin.usage.billingModeToken') }, { value: 'per_request', label: t('admin.usage.billingModePerRequest') }, { value: 'image', label: t('admin.usage.billingModeImage') }, + { value: 'video', label: t('admin.usage.billingModeVideo') }, ]) const apiKeys = ref([]) From 376e03ded15bd6ce19b71151488b7bb9492c00cd Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Tue, 7 Jul 2026 18:39:46 +0800 Subject: [PATCH 11/29] fix: update Grok media default rate card --- backend/internal/service/billing_service.go | 85 ++++++++++++++++++- .../internal/service/billing_service_test.go | 30 +++++++ .../openai_gateway_record_usage_test.go | 39 +++++++++ frontend/src/views/admin/GroupsView.vue | 49 +++++++---- .../__tests__/groupsImagePricing.spec.ts | 18 ++++ .../src/views/admin/groupsImagePricing.ts | 73 ++++++++++++++++ 6 files changed, 276 insertions(+), 18 deletions(-) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 289cf45354..c5edefd81c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1235,6 +1235,21 @@ type VideoPriceConfig struct { Price1080P *float64 // 1080p 视频价格(nil 表示使用默认值) } +const ( + defaultImageGenerationPrice = 0.134 + + defaultGrokImagineImagePrice1K = 0.02 + defaultGrokImagineImagePrice2K = 0.02 + defaultGrokImagineImageQualityPrice1K = 0.05 + defaultGrokImagineImageQualityPrice2K = 0.07 + + defaultGrokImagineVideoPrice480P = 0.05 + defaultGrokImagineVideoPrice720P = 0.07 + defaultGrokImagineVideo15Price480P = 0.08 + defaultGrokImagineVideo15Price720P = 0.14 + defaultGrokImagineVideo15Price1080P = 0.25 +) + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" @@ -1340,6 +1355,10 @@ func (s *BillingService) getVideoUnitPrice(model string, resolution string, grou // getDefaultImagePrice 获取 LiteLLM 默认图片价格 func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 { + if price, ok := getDefaultGrokImagineImagePrice(model, imageSize); ok { + return price + } + basePrice := 0.0 // 从 PricingService 获取 output_cost_per_image @@ -1352,7 +1371,7 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl // 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview) if basePrice <= 0 { - basePrice = 0.134 + basePrice = defaultImageGenerationPrice } // 2K 尺寸 1.5 倍,4K 尺寸翻倍 @@ -1367,9 +1386,71 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl } func (s *BillingService) getDefaultVideoPrice(model string, resolution string) float64 { - _ = resolution + if price, ok := getDefaultGrokImagineVideoPrice(model, resolution); ok { + return price + } + // The bundled LiteLLM schema does not expose an output video generation price. // Keep the historical model default as the fallback, while letting group-level // video prices override it independently from image prices. return s.getDefaultImagePrice(model, ImageBillingSize2K) } + +func getDefaultGrokImagineImagePrice(model string, imageSize string) (float64, bool) { + model = strings.ToLower(strings.TrimSpace(model)) + switch model { + case "grok-imagine-image-quality": + return getGrokImagineImageTierPrice( + imageSize, + defaultGrokImagineImageQualityPrice1K, + defaultGrokImagineImageQualityPrice2K, + ), true + case "grok-imagine", "grok-imagine-image", "grok-imagine-edit": + return getGrokImagineImageTierPrice( + imageSize, + defaultGrokImagineImagePrice1K, + defaultGrokImagineImagePrice2K, + ), true + default: + return 0, false + } +} + +func getGrokImagineImageTierPrice(imageSize string, price1K float64, price2K float64) float64 { + switch NormalizeImageBillingTierOrDefault(imageSize) { + case ImageBillingSize1K: + return price1K + case ImageBillingSize2K, ImageBillingSize4K: + return price2K + default: + return price2K + } +} + +func getDefaultGrokImagineVideoPrice(model string, resolution string) (float64, bool) { + model = strings.ToLower(strings.TrimSpace(model)) + switch { + case strings.HasPrefix(model, "grok-imagine-video-1.5"): + switch NormalizeVideoBillingResolutionOrDefault(resolution) { + case VideoBillingResolution480P: + return defaultGrokImagineVideo15Price480P, true + case VideoBillingResolution720P: + return defaultGrokImagineVideo15Price720P, true + case VideoBillingResolution1080P: + return defaultGrokImagineVideo15Price1080P, true + default: + return defaultGrokImagineVideo15Price480P, true + } + case strings.HasPrefix(model, "grok-imagine-video"): + switch NormalizeVideoBillingResolutionOrDefault(resolution) { + case VideoBillingResolution480P: + return defaultGrokImagineVideoPrice480P, true + case VideoBillingResolution720P, VideoBillingResolution1080P: + return defaultGrokImagineVideoPrice720P, true + default: + return defaultGrokImagineVideoPrice480P, true + } + default: + return 0, false + } +} diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index bafa4430a0..f4c4213533 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -886,6 +886,36 @@ func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) { require.Equal(t, string(BillingModeVideo), videoCost.BillingMode) } +func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { + svc := newTestBillingService() + + standard1K := svc.CalculateImageCost("grok-imagine-image", "1K", 1, nil, 1.0) + standard2K := svc.CalculateImageCost("grok-imagine-image", "2K", 1, nil, 1.0) + quality1K := svc.CalculateImageCost("grok-imagine-image-quality", "1K", 1, nil, 1.0) + quality2K := svc.CalculateImageCost("grok-imagine-image-quality", "2K", 1, nil, 1.0) + + require.InDelta(t, 0.02, standard1K.TotalCost, 1e-10) + require.InDelta(t, 0.02, standard2K.TotalCost, 1e-10) + require.InDelta(t, 0.05, quality1K.TotalCost, 1e-10) + require.InDelta(t, 0.07, quality2K.TotalCost, 1e-10) +} + +func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) { + svc := newTestBillingService() + + standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, nil, 1.0) + standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, nil, 1.0) + video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, nil, 1.0) + video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, nil, 1.0) + video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, nil, 1.0) + + require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10) + require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10) + require.InDelta(t, 0.08, video15_480P.TotalCost, 1e-10) + require.InDelta(t, 0.14, video15_720P.TotalCost, 1e-10) + require.InDelta(t, 0.25, video15_1080P.TotalCost, 1e-10) +} + func TestIsModelSupported(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index a59852d576..c3622a82e9 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1853,6 +1853,45 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) } +func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing.T) { + groupID := int64(1261) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "video-default-rate-card", + ResponseID: "video-default-rate-card", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 101261, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 201261}, + Account: &Account{ID: 301261, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.InDelta(t, 0.14, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.14, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { groupID := int64(127) channelPrice := 0.201 diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 389d4f9ea3..31771051b1 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -843,7 +843,7 @@ step="0.001" min="0" class="input" - placeholder="0.134" + :placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_1k')" />
@@ -854,7 +854,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_2k')" />
@@ -865,7 +865,7 @@ step="0.001" min="0" class="input" - placeholder="0.268" + :placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_4k')" />
@@ -987,7 +987,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_480p')" />
@@ -998,7 +998,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_720p')" />
@@ -1009,7 +1009,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_1080p')" />
@@ -2322,7 +2322,7 @@ step="0.001" min="0" class="input" - placeholder="0.134" + :placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_1k')" />
@@ -2333,7 +2333,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_2k')" />
@@ -2344,7 +2344,7 @@ step="0.001" min="0" class="input" - placeholder="0.268" + :placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_4k')" />
@@ -2466,7 +2466,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_480p')" />
@@ -2477,7 +2477,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_720p')" />
@@ -2488,7 +2488,7 @@ step="0.001" min="0" class="input" - placeholder="0.201" + :placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_1080p')" />
@@ -3497,6 +3497,10 @@ import { import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates"; import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes"; import { + getDefaultImagePreviewPrice, + getDefaultVideoPreviewPrice, + getImagePricePlaceholder, + getVideoPricePlaceholder, imagePricingI18nKey, supportsImagePricingPlatform, supportsVideoPricingPlatform, @@ -4244,6 +4248,7 @@ type ImagePricingFormState = { }; type VideoPricingFormState = { + platform: GroupPlatform; rate_multiplier: number; video_rate_independent: boolean; video_rate_multiplier: number; @@ -4272,6 +4277,14 @@ const normalizePreviewNumber = (value: number | string | null | undefined, fallb return Number.isFinite(parsed) ? parsed : fallback; }; +const parsePreviewPrice = (value: number | string | null | undefined) => { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + const formatImagePricePreview = (value: number | string | null | undefined) => { if (value === null || value === undefined || value === "") { return t("admin.groups.imagePricing.notConfigured"); @@ -4300,10 +4313,12 @@ const buildImageFinalPricePreview = (form: ImagePricingFormState) => { : normalizePreviewNumber(form.rate_multiplier, 1); const multiplier = imageMultiplier; return imagePricingTiers.map((tier) => { - const basePrice = normalizePreviewNumber(form[tier.key]); + const basePrice = + parsePreviewPrice(form[tier.key]) ?? + getDefaultImagePreviewPrice(form.platform, tier.key); return { label: tier.label, - value: basePrice > 0 + value: basePrice !== null ? formatImagePricePreview(basePrice * multiplier) : t("admin.groups.imagePricing.notConfigured"), }; @@ -4315,10 +4330,12 @@ const buildVideoFinalPricePreview = (form: VideoPricingFormState) => { ? normalizePreviewNumber(form.video_rate_multiplier, 1) : normalizePreviewNumber(form.rate_multiplier, 1); return videoPricingTiers.map((tier) => { - const basePrice = normalizePreviewNumber(form[tier.key]); + const basePrice = + parsePreviewPrice(form[tier.key]) ?? + getDefaultVideoPreviewPrice(form.platform, tier.key); return { label: tier.label, - value: basePrice > 0 + value: basePrice !== null ? formatVideoPricePreview(basePrice * multiplier) : t("admin.groups.videoPricing.notConfigured"), }; diff --git a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts index 09bc6d5ebb..e83e22319d 100644 --- a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { + getDefaultImagePreviewPrice, + getDefaultVideoPreviewPrice, + getImagePricePlaceholder, + getVideoPricePlaceholder, imagePricingPlatforms, imagePricingI18nKey, supportsImagePricingPlatform, @@ -29,4 +33,18 @@ describe("groups image pricing platform support", () => { ); expect(videoPricingI18nKey("title")).toBe("admin.groups.videoPricing.title"); }); + + it("uses Grok media defaults instead of generic image fallback placeholders", () => { + expect(getImagePricePlaceholder("grok", "image_price_1k")).toBe("0.02"); + expect(getImagePricePlaceholder("grok", "image_price_2k")).toBe("0.02"); + expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.08"); + expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.14"); + expect(getVideoPricePlaceholder("grok", "video_price_1080p")).toBe("0.25"); + }); + + it("keeps non-Grok image placeholders on the generic image card", () => { + expect(getImagePricePlaceholder("openai", "image_price_1k")).toBe("0.134"); + expect(getDefaultImagePreviewPrice("openai", "image_price_2k")).toBe(0.201); + expect(getDefaultVideoPreviewPrice("openai", "video_price_480p")).toBeNull(); + }); }); diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts index beff1fc0f5..d88c479995 100644 --- a/frontend/src/views/admin/groupsImagePricing.ts +++ b/frontend/src/views/admin/groupsImagePricing.ts @@ -16,3 +16,76 @@ export const imagePricingI18nKey = (_platform: string, key: string): string => export const videoPricingI18nKey = (key: string): string => `admin.groups.videoPricing.${key}`; + +type ImagePricingTierKey = "image_price_1k" | "image_price_2k" | "image_price_4k"; +type VideoPricingTierKey = + | "video_price_480p" + | "video_price_720p" + | "video_price_1080p"; + +const defaultImagePricePlaceholders: Record< + string, + Record +> = { + default: { + image_price_1k: "0.134", + image_price_2k: "0.201", + image_price_4k: "0.268", + }, + grok: { + image_price_1k: "0.02", + image_price_2k: "0.02", + image_price_4k: "0.02", + }, +}; + +const defaultVideoPricePlaceholders: Record< + string, + Record +> = { + grok: { + video_price_480p: "0.08", + video_price_720p: "0.14", + video_price_1080p: "0.25", + }, +}; + +export const getImagePricePlaceholder = ( + platform: string, + tier: ImagePricingTierKey, +): string => { + const card = defaultImagePricePlaceholders[platform] ?? defaultImagePricePlaceholders.default; + return card[tier]; +}; + +export const getVideoPricePlaceholder = ( + platform: string, + tier: VideoPricingTierKey, +): string => { + const card = defaultVideoPricePlaceholders[platform]; + return card?.[tier] ?? ""; +}; + +export const getDefaultImagePreviewPrice = ( + platform: string, + tier: ImagePricingTierKey, +): number | null => { + const placeholder = getImagePricePlaceholder(platform, tier); + if (placeholder === "") { + return null; + } + const value = Number(placeholder); + return Number.isFinite(value) ? value : null; +}; + +export const getDefaultVideoPreviewPrice = ( + platform: string, + tier: VideoPricingTierKey, +): number | null => { + const placeholder = getVideoPricePlaceholder(platform, tier); + if (placeholder === "") { + return null; + } + const value = Number(placeholder); + return Number.isFinite(value) ? value : null; +}; From 889b65745173f2a335c2556e31a1b7d5ed05bfd7 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Tue, 7 Jul 2026 18:52:43 +0800 Subject: [PATCH 12/29] test: accept casted video billing constraint --- .../internal/repository/migrations_schema_integration_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 4e30291b82..3235c7404a 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -66,7 +66,8 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { "usage_logs", "usage_logs_image_billing_size_check", "image_count", - "billing_mode = 'video'", + "billing_mode", + "'video'", "image_size IS NOT NULL", "'1K'", "'2K'", From 3b206cc63969e48de35dfc2ea411101a97983300 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Tue, 7 Jul 2026 21:24:54 +0800 Subject: [PATCH 13/29] test: preserve grok video resolution forwarding --- backend/internal/service/openai_gateway_grok_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 7f348f217a..c7d1cd552c 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -365,7 +365,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) - require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves"}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p"}`, string(upstream.lastBody)) require.Equal(t, "video-request-123", result.ResponseID) require.Equal(t, "grok-imagine-video", result.BillingModel) require.Equal(t, 3, result.Usage.InputTokens) From e0d149d511adc8e5d6887d35c1bc2cd77033446e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 8 Jul 2026 15:26:54 +0800 Subject: [PATCH 14/29] feat(api-key): show last used IP --- .../dto/api_key_mapper_last_used_test.go | 5 ++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 1 + backend/internal/repository/api_key_repo.go | 88 +++++++++++++++++++ .../api_key_repo_last_used_unit_test.go | 82 ++++++++++++++++- backend/internal/server/api_contract_test.go | 2 + backend/internal/service/api_key.go | 1 + frontend/src/i18n/locales/en/dashboard.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 1 + frontend/src/types/index.ts | 1 + frontend/src/views/user/KeysView.vue | 30 ++++++- .../src/views/user/__tests__/KeysView.spec.ts | 38 +++++++- 12 files changed, 245 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/dto/api_key_mapper_last_used_test.go b/backend/internal/handler/dto/api_key_mapper_last_used_test.go index d63baba91a..a9ccf94524 100644 --- a/backend/internal/handler/dto/api_key_mapper_last_used_test.go +++ b/backend/internal/handler/dto/api_key_mapper_last_used_test.go @@ -10,6 +10,7 @@ import ( func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) { lastUsed := time.Now().UTC().Truncate(time.Second) + lastUsedIP := "203.0.113.10" src := &service.APIKey{ ID: 1, UserID: 2, @@ -17,6 +18,7 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) { Name: "Mapper", Status: service.StatusActive, LastUsedAt: &lastUsed, + LastUsedIP: &lastUsedIP, CurrentConcurrency: 3, } @@ -24,6 +26,8 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) { require.NotNil(t, out) require.NotNil(t, out.LastUsedAt) require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second) + require.NotNil(t, out.LastUsedIP) + require.Equal(t, lastUsedIP, *out.LastUsedIP) require.Equal(t, 3, out.CurrentConcurrency) } @@ -39,4 +43,5 @@ func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) { out := APIKeyFromService(src) require.NotNil(t, out) require.Nil(t, out.LastUsedAt) + require.Nil(t, out.LastUsedIP) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 03e4c97309..00e1ea829c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -89,6 +89,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey { IPWhitelist: k.IPWhitelist, IPBlacklist: k.IPBlacklist, LastUsedAt: k.LastUsedAt, + LastUsedIP: k.LastUsedIP, Quota: k.Quota, QuotaUsed: k.QuotaUsed, ExpiresAt: k.ExpiresAt, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 286d2d5459..3aa8890d62 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -59,6 +59,7 @@ type APIKey struct { IPWhitelist []string `json:"ip_whitelist"` IPBlacklist []string `json:"ip_blacklist"` LastUsedAt *time.Time `json:"last_used_at"` + LastUsedIP *string `json:"last_used_ip"` Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited) QuotaUsed float64 `json:"quota_used"` // Used quota amount in USD ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 877fc90353..d69a8bf0c9 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -14,9 +14,11 @@ import ( "github.com/Wei-Shaw/sub2api/ent/schema/mixins" "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" ) @@ -431,10 +433,96 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param for i := range keys { outKeys = append(outKeys, *apiKeyEntityToService(keys[i])) } + if err := r.attachLastUsedIPs(ctx, outKeys); err != nil { + return nil, nil, err + } return outKeys, paginationResultFromTotal(int64(total), params), nil } +func (r *apiKeyRepository) attachLastUsedIPs(ctx context.Context, keys []service.APIKey) error { + if len(keys) == 0 || r.sql == nil { + return nil + } + + apiKeyIDs := make([]int64, 0, len(keys)) + for i := range keys { + apiKeyIDs = append(apiKeyIDs, keys[i].ID) + } + + lastUsedIPs, err := r.latestUsageLogIPs(ctx, apiKeyIDs) + if err != nil { + return err + } + for i := range keys { + if ip, ok := lastUsedIPs[keys[i].ID]; ok { + keys[i].LastUsedIP = &ip + } + } + return nil +} + +func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) { + if len(apiKeyIDs) == 0 || r.sql == nil { + return map[int64]string{}, nil + } + + query, args := latestUsageLogIPsQuery(apiKeyIDs, r.client.Driver().Dialect()) + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[int64]string, len(apiKeyIDs)) + for rows.Next() { + var apiKeyID int64 + var ipAddress string + if err := rows.Scan(&apiKeyID, &ipAddress); err != nil { + return nil, err + } + out[apiKeyID] = ipAddress + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func latestUsageLogIPsQuery(apiKeyIDs []int64, dialectName string) (string, []any) { + if dialectName == dialect.Postgres { + return ` + SELECT api_key_id, ip_address + FROM ( + SELECT api_key_id, ip_address, + ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn + FROM usage_logs + WHERE api_key_id = ANY($1::bigint[]) + AND ip_address IS NOT NULL + AND ip_address <> '' + ) ranked + WHERE rn = 1`, []any{pq.Array(apiKeyIDs)} + } + + placeholders := make([]string, len(apiKeyIDs)) + args := make([]any, len(apiKeyIDs)) + for i, id := range apiKeyIDs { + placeholders[i] = "?" + args[i] = id + } + return fmt.Sprintf(` + SELECT api_key_id, ip_address + FROM ( + SELECT api_key_id, ip_address, + ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn + FROM usage_logs + WHERE api_key_id IN (%s) + AND ip_address IS NOT NULL + AND ip_address <> '' + ) ranked + WHERE rn = 1`, strings.Join(placeholders, ", ")), args +} + func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { if len(apiKeyIDs) == 0 { return []int64{}, nil diff --git a/backend/internal/repository/api_key_repo_last_used_unit_test.go b/backend/internal/repository/api_key_repo_last_used_unit_test.go index 7c6e2850e8..839eda7f75 100644 --- a/backend/internal/repository/api_key_repo_last_used_unit_test.go +++ b/backend/internal/repository/api_key_repo_last_used_unit_test.go @@ -8,6 +8,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/enttest" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" @@ -30,7 +31,7 @@ func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) { client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) t.Cleanup(func() { _ = client.Close() }) - return &apiKeyRepository{client: client}, client + return &apiKeyRepository{client: client, sql: db}, client } func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User { @@ -45,6 +46,85 @@ func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.C return userEntityToService(u) } +func mustCreateAPIKeyRepoAccount(t *testing.T, ctx context.Context, client *dbent.Client, name string) int64 { + t.Helper() + a, err := client.Account.Create(). + SetName(name). + SetPlatform(service.PlatformOpenAI). + SetType(service.AccountTypeAPIKey). + SetStatus(service.StatusActive). + SetCredentials(map[string]any{"api_key": "sk-test"}). + Save(ctx) + require.NoError(t, err) + return a.ID +} + +func mustCreateAPIKeyRepoUsageLog(t *testing.T, ctx context.Context, client *dbent.Client, userID, apiKeyID, accountID int64, requestID string, createdAt time.Time, ipAddress *string) { + t.Helper() + builder := client.UsageLog.Create(). + SetUserID(userID). + SetAPIKeyID(apiKeyID). + SetAccountID(accountID). + SetRequestID(requestID). + SetModel("gpt-5"). + SetCreatedAt(createdAt) + if ipAddress != nil { + builder.SetIPAddress(*ipAddress) + } + _, err := builder.Save(ctx) + require.NoError(t, err) +} + +func TestAPIKeyRepositoryListByUserIDAttachesLastUsedIP(t *testing.T) { + repo, client := newAPIKeyRepoSQLite(t) + ctx := context.Background() + user := mustCreateAPIKeyRepoUser(t, ctx, client, "list-last-used-ip@test.com") + accountID := mustCreateAPIKeyRepoAccount(t, ctx, client, "acc-list-last-used-ip") + + withLogs := &service.APIKey{ + UserID: user.ID, + Key: "sk-list-last-used-ip-logs", + Name: "With Logs", + Status: service.StatusActive, + } + emptyOnly := &service.APIKey{ + UserID: user.ID, + Key: "sk-list-last-used-ip-empty", + Name: "Empty Only", + Status: service.StatusActive, + } + noLogs := &service.APIKey{ + UserID: user.ID, + Key: "sk-list-last-used-ip-none", + Name: "No Logs", + Status: service.StatusActive, + } + require.NoError(t, repo.Create(ctx, withLogs)) + require.NoError(t, repo.Create(ctx, emptyOnly)) + require.NoError(t, repo.Create(ctx, noLogs)) + + olderIP := "198.51.100.10" + newerEmptyIP := "" + newestIP := "203.0.113.20" + base := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second) + mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-older", base, &olderIP) + mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-empty", base.Add(time.Hour), &newerEmptyIP) + mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-newest", base.Add(2*time.Hour), &newestIP) + mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, emptyOnly.ID, accountID, "req-empty-ip", base.Add(3*time.Hour), &newerEmptyIP) + + keys, _, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}, service.APIKeyListFilters{}) + require.NoError(t, err) + + byID := make(map[int64]service.APIKey, len(keys)) + for _, key := range keys { + byID[key.ID] = key + } + require.NotNil(t, byID[withLogs.ID].LastUsedIP) + require.Equal(t, newestIP, *byID[withLogs.ID].LastUsedIP) + require.Nil(t, byID[emptyOnly.ID].LastUsedIP) + require.Nil(t, byID[noLogs.ID].LastUsedIP) +} + func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) { repo, client := newAPIKeyRepoSQLite(t) ctx := context.Background() diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 278e654834..d15ccc9c2e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -234,6 +234,7 @@ func TestAPIContracts(t *testing.T) { "ip_whitelist": null, "ip_blacklist": null, "last_used_at": null, + "last_used_ip": null, "current_concurrency": 0, "quota": 0, "quota_used": 0, @@ -284,6 +285,7 @@ func TestAPIContracts(t *testing.T) { "ip_whitelist": null, "ip_blacklist": null, "last_used_at": null, + "last_used_ip": null, "current_concurrency": 0, "quota": 0, "quota_used": 0, diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go index dfc3ec1c5a..b92a848184 100644 --- a/backend/internal/service/api_key.go +++ b/backend/internal/service/api_key.go @@ -40,6 +40,7 @@ type APIKey struct { CompiledIPWhitelist *ip.CompiledIPRules `json:"-"` CompiledIPBlacklist *ip.CompiledIPRules `json:"-"` LastUsedAt *time.Time + LastUsedIP *string CreatedAt time.Time UpdatedAt time.Time User *User diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index a9c7c750c8..046179cd94 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -124,6 +124,7 @@ export default { total: 'Last 30d', quota: 'Quota', lastUsedAt: 'Last Used', + lastUsedIP: 'Last Used IP', useKey: 'Use Key', useKeyModal: { title: 'Use API Key', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 2ce3eda5bb..104e111f3a 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -124,6 +124,7 @@ export default { total: '近30天', quota: '额度', lastUsedAt: '上次使用时间', + lastUsedIP: '最近使用 IP', useKey: '使用密钥', useKeyModal: { title: '使用 API 密钥', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8465332287..78779c75fc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -585,6 +585,7 @@ export interface ApiKey { ip_whitelist: string[] ip_blacklist: string[] last_used_at: string | null + last_used_ip: string | null quota: number // Quota limit in USD (0 = unlimited) quota_used: number // Used quota amount in USD expires_at: string | null // Expiration time (null = never expires) diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 087e0e4175..f8ecf7d076 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -354,6 +354,13 @@ - + + @@ -1174,15 +1181,19 @@ const allColumns = computed(() => [ { key: 'expires_at', label: t('keys.expiresAt'), sortable: true }, { key: 'status', label: t('common.status'), sortable: true }, { key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true }, + { key: 'last_used_ip', label: t('keys.lastUsedIP'), sortable: false }, { key: 'created_at', label: t('keys.created'), sortable: true }, { key: 'actions', label: t('common.actions'), sortable: false } ]) const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions']) -const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at'] +const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at', 'last_used_ip'] const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns' const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version' -const COLUMN_SETTINGS_VERSION = 1 +const COLUMN_SETTINGS_VERSION = 2 +const VERSION_NEW_HIDDEN_COLUMNS: Record = { + 2: ['last_used_ip'] +} const toggleableColumns = computed(() => allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key)) @@ -1213,10 +1224,23 @@ const loadSavedColumns = () => { !ALWAYS_VISIBLE_COLUMNS.has(key) ) .forEach((key) => hiddenColumns.add(key)) + const storedVersion = Number(localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? '1') + if (storedVersion < COLUMN_SETTINGS_VERSION) { + for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) { + for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) { + if (validColumnKeys.has(key) && !ALWAYS_VISIBLE_COLUMNS.has(key)) { + hiddenColumns.add(key) + } + } + } + saveColumnsToStorage() + } else { + localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION)) + } } else { DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key)) + localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION)) } - localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION)) } catch (error) { console.error('Failed to load API key table columns:', error) DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key)) diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts index 2417cd9e5c..ec4086181b 100644 --- a/frontend/src/views/user/__tests__/KeysView.spec.ts +++ b/frontend/src/views/user/__tests__/KeysView.spec.ts @@ -44,6 +44,7 @@ const messages: Record = { 'keys.group': 'Group', 'keys.currentConcurrency': 'Current Concurrency', 'keys.lastUsedAt': 'Last Used', + 'keys.lastUsedIP': 'Last Used IP', 'keys.rateLimitColumn': 'Rate Limit', 'keys.searchPlaceholder': 'Search name or key...', 'keys.status.active': 'Active', @@ -113,6 +114,7 @@ const createApiKey = (): ApiKey => ({ ip_whitelist: [], ip_blacklist: [], last_used_at: null, + last_used_ip: null, quota: 0, quota_used: 0, expires_at: null, @@ -159,6 +161,12 @@ const DataTableStub = {
+
+ +
@@ -265,6 +273,7 @@ describe('user KeysView column settings', () => { ]) expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit') expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at') + expect(visibleColumnKeys(wrapper)).not.toContain('last_used_ip') }) it('shows a hidden column when toggled and persists the preference', async () => { @@ -275,8 +284,28 @@ describe('user KeysView column settings', () => { await nextTick() expect(visibleColumnKeys(wrapper)).toContain('rate_limit') - expect(localStorage.getItem('api-key-hidden-columns')).toBe(JSON.stringify(['last_used_at'])) - expect(localStorage.getItem('api-key-column-settings-version')).toBe('1') + expect(localStorage.getItem('api-key-hidden-columns')).toBe( + JSON.stringify(['last_used_at', 'last_used_ip']) + ) + expect(localStorage.getItem('api-key-column-settings-version')).toBe('2') + }) + + it('shows the last used IP column when toggled', async () => { + listKeys.mockResolvedValueOnce({ + items: [{ ...createApiKey(), last_used_ip: '203.0.113.10' }], + total: 1, + page: 1, + page_size: 20, + pages: 1, + }) + const wrapper = await mountView() + + await wrapper.get('button[title="Column Settings"]').trigger('click') + await getButtonByText(wrapper, 'Last Used IP').trigger('click') + await nextTick() + + expect(visibleColumnKeys(wrapper)).toContain('last_used_ip') + expect(wrapper.get('[data-test="last-used-ip"]').text()).toBe('203.0.113.10') }) it('restores column preferences from localStorage on mount', async () => { @@ -296,6 +325,10 @@ describe('user KeysView column settings', () => { 'last_used_at', 'actions', ]) + expect(localStorage.getItem('api-key-hidden-columns')).toBe( + JSON.stringify(['group', 'created_at', 'last_used_ip']) + ) + expect(localStorage.getItem('api-key-column-settings-version')).toBe('2') }) it('does not include always-visible columns in the toggleable menu', async () => { @@ -308,6 +341,7 @@ describe('user KeysView column settings', () => { expect(columnMenuText).toContain('API Key') expect(columnMenuText).toContain('Current Concurrency') expect(columnMenuText).toContain('Rate Limit') + expect(columnMenuText).toContain('Last Used IP') expect(columnMenuText).not.toContain('Name') expect(columnMenuText).not.toContain('Actions') }) From 7a11b39d6d2b008e0ff894f0549ad7ac6cee938f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 8 Jul 2026 15:36:12 +0800 Subject: [PATCH 15/29] fix(api-key): check usage log rows close --- backend/internal/repository/api_key_repo.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index d69a8bf0c9..638eec52f1 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -462,7 +462,7 @@ func (r *apiKeyRepository) attachLastUsedIPs(ctx context.Context, keys []service return nil } -func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) { +func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (result map[int64]string, err error) { if len(apiKeyIDs) == 0 || r.sql == nil { return map[int64]string{}, nil } @@ -472,7 +472,11 @@ func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []in if err != nil { return nil, err } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() out := make(map[int64]string, len(apiKeyIDs)) for rows.Next() { From 4a30397623b096591a67040b502950c167cd02ee Mon Sep 17 00:00:00 2001 From: Wesley Liddick Date: Wed, 8 Jul 2026 18:37:33 +0000 Subject: [PATCH 16/29] fix: prevent channel pricing overrides from mutating shared fallback pricing --- backend/internal/service/billing_service.go | 3 +++ backend/internal/service/model_pricing_resolver.go | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 4c265aed3c..89755c8922 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -771,6 +771,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing if channelPricing == nil { return pricing, nil } + // 防止修改 fallbackPrices 中的共享指针 + cloned := *pricing + pricing = &cloned if channelPricing.InputPrice != nil { pricing.InputPricePerToken = *channelPricing.InputPrice pricing.InputPricePerTokenPriority = *channelPricing.InputPrice diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 029cb80259..0cc7a0ac47 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -150,6 +150,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin // 区间不匹配时回退到 BasePricing,也需要覆盖图片价格 if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} + } else { + // 防止修改 fallbackPrices 中的共享指针 + cloned := *resolved.BasePricing + resolved.BasePricing = &cloned } if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice @@ -163,6 +167,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin // 否则用 flat 字段覆盖 BasePricing if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} + } else { + // 防止修改 fallbackPrices 中的共享指针 + cloned := *resolved.BasePricing + resolved.BasePricing = &cloned } if chPricing.InputPrice != nil { From 88581912ba8fad4e48ba9303f811bbf3fd9a248f Mon Sep 17 00:00:00 2001 From: Wesley Liddick Date: Wed, 8 Jul 2026 19:23:38 +0000 Subject: [PATCH 17/29] test: add regression tests for fallback pricing pollution --- .../service/model_pricing_resolver_test.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 3b49b15556..d4169e5f0e 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -727,3 +727,63 @@ func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) require.True(t, pricing.ImageOutputPriceExplicit) require.Equal(t, 0.0, pricing.ImageOutputPricePerToken) } + +// =========================================================================== +// 10. Regression: channel overrides must not pollute fallbackPrices +// =========================================================================== + +// TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices verifies that the +// flat-override path in applyTokenOverrides clones the BasePricing struct +// before mutation, so the shared fallbackPrices map entry is not written through. +func TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices(t *testing.T) { + r := newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), // base is 3e-6 + OutputPrice: testPtrFloat64(50e-6), // base is 15e-6 + }}) + + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) + + // Resolved pricing should reflect the channel override + require.NotNil(t, resolved) + require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12) + require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) + + // Global fallbackPrices must NOT be polluted + fp := r.billingService.fallbackPrices["claude-sonnet-4"] + require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted") + require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted") + require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted") +} + +// TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices verifies that +// the interval-override path also clones before mutation. +func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T) { + r := newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + Intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(2e-6), OutputPrice: testPtrFloat64(8e-6)}, + }, + }}) + + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) + + require.NotNil(t, resolved) + require.True(t, resolved.BasePricing.ImageOutputPriceExplicit) + + // Global fallbackPrices must NOT be polluted + fp := r.billingService.fallbackPrices["claude-sonnet-4"] + require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted") + require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted") + require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted") +} From 29a5fcd25e997d1bcdd29ff2c30d33fc9265e8a3 Mon Sep 17 00:00:00 2001 From: superman2003 <171322926+superman2003@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:37:43 +0800 Subject: [PATCH 18/29] =?UTF-8?q?fix(gateway,frontend):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=89=B4=E6=9D=83=E7=BB=95=E8=BF=87=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=94=AF=E4=BB=98/=E4=BC=9A=E8=AF=9D=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - Gemini /v1beta 鉴权中间件补齐主中间件的授权校验: API Key 的 IP 白/黑名单、 专属分组授权、运行时过期/配额二次检查, 修复经 Gemini 端点绕过 IP ACL、 越权访问专属分组、以及状态未刷新时的配额/有效期绕过窗口。 - 粘性会话等待计划分支改走 newSelectionResult 以 hydrate 账号凭证, 修复调度 快照中账号凭证被剥离导致等待路径转发鉴权失败。 - SSE 流式转发客户端断开时不再 break 跳过当前事件 usage 合并, 修复少计费。 - Forward 对 nil gin.Context 的防御补齐; 上游错误体读取失败时记录日志避免静默。 前端: - logout 将本地会话清理移入 finally, 服务端吊销失败也保证本地登出。 - Stripe 弹窗轮询改用正确的 auth_token 键并加防重入; 收到 INIT 后清除兜底 超时定时器, onUnmounted 清理 message 监听器。 - token 刷新请求补充 30s 超时, 避免挂起导致请求队列与 loading 永久卡死。 - 路由守卫在公共设置未加载时先 await fetchPublicSettings, 避免 payment/ risk_control 被误判为未启用而错误拦截。 - 支付状态轮询回调补充防重入与终态守卫。 Co-authored-by: Cursor --- .../server/middleware/api_key_auth_google.go | 52 ++++++++++++++++++- backend/internal/service/gateway_forward.go | 12 +++-- .../internal/service/gateway_scheduling.go | 18 +++---- .../service/gateway_upstream_response.go | 19 +++++-- frontend/src/api/client.ts | 4 +- .../components/payment/PaymentStatusPanel.vue | 39 +++++++++----- frontend/src/router/index.ts | 11 ++++ frontend/src/stores/auth.ts | 15 ++++-- frontend/src/views/user/PaymentQRCodeView.vue | 26 +++++++--- frontend/src/views/user/StripePopupView.vue | 43 ++++++++++++--- 10 files changed, 185 insertions(+), 54 deletions(-) diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index 5c5ee147a4..c75d5b99f2 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -2,10 +2,12 @@ package middleware import ( "errors" + "fmt" "strings" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -46,10 +48,32 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs // user/group/platform。 SetOpsFallbackAPIKey(c, apiKey) - if !apiKey.IsActive() { + // disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段, + // 与主中间件 api_key_auth.go 保持一致)。 + if !apiKey.IsActive() && + apiKey.Status != service.StatusAPIKeyExpired && + apiKey.Status != service.StatusAPIKeyQuotaExhausted { abortWithGoogleError(c, 401, "API key is disabled") return } + + // 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。 + if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { + clientIP := ip.GetTrustedClientIP(c) + if cfg.TrustForwardedIPForAPIKeyACL() { + clientIP = ip.GetClientIP(c) + } + allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist) + if !allowed { + if clientIP == "" { + clientIP = "unknown" + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction) + abortWithGoogleError(c, 403, fmt.Sprintf("Access denied. Your IP is %s", clientIP)) + return + } + } + if apiKey.User == nil { abortWithGoogleError(c, 401, "User associated with API key not found") return @@ -63,6 +87,12 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs abortWithGoogleError(c, 403, message) return } + // 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。 + if !validateAPIKeyGroupAllowed(apiKey) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable) + abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用") + return + } // 简易模式:跳过余额和订阅检查 if cfg.RunMode == config.RunModeSimple { @@ -78,6 +108,26 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs return } + // Key 状态检查(状态字段可能因后台异步刷新而滞后,故显式拦截)。 + switch apiKey.Status { + case service.StatusAPIKeyQuotaExhausted: + abortWithGoogleError(c, 429, "API key 额度已用完") + return + case service.StatusAPIKeyExpired: + abortWithGoogleError(c, 403, "API key 已过期") + return + } + + // 运行时过期/配额检查(即使状态是 active,也要检查时间和用量,与主中间件一致)。 + if apiKey.IsExpired() { + abortWithGoogleError(c, 403, "API key 已过期") + return + } + if apiKey.IsQuotaExhausted() { + abortWithGoogleError(c, 429, "API key 额度已用完") + return + } + isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() if isSubscriptionType && subscriptionService != nil { subscription, err := subscriptionService.GetActiveSubscription( diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index e690aaab22..1418989780 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -165,7 +165,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 最低缓存门槛,导致系统级缓存失效)。 // // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 - isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + var clientUserAgent string + if c != nil { + clientUserAgent = c.GetHeader("User-Agent") + } + isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { @@ -190,7 +194,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} - if s.identityService != nil { + if s.identityService != nil && c != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { // metadata 透传开启时跳过 metadata 注入 @@ -220,7 +224,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { return nil, err } - c.Set(toolNameRewriteKey, rw) + if c != nil { + c.Set(toolNameRewriteKey, rw) + } } else { if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { return nil, err diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 9a640d9fe8..d72303cd92 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -360,15 +360,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro stickyCacheMissReason = "session_limit" // 会话限制已满,继续到负载感知选择 } else { - return &AccountSelectionResult{ - Account: stickyAccount, - WaitPlan: &AccountWaitPlan{ - AccountID: stickyAccountID, - MaxConcurrency: stickyAccount.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + // 必须走 newSelectionResult 以 hydrate 账号凭证: + // 调度快照中的账号是精简版(OAuth token 等被剥离), + // 直接返回会导致后续转发缺少凭证而鉴权失败。 + return s.newSelectionResult(ctx, stickyAccount, false, nil, &AccountWaitPlan{ + AccountID: stickyAccountID, + MaxConcurrency: stickyAccount.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } else { stickyCacheMissReason = "wait_queue_full" diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 7a66e12a9a..03f757dabc 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -356,7 +356,13 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { - body, _ := s.readUpstreamErrorBody(resp) + body, readErr := s.readUpstreamErrorBody(resp) + if readErr != nil { + // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, + // 避免静默吞掉导致误判。 + logger.LegacyPrintf("service.gateway", "[Forward] Failed to fully read upstream error body: Account=%d(%s) Status=%d err=%v", + account.ID, account.Name, resp.StatusCode, readErr) + } // 调试日志:打印上游错误响应 logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s", @@ -1023,11 +1029,14 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http if _, werr := fmt.Fprint(w, string(restored)); werr != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - break + // 不 break:客户端断开后仍需继续合并本事件及后续事件的 usage, + // 否则会漏计当前事件携带的 usage 导致少计费。后续写入由 + // clientDisconnected 守卫跳过。 + } else { + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() } - flusher.Flush() - lastDataAt = time.Now() - resetKeepaliveTimer() } if data != "" { if firstTokenMs == nil && data != "[DONE]" { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c1b9abcd5e..5df969f188 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -205,7 +205,9 @@ apiClient.interceptors.response.use( const refreshResponse = await axios.post( `${getAPIBaseURL()}/auth/refresh`, { refresh_token: refreshToken }, - { headers: { 'Content-Type': 'application/json' } } + // 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing + // 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。 + { headers: { 'Content-Type': 'application/json' }, timeout: 30000 } ) const refreshData = refreshResponse.data as ApiResponse<{ diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index c7232fd640..75c4164777 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -275,22 +275,33 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise { } + // 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署 + // 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control + // 误判为“未启用”而错误拦截,故这里先确保设置加载完成。 + if ((to.meta.requiresPayment || to.meta.requiresRiskControl) && !appStore.publicSettingsLoaded) { + try { + await appStore.fetchPublicSettings() + } catch (error) { + console.warn('Failed to load public settings in route guard', error) + } + } + // Check payment requirement (internal payment system only) if (to.meta.requiresPayment) { const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 4b712692b3..1346262ee8 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -397,11 +397,16 @@ export const useAuthStore = defineStore('auth', () => { * Clears all authentication state and persisted data */ async function logout(): Promise { - // Call API logout (revokes refresh token on server) - await authAPI.logout() - - // Clear state - clearAuth() + try { + // Call API logout (revokes refresh token on server) + await authAPI.logout() + } catch (err) { + // 服务端吊销失败(网络/5xx/超时)不应阻止本地登出,否则用户点了退出仍处于登录态。 + console.warn('Logout API call failed, clearing local session anyway', err) + } finally { + // Always clear local state (tokens, user data, refresh timers) + clearAuth() + } } /** diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index 5df67d0fe9..0569bf6907 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -132,16 +132,26 @@ async function renderQR() { } } +let pollInFlight = false async function pollStatus() { if (!orderId.value) return - const order = await paymentStore.pollOrderStatus(orderId.value) - if (!order) return - if (order.status === 'COMPLETED' || order.status === 'PAID') { - cleanup() - router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } }) - } else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') { - cleanup() - expired.value = true + // 防重入:接口响应慢于 3 秒轮询间隔时避免并发重叠请求与重复跳转。 + if (pollInFlight) return + pollInFlight = true + try { + const order = await paymentStore.pollOrderStatus(orderId.value) + if (!order) return + // 定时器已被 cleanup 清除时不再执行终态跳转(响应可能在 cleanup 后才回来)。 + if (!pollTimer) return + if (order.status === 'COMPLETED' || order.status === 'PAID') { + cleanup() + router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } }) + } else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') { + cleanup() + expired.value = true + } + } finally { + pollInFlight = false } } diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 063101cccb..0e852e5206 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -84,23 +84,38 @@ const success = ref(false) const hint = ref(t('payment.stripePopup.redirecting')) let pollTimer: ReturnType | null = null +let initTimeoutTimer: ReturnType | null = null +let messageHandler: ((event: MessageEvent) => void) | null = null function closeWindow() { window.close() } +function clearInitTimeout() { + if (initTimeoutTimer) { + clearTimeout(initTimeoutTimer) + initTimeoutTimer = null + } +} + onMounted(() => { - const handler = (event: MessageEvent) => { + messageHandler = (event: MessageEvent) => { if (event.origin !== window.location.origin) return if (event.data?.type !== 'STRIPE_POPUP_INIT') return - window.removeEventListener('message', handler) + // INIT 已到达,取消兜底超时,避免长时间的扫码支付被误判为超时。 + clearInitTimeout() + if (messageHandler) { + window.removeEventListener('message', messageHandler) + messageHandler = null + } initStripe(event.data.clientSecret, event.data.publishableKey) } - window.addEventListener('message', handler) + window.addEventListener('message', messageHandler) if (window.opener) { window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin) } - setTimeout(() => { + // 仅兜底“父窗口始终未发 STRIPE_POPUP_INIT”的场景。 + initTimeoutTimer = setTimeout(() => { if (!error.value && !success.value) { error.value = t('payment.stripePopup.timeout') } @@ -108,7 +123,12 @@ onMounted(() => { }) onUnmounted(() => { - if (pollTimer) clearInterval(pollTimer) + if (pollTimer) { clearInterval(pollTimer); pollTimer = null } + clearInitTimeout() + if (messageHandler) { + window.removeEventListener('message', messageHandler) + messageHandler = null + } }) async function initStripe(clientSecret: string, publishableKey: string) { @@ -149,10 +169,15 @@ async function initStripe(clientSecret: string, publishableKey: string) { } function startPolling() { + let inFlight = false pollTimer = setInterval(async () => { + // 防重入:接口响应慢于轮询间隔时避免并发重叠请求。 + if (inFlight) return + inFlight = true try { - const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1] - || localStorage.getItem('token') || '' + // access token 存储在 localStorage 的 'auth_token' 键下(见 api/client.ts), + // 之前误读 'token' 导致轮询请求不带认证、永远 401,支付成功无法被检测到。 + const token = localStorage.getItem('auth_token') || '' const res = await fetch(buildApiUrl(`/payment/orders/${orderId}`), { headers: token ? { Authorization: 'Bearer ' + token } : {}, credentials: 'include', @@ -165,7 +190,9 @@ function startPolling() { success.value = true setTimeout(closeWindow, 2000) } - } catch { /* ignore */ } + } catch { /* ignore */ } finally { + inFlight = false + } }, 3000) } From 4e5be8f75e368fbe34bc5e901ebb24412d5588dc Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 9 Jul 2026 09:45:47 +0800 Subject: [PATCH 19/29] fix(crs-sync): raise frontend sync timeout to 180s --- frontend/src/api/admin/accounts.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index d0ec12c364..2f9625b430 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -560,7 +560,9 @@ export async function syncFromCrs(params: { action: string error?: string }> - }>('/admin/accounts/sync/crs', params) + }>('/admin/accounts/sync/crs', params, { + timeout: 180000 // 180s timeout: sync refreshes each existing account's OAuth token serially + }) return data } From bfb827b879f3b41dac899b6c044f9510b8abc8fd Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 10:03:49 +0800 Subject: [PATCH 20/29] =?UTF-8?q?fix(security):=20HTML-escape=20site=5Fnam?= =?UTF-8?q?e=20=E5=B9=B6=E5=AF=B9=20doc=5Furl=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=BA=94=E7=94=A8=20sanitizeUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #3839 (第 8、12 点) --- .../handler/admin/setting_handler_email.go | 3 +- .../service/email_html_escape_test.go | 67 +++++++++++++++++++ backend/internal/service/email_service.go | 5 +- backend/internal/web/embed_on.go | 3 +- backend/internal/web/embed_test.go | 19 ++++++ frontend/src/components/layout/AppHeader.vue | 3 +- .../__tests__/docUrlSanitization.spec.ts | 36 ++++++++++ frontend/src/views/HomeView.vue | 3 +- frontend/src/views/KeyUsageView.vue | 3 +- 9 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/email_html_escape_test.go create mode 100644 frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts diff --git a/backend/internal/handler/admin/setting_handler_email.go b/backend/internal/handler/admin/setting_handler_email.go index 9ff0529a5b..68d76de6cc 100644 --- a/backend/internal/handler/admin/setting_handler_email.go +++ b/backend/internal/handler/admin/setting_handler_email.go @@ -1,6 +1,7 @@ package admin import ( + "html" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" @@ -163,7 +164,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
-

` + siteName + `

+

` + html.EscapeString(siteName) + `

✓
diff --git a/backend/internal/service/email_html_escape_test.go b/backend/internal/service/email_html_escape_test.go new file mode 100644 index 0000000000..d1e7aca3bc --- /dev/null +++ b/backend/internal/service/email_html_escape_test.go @@ -0,0 +1,67 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildVerifyCodeEmailBody_EscapesSiteName(t *testing.T) { + svc := &EmailService{} + + t.Run("escapes_script_injection", func(t *testing.T) { + body := svc.buildVerifyCodeEmailBody("123456", `

`) + + assert.NotContains(t, body, ""}`) + + result := injectSiteTitle(html, settingsJSON) + + assert.NotContains(t, string(result), "<script>") + assert.Contains(t, string(result), "</title><script>alert(1)</script><title>") + }) + + t.Run("escapes_ampersand_in_site_name", func(t *testing.T) { + html := []byte(`<html><head><title>Sub2API`) + settingsJSON := []byte(`{"site_name":"A&B"}`) + + result := injectSiteTitle(html, settingsJSON) + + assert.Contains(t, string(result), "A&B - AI API Gateway") + }) + t.Run("preserves_rest_of_html", func(t *testing.T) { html := []byte(`Sub2API
`) settingsJSON := []byte(`{"site_name":"TestSite"}`) diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 126eddf6cc..d0e4526055 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -249,6 +249,7 @@ import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue' import AnnouncementBell from '@/components/common/AnnouncementBell.vue' import Icon from '@/components/icons/Icon.vue' +import { sanitizeUrl } from '@/utils/url' const router = useRouter() const route = useRoute() @@ -262,7 +263,7 @@ const user = computed(() => authStore.user) const dropdownOpen = ref(false) const dropdownRef = ref(null) const contactInfo = computed(() => appStore.contactInfo) -const docUrl = computed(() => appStore.docUrl) +const docUrl = computed(() => sanitizeUrl(appStore.docUrl)) const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '') const availableBalance = computed(() => Number(user.value?.balance || 0)) const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0)) diff --git a/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts b/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts new file mode 100644 index 0000000000..3b0c6062b2 --- /dev/null +++ b/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const dir = dirname(fileURLToPath(import.meta.url)) +const headerSource = readFileSync(resolve(dir, '../AppHeader.vue'), 'utf8') +const homeViewSource = readFileSync(resolve(dir, '../../../views/HomeView.vue'), 'utf8') +const keyUsageViewSource = readFileSync(resolve(dir, '../../../views/KeyUsageView.vue'), 'utf8') + +describe('doc_url sanitization', () => { + it('AppHeader imports sanitizeUrl', () => { + expect(headerSource).toContain("import { sanitizeUrl } from '@/utils/url'") + }) + + it('AppHeader applies sanitizeUrl to docUrl', () => { + expect(headerSource).toContain('sanitizeUrl(appStore.docUrl)') + }) + + it('HomeView imports sanitizeUrl', () => { + expect(homeViewSource).toContain("import { sanitizeUrl } from '@/utils/url'") + }) + + it('HomeView applies sanitizeUrl to docUrl', () => { + expect(homeViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl') + }) + + it('KeyUsageView imports sanitizeUrl', () => { + expect(keyUsageViewSource).toContain("import { sanitizeUrl } from '@/utils/url'") + }) + + it('KeyUsageView applies sanitizeUrl to docUrl', () => { + expect(keyUsageViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl') + }) +}) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6a3753f1c1..d8eb176998 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -410,6 +410,7 @@ import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' +import { sanitizeUrl } from '@/utils/url' const { t } = useI18n() @@ -420,7 +421,7 @@ const appStore = useAppStore() const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform') -const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '') +const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')) const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '') // Check if homeContent is a URL (for iframe display) diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 8bc429ca3d..dda7590dce 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -423,6 +423,7 @@ import { useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' import { buildGatewayUrl } from '@/api/client' +import { sanitizeUrl } from '@/utils/url' const { t, locale } = useI18n() const appStore = useAppStore() @@ -431,7 +432,7 @@ const appStore = useAppStore() const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') -const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '') +const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')) const githubUrl = 'https://github.com/Wei-Shaw/sub2api' // ==================== Theme (same as HomeView) ==================== From 53a5c45bd86ace7f094cecc03aea9c129194a60d Mon Sep 17 00:00:00 2001 From: InCerry Date: Thu, 9 Jul 2026 11:15:52 +0800 Subject: [PATCH 21/29] fix(gateway): cap lenient json normalization Fixes #3540 --- backend/internal/handler/gateway_handler.go | 5 +- .../gateway_handler_chat_completions.go | 3 +- .../handler/gateway_handler_responses.go | 3 +- .../handler/openai_chat_completions.go | 3 +- .../handler/openai_gateway_count_tokens.go | 3 +- .../handler/openai_gateway_handler.go | 5 +- .../internal/handler/request_body_limit.go | 14 ++ backend/internal/pkg/httputil/body.go | 85 ++++++++ .../pkg/httputil/body_lenient_json_test.go | 184 ++++++++++++++++++ 9 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 backend/internal/pkg/httputil/body_lenient_json_test.go diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 0caa7f718b..8f863739ac 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -20,7 +20,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -138,7 +137,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { defer h.maybeLogCompatibilityFallbackMetrics(reqLog) // 读取请求体 - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) @@ -1777,7 +1776,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { defer h.maybeLogCompatibilityFallbackMetrics(reqLog) // 读取请求体 - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 03ceb0d952..f3805f3a53 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,7 +44,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { ) // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.chatCompletionsErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index f5ee18b722..5b49ca69a2 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,7 +44,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { ) // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.responsesErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 847d386cde..f5f2522e49 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" @@ -49,7 +48,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 9a6709cc4f..0461017067 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -6,7 +6,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/domain" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -47,7 +46,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 45eafbae76..f049711b7f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -13,7 +13,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -185,7 +184,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) @@ -715,7 +714,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/request_body_limit.go b/backend/internal/handler/request_body_limit.go index d746673b34..de24551ba9 100644 --- a/backend/internal/handler/request_body_limit.go +++ b/backend/internal/handler/request_body_limit.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "net/http" + + "github.com/Wei-Shaw/sub2api/internal/config" + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" ) func extractMaxBytesError(err error) (*http.MaxBytesError, bool) { @@ -25,3 +28,14 @@ func formatBodyLimit(limit int64) string { func buildBodyTooLargeMessage(limit int64) string { return fmt.Sprintf("Request body too large, limit is %s", formatBodyLimit(limit)) } + +func readLenientJSONRequestBodyWithPrealloc(req *http.Request, cfg *config.Config) ([]byte, error) { + return pkghttputil.ReadLenientJSONRequestBodyWithPrealloc(req, gatewayMaxBodySize(cfg)) +} + +func gatewayMaxBodySize(cfg *config.Config) int64 { + if cfg == nil { + return 0 + } + return cfg.Gateway.MaxBodySize +} diff --git a/backend/internal/pkg/httputil/body.go b/backend/internal/pkg/httputil/body.go index cee129484c..2bc3b9e753 100644 --- a/backend/internal/pkg/httputil/body.go +++ b/backend/internal/pkg/httputil/body.go @@ -16,6 +16,7 @@ import ( const ( requestBodyReadInitCap = 512 requestBodyReadMaxInitCap = 1 << 20 + jsonUTF8BOMLen = 3 // maxDecompressedBodySize limits the decompressed request body to 64 MB // to prevent decompression bomb attacks. maxDecompressedBodySize = 64 << 20 @@ -64,6 +65,16 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { return decoded, nil } +// ReadLenientJSONRequestBodyWithPrealloc reads a request body and normalizes +// JSON string control bytes before strict validation. +func ReadLenientJSONRequestBodyWithPrealloc(req *http.Request, maxNormalizedBytes int64) ([]byte, error) { + body, err := ReadRequestBodyWithPrealloc(req) + if err != nil { + return nil, err + } + return NormalizeLenientJSONRequestBody(body, maxNormalizedBytes) +} + func decompressRequestBody(encoding string, raw []byte) ([]byte, error) { switch encoding { case "zstd": @@ -91,3 +102,77 @@ func decompressRequestBody(encoding string, raw []byte) ([]byte, error) { return nil, errors.New("unsupported Content-Encoding") } } + +// NormalizeLenientJSONRequestBody escapes raw control bytes that broken +// OpenAI-compatible clients sometimes place inside JSON strings. +func NormalizeLenientJSONRequestBody(body []byte, maxNormalizedBytes int64) ([]byte, error) { + if maxNormalizedBytes <= 0 { + maxNormalizedBytes = maxDecompressedBodySize + } + + body = trimUTF8BOM(body) + if len(body) == 0 { + return body, nil + } + if int64(len(body)) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + + var out []byte + inString := false + escaped := false + for i, b := range body { + if inString && isJSONControlByte(b) { + if out == nil { + capHint := len(body) + 6 + if int64(capHint) > maxNormalizedBytes { + capHint = int(maxNormalizedBytes) + } + out = make([]byte, 0, capHint) + out = append(out, body[:i]...) + } + if int64(len(out)+6) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + out = appendJSONUnicodeEscape(out, b) + escaped = false + continue + } + + switch { + case escaped: + escaped = false + case inString && b == '\\': + escaped = true + case b == '"': + inString = !inString + } + + if out != nil { + if int64(len(out)+1) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + out = append(out, b) + } + } + if out != nil { + return out, nil + } + return body, nil +} + +func trimUTF8BOM(body []byte) []byte { + if len(body) >= jsonUTF8BOMLen && body[0] == 0xef && body[1] == 0xbb && body[2] == 0xbf { + return body[jsonUTF8BOMLen:] + } + return body +} + +func isJSONControlByte(b byte) bool { + return b < 0x20 || b == 0x7f +} + +func appendJSONUnicodeEscape(dst []byte, b byte) []byte { + const hex = "0123456789abcdef" + return append(dst, '\\', 'u', '0', '0', hex[b>>4], hex[b&0x0f]) +} diff --git a/backend/internal/pkg/httputil/body_lenient_json_test.go b/backend/internal/pkg/httputil/body_lenient_json_test.go new file mode 100644 index 0000000000..71ffc392b8 --- /dev/null +++ b/backend/internal/pkg/httputil/body_lenient_json_test.go @@ -0,0 +1,184 @@ +package httputil + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tidwall/gjson" +) + +func TestNormalizeLenientJSONRequestBody_accepts_client_control_chars_in_strings(t *testing.T) { + tests := []struct { + name string + body []byte + path string + want string + wantRaw string + }{ + { + name: "null byte in message content", + body: []byte("{\"messages\":[{\"content\":\"hello\x00world\"}]}"), + path: "messages.0.content", + want: "hello\x00world", + wantRaw: `"hello\u0000world"`, + }, + { + name: "ansi escape in message content", + body: []byte("{\"messages\":[{\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"), + path: "messages.0.content", + want: "hello\x1b[31mred\x1b[0m", + wantRaw: `"hello\u001b[31mred\u001b[0m"`, + }, + { + name: "leading UTF-8 BOM", + body: []byte("\xef\xbb\xbf{\"input\":\"hello\"}"), + path: "input", + want: "hello", + wantRaw: `"hello"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Given + if gjson.ValidBytes(tt.body) { + t.Fatalf("test payload should reproduce strict JSON rejection: %q", tt.body) + } + + // When + got, err := NormalizeLenientJSONRequestBody(tt.body, 1024) + if err != nil { + t.Fatalf("NormalizeLenientJSONRequestBody: %v", err) + } + + // Then + if !gjson.ValidBytes(got) { + t.Fatalf("normalized body should be valid JSON: %q", got) + } + result := gjson.GetBytes(got, tt.path) + if result.String() != tt.want { + t.Fatalf("value mismatch: got %q want %q", result.String(), tt.want) + } + if result.Raw != tt.wantRaw { + t.Fatalf("raw value mismatch: got %q want %q", result.Raw, tt.wantRaw) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_keeps_invalid_structure_invalid(t *testing.T) { + tests := []struct { + name string + body []byte + }{ + { + name: "truncated JSON", + body: []byte("{\"messages\":[{\"content\":\"hello\"}]"), + }, + { + name: "control character outside string", + body: []byte("{\"input\":\"hello\"}\x00"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // When + got, err := NormalizeLenientJSONRequestBody(tt.body, 1024) + if err != nil { + t.Fatalf("NormalizeLenientJSONRequestBody: %v", err) + } + + // Then + if gjson.ValidBytes(got) { + t.Fatalf("normalization must not repair invalid JSON structure: %q", got) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_allows_http_requests_with_client_control_chars(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Given + body, err := ReadLenientJSONRequestBodyWithPrealloc(r, 1024) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // When + if !gjson.ValidBytes(body) { + http.Error(w, "Failed to parse request body", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + tests := []struct { + name string + body []byte + want int + }{ + { + name: "null byte in JSON string", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x00world\"}]}"), + want: http.StatusAccepted, + }, + { + name: "ANSI escape in JSON string", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"), + want: http.StatusAccepted, + }, + { + name: "leading UTF-8 BOM", + body: []byte("\xef\xbb\xbf{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}"), + want: http.StatusAccepted, + }, + { + name: "truncated JSON", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]"), + want: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, server.URL+"/v1/chat/completions", bytes.NewReader(tt.body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != tt.want { + t.Fatalf("status mismatch: got %d want %d", resp.StatusCode, tt.want) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_rejects_expansion_past_limit(t *testing.T) { + // Given + body := []byte("{\"input\":\"\x00\x00\"}") + + // When + _, err := NormalizeLenientJSONRequestBody(body, int64(len(body)+5)) + + // Then + var maxErr *http.MaxBytesError + if !errors.As(err, &maxErr) { + t.Fatalf("expected MaxBytesError, got %T %v", err, err) + } + if maxErr.Limit != int64(len(body)+5) { + t.Fatalf("limit mismatch: got %d want %d", maxErr.Limit, len(body)+5) + } +} From 54859022aa9ed07ca8fc1a52eca017b70cd7c622 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 9 Jul 2026 11:45:59 +0800 Subject: [PATCH 22/29] feat: show used quota in groups list --- .../__tests__/BulkEditAccountModal.spec.ts | 2 +- frontend/src/views/admin/GroupsView.vue | 95 +++++++++++++++---- .../admin/__tests__/DashboardView.spec.ts | 3 + .../GroupsView.columnSettings.spec.ts | 7 +- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index d094f5366d..31f6e3bd26 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -107,7 +107,7 @@ describe('BulkEditAccountModal', () => { expect(mappingTab).toBeTruthy() await mappingTab!.trigger('click') - expect(wrapper.text()).toContain('3.1-Flash-Image passthrough') + expect(wrapper.text()).toContain('3.1-Flash-Image透传') expect(wrapper.text()).toContain('3-Pro-Image→3.1') expect(wrapper.text()).not.toContain('GPT-5.3 Codex Spark') }) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 0700f127b8..404ca0dc64 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -168,20 +168,40 @@
- +
{{ t("admin.groups.subscription.noLimit") }} +
+ {{ t("admin.groups.usageTotal") }} + {{ + usageLoading + ? "—" + : formatUsd(usageMap.get(row.id)?.total_cost ?? 0) + }} +

@@ -3396,7 +3426,9 @@ const saveColumnsToStorage = () => { }; const isColumnVisible = (key: string) => !hiddenColumns.has(key); -const hasVisibleUsageColumn = computed(() => isColumnVisible("usage")); +const hasVisibleUsageSummaryConsumer = computed( + () => isColumnVisible("usage") || isColumnVisible("billing_type"), +); const hasVisibleCapacityColumn = computed(() => isColumnVisible("capacity")); const toggleColumn = (key: string) => { @@ -3411,7 +3443,7 @@ const toggleColumn = (key: string) => { } saveColumnsToStorage(); - if (wasHidden && key === "usage") { + if (wasHidden && (key === "usage" || key === "billing_type")) { loadUsageSummary(); } if (wasHidden && key === "capacity") { @@ -3571,9 +3603,12 @@ const copyAccountsGroupOptionsForEdit = computed(() => { const groups = ref([]); const loading = ref(false); -const usageMap = ref>( - new Map(), -); +type GroupUsageSummary = { + today_cost: number; + total_cost: number; +}; + +const usageMap = ref>(new Map()); const usageLoading = ref(false); const capacityMap = ref< Map< @@ -4146,7 +4181,7 @@ const loadGroups = async () => { groups.value = response.items; pagination.total = response.total; pagination.pages = response.pages; - if (hasVisibleUsageColumn.value) { + if (hasVisibleUsageSummaryConsumer.value) { loadUsageSummary(); } else { usageLoading.value = false; @@ -4177,8 +4212,28 @@ const formatCost = (cost: number): string => { return cost.toFixed(2); }; +const formatUsd = (cost: number | null | undefined): string => + `$${formatCost(cost ?? 0)}`; + +const getQuotaUsageClass = ( + used: number, + limit: number | null | undefined, +): string => { + if (!limit || limit <= 0) { + return "font-medium text-gray-700 dark:text-gray-300"; + } + const ratio = used / limit; + if (ratio >= 1) { + return "font-semibold text-red-600 dark:text-red-400"; + } + if (ratio >= 0.8) { + return "font-semibold text-amber-600 dark:text-amber-400"; + } + return "font-medium text-gray-700 dark:text-gray-300"; +}; + const loadUsageSummary = async () => { - if (!hasVisibleUsageColumn.value) { + if (!hasVisibleUsageSummaryConsumer.value) { usageLoading.value = false; return; } @@ -4186,7 +4241,7 @@ const loadUsageSummary = async () => { try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const data = await adminAPI.groups.getUsageSummary(tz); - const map = new Map(); + const map = new Map(); for (const item of data) { map.set(item.group_id, { today_cost: item.today_cost, diff --git a/frontend/src/views/admin/__tests__/DashboardView.spec.ts b/frontend/src/views/admin/__tests__/DashboardView.spec.ts index 7cb5123211..ded7db034f 100644 --- a/frontend/src/views/admin/__tests__/DashboardView.spec.ts +++ b/frontend/src/views/admin/__tests__/DashboardView.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import type { DashboardStats } from '@/types' import DashboardView from '../DashboardView.vue' @@ -87,6 +88,8 @@ const createDashboardStats = (): DashboardStats => ({ describe('admin DashboardView', () => { beforeEach(() => { + setActivePinia(createPinia()) + getSnapshotV2.mockReset() getUserUsageTrend.mockReset() getUserSpendingRanking.mockReset() diff --git a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts index c323d44abd..e634bdd5d7 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts @@ -308,8 +308,11 @@ describe('admin GroupsView column settings', () => { expect(localStorage.getItem('group-hidden-columns')).toBe(JSON.stringify(['usage'])) }) - it('skips hidden usage and capacity fetches until those columns are shown', async () => { - localStorage.setItem('group-hidden-columns', JSON.stringify(['usage', 'capacity'])) + it('skips usage and capacity fetches until consuming columns are shown', async () => { + localStorage.setItem( + 'group-hidden-columns', + JSON.stringify(['billing_type', 'usage', 'capacity']), + ) const wrapper = await mountView() From 25a716960197d7ecbccb2bcca8feb65ac44f6e78 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 9 Jul 2026 14:06:57 +0800 Subject: [PATCH 23/29] =?UTF-8?q?chore:=20Go=20=E5=B7=A5=E5=85=B7=E9=93=BE?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=201.26.4=20=E2=86=92=201.26.5=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BF=AE=E5=A4=8D=20stdlib=20=E6=BC=8F=E6=B4=9E?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E9=BD=90=20CI=20=E7=89=88=E6=9C=AC=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/go.mod 工具链 1.26.5:修复 stdlib crypto/tls 漏洞(GO-2026-5856) - 同步全部构建/校验点的硬编码版本:根 Dockerfile、backend/Dockerfile、 deploy/Dockerfile 基础镜像;backend-ci / release / security-scan 三个 workflow 的 go version 校验 --- .github/workflows/backend-ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- Dockerfile | 2 +- backend/Dockerfile | 2 +- backend/go.mod | 2 +- deploy/Dockerfile | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a9cf349937..bb5cf692bc 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -20,7 +20,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.4' + go version | grep -q 'go1.26.5' - name: Unit tests working-directory: backend run: make test-unit @@ -60,7 +60,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.4' + go version | grep -q 'go1.26.5' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8c5dcfbfa..2ba01833c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.4' + go version | grep -q 'go1.26.5' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ab3305ab6e..96a7ae4edd 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.4' + go version | grep -q 'go1.26.5' - name: Run govulncheck working-directory: backend run: | diff --git a/Dockerfile b/Dockerfile index 13a6b8700d..17b631d5a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.4-alpine +ARG GOLANG_IMAGE=golang:1.26.5-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/backend/Dockerfile b/backend/Dockerfile index d4adb5564b..9976abe46b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine +FROM golang:1.26.5-alpine WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index 53d0596ef5..a06f06437d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.4 +go 1.26.5 require ( entgo.io/ent v0.14.5 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d83b0e25b8..88d364a7f3 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.4-alpine +ARG GOLANG_IMAGE=golang:1.26.5-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn From cccba9a82e6100c34ebb679b1bab6998b1185b58 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Thu, 9 Jul 2026 14:26:10 +0800 Subject: [PATCH 24/29] Add official Grok 4.5 support --- .../handler/openai_gateway_handler_test.go | 2 +- backend/internal/pkg/xai/models.go | 9 ++-- backend/internal/pkg/xai/oauth_test.go | 7 ++- backend/internal/service/billing_service.go | 12 ++++- .../internal/service/billing_service_test.go | 16 +++++++ .../internal/service/openai_gateway_grok.go | 10 +++++ .../service/openai_gateway_grok_test.go | 44 +++++++++++++++++++ .../service/openai_messages_dispatch_test.go | 6 +-- .../__tests__/useModelWhitelist.spec.ts | 27 ++++++++++++ frontend/src/composables/useModelWhitelist.ts | 8 +++- 10 files changed, 130 insertions(+), 11 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index c4ccb5a024..b7f43079ef 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -440,7 +440,7 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.3", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5")) + require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5")) require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok")) }) diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index a5b800cf2c..a42f9b1871 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -10,6 +10,7 @@ type Model struct { } var defaultModels = []Model{ + {ID: "grok-4.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, {ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, {ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"}, {ID: "grok-composer-2.5-fast", Object: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"}, @@ -40,13 +41,15 @@ func DefaultModelIDs() []string { } func DefaultModelMapping() map[string]string { - mapping := make(map[string]string, len(defaultModels)+3) + mapping := make(map[string]string, len(defaultModels)+5) for _, model := range defaultModels { mapping[model.ID] = model.ID } - mapping["grok"] = "grok-4.3" - mapping["grok-latest"] = "grok-4.3" + mapping["grok"] = "grok-4.5" + mapping["grok-latest"] = "grok-4.5" + mapping["grok-4.5-latest"] = "grok-4.5" mapping["grok-build"] = "grok-build-0.1" + mapping["grok-build-latest"] = "grok-4.5" mapping["grok-composer"] = "grok-composer-2.5-fast" mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning" mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning" diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 28609a08fa..1eea83640b 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -207,9 +207,12 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { t.Parallel() mapping := DefaultModelMapping() - require.Equal(t, "grok-4.3", mapping["grok"]) - require.Equal(t, "grok-4.3", mapping["grok-latest"]) + require.Equal(t, "grok-4.5", mapping["grok"]) + require.Equal(t, "grok-4.5", mapping["grok-latest"]) + require.Equal(t, "grok-4.5", mapping["grok-4.5"]) + require.Equal(t, "grok-4.5", mapping["grok-4.5-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) + require.Equal(t, "grok-4.5", mapping["grok-build-latest"]) require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"]) require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"]) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 4c265aed3c..b3d34eb175 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -512,6 +512,14 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + // xAI Grok 4.5 (official docs: $2 input / $0.50 cached input / $6 output per MTok) + s.fallbackPrices["grok-4.5"] = &ModelPricing{ + InputPricePerToken: 2e-6, + OutputPricePerToken: 6e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + } + // xAI Grok 4.3 (official docs: $1.25 input / $2.50 output per MTok) s.fallbackPrices["grok-4.3"] = &ModelPricing{ InputPricePerToken: 1.25e-6, @@ -696,7 +704,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { } switch modelLower { - case "grok", "grok-latest", "grok-4.3": + case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest": + return s.fallbackPrices["grok-4.5"] + case "grok-4.3": return s.fallbackPrices["grok-4.3"] case "grok-build", "grok-build-0.1": return s.fallbackPrices["grok-build-0.1"] diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 92c143c6ff..4adacd6f8a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -963,6 +963,22 @@ func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { require.Contains(t, err.Error(), "pricing not found") } +func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { + svc := newTestBillingService() + + for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest"} { + model := model + t.Run(model, func(t *testing.T) { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.False(t, pricing.SupportsCacheBreakdown) + }) + } +} + func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) { svc := &BillingService{ cfg: &config.Config{}, diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 4a0ad06d46..19e44fc4d9 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -153,6 +153,16 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) { } } } + if strings.EqualFold(upstreamModel, "grok-4.5") { + for _, unsupportedField := range []string{"presence_penalty", "presencePenalty", "frequency_penalty", "frequencyPenalty", "stop"} { + if gjson.GetBytes(out, unsupportedField).Exists() { + out, err = sjson.DeleteBytes(out, unsupportedField) + if err != nil { + return nil, err + } + } + } + } out, err = sanitizeGrokResponsesUnsupportedFields(out) if err != nil { return nil, err diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index f6aa4b6cd1..b0223c8580 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -41,6 +41,50 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String()) } +func TestPatchGrokResponsesBodyDropsGrok45ReasoningUnsupportedFields(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-latest", + "input": "hello", + "presence_penalty": 0.1, + "presencePenalty": 0.2, + "frequency_penalty": 0.3, + "frequencyPenalty": 0.4, + "stop": ["done"] + }`) + + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + require.True(t, json.Valid(patched)) + require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String()) + require.False(t, gjson.GetBytes(patched, "presence_penalty").Exists()) + require.False(t, gjson.GetBytes(patched, "presencePenalty").Exists()) + require.False(t, gjson.GetBytes(patched, "frequency_penalty").Exists()) + require.False(t, gjson.GetBytes(patched, "frequencyPenalty").Exists()) + require.False(t, gjson.GetBytes(patched, "stop").Exists()) +} + +func TestPatchGrokResponsesBodyKeepsPenaltyAndStopFieldsForNon45Models(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-4.3", + "input": "hello", + "presence_penalty": 0.1, + "frequency_penalty": 0.2, + "stop": ["done"] + }`) + + patched, err := patchGrokResponsesBody(body, "grok-4.3") + require.NoError(t, err) + require.True(t, json.Valid(patched)) + require.Equal(t, "grok-4.3", gjson.GetBytes(patched, "model").String()) + require.Equal(t, 0.1, gjson.GetBytes(patched, "presence_penalty").Float()) + require.Equal(t, 0.2, gjson.GetBytes(patched, "frequency_penalty").Float()) + require.Len(t, gjson.GetBytes(patched, "stop").Array(), 1) +} + func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_messages_dispatch_test.go b/backend/internal/service/openai_messages_dispatch_test.go index e0b8ab0aa0..bafd36449b 100644 --- a/backend/internal/service/openai_messages_dispatch_test.go +++ b/backend/internal/service/openai_messages_dispatch_test.go @@ -31,9 +31,9 @@ func TestGroupResolveMessagesDispatchModel_GrokMapsClaudeFamilyToGrok(t *testing group := &Group{Platform: PlatformGrok} - require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-sonnet-4-5")) - require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-opus-4-6")) - require.Equal(t, "grok-4.3", group.ResolveMessagesDispatchModel("claude-haiku-4-5")) + require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-sonnet-4-5")) + require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-opus-4-6")) + require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-haiku-4-5")) require.Empty(t, group.ResolveMessagesDispatchModel("grok")) require.Empty(t, group.ResolveMessagesDispatchModel("gpt-5.3-codex")) } diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index d7e70e309d..d34d7113bb 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -42,6 +42,33 @@ describe('useModelWhitelist', () => { expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) + it('xAI 模型列表包含 Grok 4.5 官方模型和别名', () => { + const models = getModelsByPlatform('grok') + + expect(models).toContain('grok-4.5') + expect(models).toContain('grok-4.5-latest') + expect(models).toContain('grok-build-latest') + }) + + it('combined 模式支持 Grok 4.5 官方别名映射', () => { + const mapping = buildModelMappingObject( + 'combined', + ['grok-4.5'], + [ + { from: 'grok-latest', to: 'grok-4.5' }, + { from: 'grok-4.5-latest', to: 'grok-4.5' }, + { from: 'grok-build-latest', to: 'grok-4.5' } + ] + ) + + expect(mapping).toEqual({ + 'grok-4.5': 'grok-4.5', + 'grok-latest': 'grok-4.5', + 'grok-4.5-latest': 'grok-4.5', + 'grok-build-latest': 'grok-4.5' + }) + }) + it('gemini 模型列表包含原生生图模型', () => { const models = getModelsByPlatform('gemini') diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 28bc1d28ad..2f8b7486ac 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -135,6 +135,7 @@ const metaModels = [ // xAI Grok const xaiModels = [ + 'grok-4.5', 'grok-4.3', 'grok-build-0.1', 'grok-composer-2.5-fast', @@ -143,7 +144,9 @@ const xaiModels = [ 'grok-4.20-multi-agent-0309', 'grok', 'grok-latest', + 'grok-4.5-latest', 'grok-build', + 'grok-build-latest', 'grok-composer', 'grok-4.20-reasoning', 'grok-4.20-non-reasoning', @@ -296,9 +299,12 @@ const geminiPresetMappings = [ ] const grokPresetMappings = [ + { label: 'Grok 4.5', from: 'grok-4.5', to: 'grok-4.5', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, { label: 'Grok 4.3', from: 'grok-4.3', to: 'grok-4.3', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' }, - { label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, + { label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, + { label: '4.5 Latest', from: 'grok-4.5-latest', to: 'grok-4.5', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, { label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, + { label: 'Build Latest', from: 'grok-build-latest', to: 'grok-4.5', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, { label: 'Composer 2.5', from: 'grok-composer', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, { label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, From 1785509873a58558732b831455595c00858a8b02 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 15:34:10 +0800 Subject: [PATCH 25/29] =?UTF-8?q?fix(apicompat):=20ResponsesToAnthropicReq?= =?UTF-8?q?uest=20=E8=A1=A5=E5=85=A8=20instructions=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E6=98=A0=E5=B0=84=20developer=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3850 --- ...esponses_to_anthropic_instructions_test.go | 126 ++++++++++++++++++ .../responses_to_anthropic_request.go | 30 +++-- ...esponses_to_anthropic_tool_pairing_test.go | 2 +- 3 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go new file mode 100644 index 0000000000..b63787cd44 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go @@ -0,0 +1,126 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponsesToAnthropicRequest_Instructions(t *testing.T) { + t.Run("instructions_becomes_system", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "You are a helpful assistant.", + Input: json.RawMessage(`[{"role":"user","content":"hello"}]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Equal(t, "You are a helpful assistant.", system) + assert.NotEmpty(t, result.Messages) + }) + + t.Run("empty_instructions_no_system", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Input: json.RawMessage(`[{"role":"user","content":"hello"}]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + assert.Nil(t, result.System) + }) + + t.Run("instructions_and_system_item_concatenated", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "Top-level instruction.", + Input: json.RawMessage(`[ + {"role":"system","content":"Input-level system prompt."}, + {"role":"user","content":"hello"} + ]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Contains(t, system, "Top-level instruction.") + assert.Contains(t, system, "Input-level system prompt.") + }) + + t.Run("instructions_with_string_input", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "Be concise.", + Input: json.RawMessage(`"What is Go?"`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Equal(t, "Be concise.", system) + require.Len(t, result.Messages, 1) + assert.Equal(t, "user", result.Messages[0].Role) + }) +} + +func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { + t.Run("developer_becomes_system", func(t *testing.T) { + input := `[ + {"role":"developer","content":[{"type":"input_text","text":"You are a code reviewer."}]}, + {"role":"user","content":"review this code"} + ]` + + system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + require.NoError(t, err) + + var systemText string + require.NoError(t, json.Unmarshal(system, &systemText)) + assert.Equal(t, "You are a code reviewer.", systemText) + + require.Len(t, messages, 1) + assert.Equal(t, "user", messages[0].Role) + }) + + t.Run("developer_does_not_become_user", func(t *testing.T) { + input := `[ + {"role":"developer","content":[{"type":"input_text","text":"System prompt."}]}, + {"role":"user","content":"hi"} + ]` + + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + require.NoError(t, err) + + for _, m := range messages { + if m.Role == "user" { + var s string + if json.Unmarshal(m.Content, &s) == nil { + assert.NotContains(t, s, "System prompt.") + } + } + } + }) + + t.Run("instructions_and_developer_concatenated_in_order", func(t *testing.T) { + input := `[ + {"role":"developer","content":"Extra context."}, + {"role":"user","content":"hello"} + ]` + + system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input)) + require.NoError(t, err) + + var systemText string + require.NoError(t, json.Unmarshal(system, &systemText)) + assert.Equal(t, "Main instruction.\n\nExtra context.", systemText) + }) +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 6da249ed25..46f57d0ca6 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -11,7 +11,7 @@ import ( // enables Anthropic platform groups to accept OpenAI Responses API requests // by converting them to the native /v1/messages format before forwarding upstream. func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { - system, messages, err := convertResponsesInputToAnthropic(req.Input) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input) if err != nil { return nil, err } @@ -98,14 +98,23 @@ func mapResponsesEffortToAnthropic(effort string) string { } // convertResponsesInputToAnthropic extracts system prompt and messages from -// a Responses API input array. Returns the system as raw JSON (for Anthropic's -// polymorphic system field) and a list of Anthropic messages. -func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { +// a Responses API instructions + input array. Returns the system as raw JSON +// (for Anthropic's polymorphic system field) and a list of Anthropic messages. +func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { + var systemParts []string + if strings.TrimSpace(instructions) != "" { + systemParts = append(systemParts, strings.TrimSpace(instructions)) + } + // Try as plain string input. var inputStr string if err := json.Unmarshal(inputRaw, &inputStr); err == nil { content, _ := json.Marshal(inputStr) - return nil, []AnthropicMessage{{Role: "user", Content: content}}, nil + var system json.RawMessage + if len(systemParts) > 0 { + system, _ = json.Marshal(strings.Join(systemParts, "\n\n")) + } + return system, []AnthropicMessage{{Role: "user", Content: content}}, nil } var items []ResponsesInputItem @@ -113,16 +122,14 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage return nil, nil, fmt.Errorf("parse responses input: %w", err) } - var system json.RawMessage var messages []AnthropicMessage for _, item := range items { switch { - case item.Role == "system": - // System prompt → Anthropic system field + case item.Role == "system" || item.Role == "developer": text := extractTextFromContent(item.Content) if text != "" { - system, _ = json.Marshal(text) + systemParts = append(systemParts, text) } case item.Type == "function_call": @@ -201,6 +208,11 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage messages = normalizeAnthropicToolPairing(messages) messages = mergeConsecutiveMessages(messages) + var system json.RawMessage + if len(systemParts) > 0 { + system, _ = json.Marshal(strings.Join(systemParts, "\n\n")) + } + return system, messages, nil } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index b2522f274b..1a51b5478d 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { func convertAnthropic(t *testing.T, input string) []AnthropicMessage { t.Helper() - _, messages, err := convertResponsesInputToAnthropic(json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) require.NoError(t, err) assertAnthropicPairing(t, messages) return messages From d4952154ffd2668bf9cdb1a1f25e0172ee851e61 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 9 Jul 2026 15:38:59 +0800 Subject: [PATCH 26/29] fix: bill Grok video per second and harden video usage logging Follow-up fixes for the #3775 audit findings: - Bill Grok video generation per second of output, matching the xAI rate card: parse the request duration (1-15s, upstream default 8s) and compute cost as per-second price x duration x count. The built-in rate card values were already xAI per-second prices but were previously charged per video, undercharging up to 15x with a user-controlled duration. - Group video_price_* fields are now documented and surfaced as per-second rates (USD/s); admin UI labels, placeholders and hints updated accordingly. - Persist video_count/video_resolution/video_duration_seconds on usage_logs (migration 172) so video billing is auditable, and exempt any row with video_count > 0 from the image_size check constraint: a video billed via a token-mode channel price produces billing_mode='token' with image_count=1 and no image_size, which the previous constraint rejected, dropping the whole billing transaction. - Only refetch the group in apiKeyWithFreshGroupMediaPricing when the group object actually looks like it is missing media pricing fields (both media multipliers zero and all prices nil, impossible for a normally loaded group), removing a per-usage DB query for groups without overrides. - Frontend: drop the unused admin.groups.mediaPricing locale block, map cleared price inputs to null (create) / -1 (update, cleared via backend normalizePrice) instead of sending "" that failed *float64 unmarshalling, and align video price placeholders with the text-to-video default model (grok-imagine-video 0.05/0.07, 1080p only on 1.5 at 0.25). --- backend/ent/migrate/schema.go | 31 +- backend/ent/mutation.go | 269 +++++++++++++++++- backend/ent/runtime/runtime.go | 12 +- backend/ent/schema/usage_log.go | 14 + backend/ent/usagelog.go | 43 ++- backend/ent/usagelog/usagelog.go | 28 ++ backend/ent/usagelog/where.go | 180 ++++++++++++ backend/ent/usagelog_create.go | 266 +++++++++++++++++ backend/ent/usagelog_update.go | 188 ++++++++++++ .../migrations_schema_integration_test.go | 4 + .../repository/usage_log_repo_insert.go | 40 ++- .../repository/usage_log_repo_query.go | 16 +- .../usage_log_repo_request_type_test.go | 18 ++ backend/internal/service/billing_service.go | 26 +- .../internal/service/billing_service_test.go | 32 ++- backend/internal/service/grok_media.go | 76 ++--- .../service/openai_gateway_grok_test.go | 7 +- .../openai_gateway_record_usage_test.go | 107 +++++-- .../service/openai_gateway_service.go | 2 + .../internal/service/openai_gateway_usage.go | 35 ++- backend/internal/service/usage_log.go | 5 + .../service/video_billing_resolution.go | 23 ++ .../172_video_per_second_billing_metadata.sql | 38 +++ .../src/i18n/locales/en/admin/overview.ts | 19 +- .../src/i18n/locales/zh/admin/overview.ts | 18 +- frontend/src/views/admin/GroupsView.vue | 30 +- .../__tests__/groupsImagePricing.spec.ts | 6 +- .../src/views/admin/groupsImagePricing.ts | 6 +- 28 files changed, 1396 insertions(+), 143 deletions(-) create mode 100644 backend/migrations/172_video_per_second_billing_metadata.sql diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index edae57d212..d3e8bc5448 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1572,6 +1572,9 @@ var ( {Name: "image_output_size", Type: field.TypeString, Nullable: true, Size: 32}, {Name: "image_size_source", Type: field.TypeString, Nullable: true, Size: 16}, {Name: "image_size_breakdown", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "video_count", Type: field.TypeInt, Default: 0}, + {Name: "video_resolution", Type: field.TypeString, Nullable: true, Size: 10}, + {Name: "video_duration_seconds", Type: field.TypeInt, Nullable: true}, {Name: "cache_ttl_overridden", Type: field.TypeBool, Default: false}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "api_key_id", Type: field.TypeInt64}, @@ -1588,31 +1591,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[38]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[39]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -1621,32 +1624,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[38]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[39]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[39]}, }, { Name: "usagelog_model", @@ -1666,17 +1669,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[39]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[39]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[42], UsageLogsColumns[39]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 07f8ce623e..8d32773050 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -41712,6 +41712,11 @@ type UsageLogMutation struct { image_output_size *string image_size_source *string image_size_breakdown *map[string]int + video_count *int + addvideo_count *int + video_resolution *string + video_duration_seconds *int + addvideo_duration_seconds *int cache_ttl_overridden *bool created_at *time.Time clearedFields map[string]struct{} @@ -43850,6 +43855,181 @@ func (m *UsageLogMutation) ResetImageSizeBreakdown() { delete(m.clearedFields, usagelog.FieldImageSizeBreakdown) } +// SetVideoCount sets the "video_count" field. +func (m *UsageLogMutation) SetVideoCount(i int) { + m.video_count = &i + m.addvideo_count = nil +} + +// VideoCount returns the value of the "video_count" field in the mutation. +func (m *UsageLogMutation) VideoCount() (r int, exists bool) { + v := m.video_count + if v == nil { + return + } + return *v, true +} + +// OldVideoCount returns the old "video_count" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoCount(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoCount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoCount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoCount: %w", err) + } + return oldValue.VideoCount, nil +} + +// AddVideoCount adds i to the "video_count" field. +func (m *UsageLogMutation) AddVideoCount(i int) { + if m.addvideo_count != nil { + *m.addvideo_count += i + } else { + m.addvideo_count = &i + } +} + +// AddedVideoCount returns the value that was added to the "video_count" field in this mutation. +func (m *UsageLogMutation) AddedVideoCount() (r int, exists bool) { + v := m.addvideo_count + if v == nil { + return + } + return *v, true +} + +// ResetVideoCount resets all changes to the "video_count" field. +func (m *UsageLogMutation) ResetVideoCount() { + m.video_count = nil + m.addvideo_count = nil +} + +// SetVideoResolution sets the "video_resolution" field. +func (m *UsageLogMutation) SetVideoResolution(s string) { + m.video_resolution = &s +} + +// VideoResolution returns the value of the "video_resolution" field in the mutation. +func (m *UsageLogMutation) VideoResolution() (r string, exists bool) { + v := m.video_resolution + if v == nil { + return + } + return *v, true +} + +// OldVideoResolution returns the old "video_resolution" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoResolution(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoResolution is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoResolution requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoResolution: %w", err) + } + return oldValue.VideoResolution, nil +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (m *UsageLogMutation) ClearVideoResolution() { + m.video_resolution = nil + m.clearedFields[usagelog.FieldVideoResolution] = struct{}{} +} + +// VideoResolutionCleared returns if the "video_resolution" field was cleared in this mutation. +func (m *UsageLogMutation) VideoResolutionCleared() bool { + _, ok := m.clearedFields[usagelog.FieldVideoResolution] + return ok +} + +// ResetVideoResolution resets all changes to the "video_resolution" field. +func (m *UsageLogMutation) ResetVideoResolution() { + m.video_resolution = nil + delete(m.clearedFields, usagelog.FieldVideoResolution) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (m *UsageLogMutation) SetVideoDurationSeconds(i int) { + m.video_duration_seconds = &i + m.addvideo_duration_seconds = nil +} + +// VideoDurationSeconds returns the value of the "video_duration_seconds" field in the mutation. +func (m *UsageLogMutation) VideoDurationSeconds() (r int, exists bool) { + v := m.video_duration_seconds + if v == nil { + return + } + return *v, true +} + +// OldVideoDurationSeconds returns the old "video_duration_seconds" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoDurationSeconds(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoDurationSeconds is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoDurationSeconds requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoDurationSeconds: %w", err) + } + return oldValue.VideoDurationSeconds, nil +} + +// AddVideoDurationSeconds adds i to the "video_duration_seconds" field. +func (m *UsageLogMutation) AddVideoDurationSeconds(i int) { + if m.addvideo_duration_seconds != nil { + *m.addvideo_duration_seconds += i + } else { + m.addvideo_duration_seconds = &i + } +} + +// AddedVideoDurationSeconds returns the value that was added to the "video_duration_seconds" field in this mutation. +func (m *UsageLogMutation) AddedVideoDurationSeconds() (r int, exists bool) { + v := m.addvideo_duration_seconds + if v == nil { + return + } + return *v, true +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (m *UsageLogMutation) ClearVideoDurationSeconds() { + m.video_duration_seconds = nil + m.addvideo_duration_seconds = nil + m.clearedFields[usagelog.FieldVideoDurationSeconds] = struct{}{} +} + +// VideoDurationSecondsCleared returns if the "video_duration_seconds" field was cleared in this mutation. +func (m *UsageLogMutation) VideoDurationSecondsCleared() bool { + _, ok := m.clearedFields[usagelog.FieldVideoDurationSeconds] + return ok +} + +// ResetVideoDurationSeconds resets all changes to the "video_duration_seconds" field. +func (m *UsageLogMutation) ResetVideoDurationSeconds() { + m.video_duration_seconds = nil + m.addvideo_duration_seconds = nil + delete(m.clearedFields, usagelog.FieldVideoDurationSeconds) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) { m.cache_ttl_overridden = &b @@ -44091,7 +44271,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 41) + fields := make([]string, 0, 44) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -44209,6 +44389,15 @@ func (m *UsageLogMutation) Fields() []string { if m.image_size_breakdown != nil { fields = append(fields, usagelog.FieldImageSizeBreakdown) } + if m.video_count != nil { + fields = append(fields, usagelog.FieldVideoCount) + } + if m.video_resolution != nil { + fields = append(fields, usagelog.FieldVideoResolution) + } + if m.video_duration_seconds != nil { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } if m.cache_ttl_overridden != nil { fields = append(fields, usagelog.FieldCacheTTLOverridden) } @@ -44301,6 +44490,12 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.ImageSizeSource() case usagelog.FieldImageSizeBreakdown: return m.ImageSizeBreakdown() + case usagelog.FieldVideoCount: + return m.VideoCount() + case usagelog.FieldVideoResolution: + return m.VideoResolution() + case usagelog.FieldVideoDurationSeconds: + return m.VideoDurationSeconds() case usagelog.FieldCacheTTLOverridden: return m.CacheTTLOverridden() case usagelog.FieldCreatedAt: @@ -44392,6 +44587,12 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldImageSizeSource(ctx) case usagelog.FieldImageSizeBreakdown: return m.OldImageSizeBreakdown(ctx) + case usagelog.FieldVideoCount: + return m.OldVideoCount(ctx) + case usagelog.FieldVideoResolution: + return m.OldVideoResolution(ctx) + case usagelog.FieldVideoDurationSeconds: + return m.OldVideoDurationSeconds(ctx) case usagelog.FieldCacheTTLOverridden: return m.OldCacheTTLOverridden(ctx) case usagelog.FieldCreatedAt: @@ -44678,6 +44879,27 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetImageSizeBreakdown(v) return nil + case usagelog.FieldVideoCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoCount(v) + return nil + case usagelog.FieldVideoResolution: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoResolution(v) + return nil + case usagelog.FieldVideoDurationSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoDurationSeconds(v) + return nil case usagelog.FieldCacheTTLOverridden: v, ok := value.(bool) if !ok { @@ -44757,6 +44979,12 @@ func (m *UsageLogMutation) AddedFields() []string { if m.addimage_count != nil { fields = append(fields, usagelog.FieldImageCount) } + if m.addvideo_count != nil { + fields = append(fields, usagelog.FieldVideoCount) + } + if m.addvideo_duration_seconds != nil { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } return fields } @@ -44803,6 +45031,10 @@ func (m *UsageLogMutation) AddedField(name string) (ent.Value, bool) { return m.AddedFirstTokenMs() case usagelog.FieldImageCount: return m.AddedImageCount() + case usagelog.FieldVideoCount: + return m.AddedVideoCount() + case usagelog.FieldVideoDurationSeconds: + return m.AddedVideoDurationSeconds() } return nil, false } @@ -44945,6 +45177,20 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error { } m.AddImageCount(v) return nil + case usagelog.FieldVideoCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoCount(v) + return nil + case usagelog.FieldVideoDurationSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoDurationSeconds(v) + return nil } return fmt.Errorf("unknown UsageLog numeric field %s", name) } @@ -45007,6 +45253,12 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldImageSizeBreakdown) { fields = append(fields, usagelog.FieldImageSizeBreakdown) } + if m.FieldCleared(usagelog.FieldVideoResolution) { + fields = append(fields, usagelog.FieldVideoResolution) + } + if m.FieldCleared(usagelog.FieldVideoDurationSeconds) { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } return fields } @@ -45075,6 +45327,12 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldImageSizeBreakdown: m.ClearImageSizeBreakdown() return nil + case usagelog.FieldVideoResolution: + m.ClearVideoResolution() + return nil + case usagelog.FieldVideoDurationSeconds: + m.ClearVideoDurationSeconds() + return nil } return fmt.Errorf("unknown UsageLog nullable field %s", name) } @@ -45200,6 +45458,15 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldImageSizeBreakdown: m.ResetImageSizeBreakdown() return nil + case usagelog.FieldVideoCount: + m.ResetVideoCount() + return nil + case usagelog.FieldVideoResolution: + m.ResetVideoResolution() + return nil + case usagelog.FieldVideoDurationSeconds: + m.ResetVideoDurationSeconds() + return nil case usagelog.FieldCacheTTLOverridden: m.ResetCacheTTLOverridden() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index ddde08d23d..d47e7d143b 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1976,12 +1976,20 @@ func init() { usagelogDescImageSizeSource := usagelogFields[37].Descriptor() // usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error) + // usagelogDescVideoCount is the schema descriptor for video_count field. + usagelogDescVideoCount := usagelogFields[39].Descriptor() + // usagelog.DefaultVideoCount holds the default value on creation for the video_count field. + usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int) + // usagelogDescVideoResolution is the schema descriptor for video_resolution field. + usagelogDescVideoResolution := usagelogFields[40].Descriptor() + // usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. + usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[42].Descriptor() // usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field. usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[40].Descriptor() + usagelogDescCreatedAt := usagelogFields[43].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index db9e517892..e84cc1c140 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -149,6 +149,20 @@ func (UsageLog) Fields() []ent.Field { field.JSON("image_size_breakdown", map[string]int{}). Optional(). SchemaType(map[string]string{dialect.Postgres: "jsonb"}), + + // 视频生成字段(Grok 视频按秒计费;billing_mode 走 token/其他模式时这些列仍标记视频用量) + field.Int("video_count"). + Default(0). + Comment("视频生成数量;>0 表示本行是视频生成用量"), + field.String("video_resolution"). + MaxLen(10). + Optional(). + Nillable(). + Comment("计费用视频分辨率 480p/720p/1080p"), + field.Int("video_duration_seconds"). + Optional(). + Nillable(). + Comment("提交时请求的视频时长(秒),按秒计费的乘数"), // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) field.Bool("cache_ttl_overridden"). Default(false), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index 283fe828a9..4d374a8495 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -101,6 +101,12 @@ type UsageLog struct { ImageSizeSource *string `json:"image_size_source,omitempty"` // ImageSizeBreakdown holds the value of the "image_size_breakdown" field. ImageSizeBreakdown map[string]int `json:"image_size_breakdown,omitempty"` + // 视频生成数量;>0 表示本行是视频生成用量 + VideoCount int `json:"video_count,omitempty"` + // 计费用视频分辨率 480p/720p/1080p + VideoResolution *string `json:"video_resolution,omitempty"` + // 提交时请求的视频时长(秒),按秒计费的乘数 + VideoDurationSeconds *int `json:"video_duration_seconds,omitempty"` // CacheTTLOverridden holds the value of the "cache_ttl_overridden" field. CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"` // CreatedAt holds the value of the "created_at" field. @@ -194,9 +200,9 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier: values[i] = new(sql.NullFloat64) - case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount: + case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -474,6 +480,26 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field image_size_breakdown: %w", err) } } + case usagelog.FieldVideoCount: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field video_count", values[i]) + } else if value.Valid { + _m.VideoCount = int(value.Int64) + } + case usagelog.FieldVideoResolution: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field video_resolution", values[i]) + } else if value.Valid { + _m.VideoResolution = new(string) + *_m.VideoResolution = value.String + } + case usagelog.FieldVideoDurationSeconds: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field video_duration_seconds", values[i]) + } else if value.Valid { + _m.VideoDurationSeconds = new(int) + *_m.VideoDurationSeconds = int(value.Int64) + } case usagelog.FieldCacheTTLOverridden: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i]) @@ -698,6 +724,19 @@ func (_m *UsageLog) String() string { builder.WriteString("image_size_breakdown=") builder.WriteString(fmt.Sprintf("%v", _m.ImageSizeBreakdown)) builder.WriteString(", ") + builder.WriteString("video_count=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoCount)) + builder.WriteString(", ") + if v := _m.VideoResolution; v != nil { + builder.WriteString("video_resolution=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.VideoDurationSeconds; v != nil { + builder.WriteString("video_duration_seconds=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("cache_ttl_overridden=") builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden)) builder.WriteString(", ") diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index 297e0b41ad..a74a92c40f 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -92,6 +92,12 @@ const ( FieldImageSizeSource = "image_size_source" // FieldImageSizeBreakdown holds the string denoting the image_size_breakdown field in the database. FieldImageSizeBreakdown = "image_size_breakdown" + // FieldVideoCount holds the string denoting the video_count field in the database. + FieldVideoCount = "video_count" + // FieldVideoResolution holds the string denoting the video_resolution field in the database. + FieldVideoResolution = "video_resolution" + // FieldVideoDurationSeconds holds the string denoting the video_duration_seconds field in the database. + FieldVideoDurationSeconds = "video_duration_seconds" // FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database. FieldCacheTTLOverridden = "cache_ttl_overridden" // FieldCreatedAt holds the string denoting the created_at field in the database. @@ -187,6 +193,9 @@ var Columns = []string{ FieldImageOutputSize, FieldImageSizeSource, FieldImageSizeBreakdown, + FieldVideoCount, + FieldVideoResolution, + FieldVideoDurationSeconds, FieldCacheTTLOverridden, FieldCreatedAt, } @@ -260,6 +269,10 @@ var ( ImageOutputSizeValidator func(string) error // ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. ImageSizeSourceValidator func(string) error + // DefaultVideoCount holds the default value on creation for the "video_count" field. + DefaultVideoCount int + // VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. + VideoResolutionValidator func(string) error // DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field. DefaultCacheTTLOverridden bool // DefaultCreatedAt holds the default value on creation for the "created_at" field. @@ -464,6 +477,21 @@ func ByImageSizeSource(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImageSizeSource, opts...).ToFunc() } +// ByVideoCount orders the results by the video_count field. +func ByVideoCount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoCount, opts...).ToFunc() +} + +// ByVideoResolution orders the results by the video_resolution field. +func ByVideoResolution(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoResolution, opts...).ToFunc() +} + +// ByVideoDurationSeconds orders the results by the video_duration_seconds field. +func ByVideoDurationSeconds(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoDurationSeconds, opts...).ToFunc() +} + // ByCacheTTLOverridden orders the results by the cache_ttl_overridden field. func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index 2987f17930..4b08cc3425 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -245,6 +245,21 @@ func ImageSizeSource(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v)) } +// VideoCount applies equality check predicate on the "video_count" field. It's identical to VideoCountEQ. +func VideoCount(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v)) +} + +// VideoResolution applies equality check predicate on the "video_resolution" field. It's identical to VideoResolutionEQ. +func VideoResolution(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v)) +} + +// VideoDurationSeconds applies equality check predicate on the "video_duration_seconds" field. It's identical to VideoDurationSecondsEQ. +func VideoDurationSeconds(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v)) +} + // CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ. func CacheTTLOverridden(v bool) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) @@ -2150,6 +2165,171 @@ func ImageSizeBreakdownNotNil() predicate.UsageLog { return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeBreakdown)) } +// VideoCountEQ applies the EQ predicate on the "video_count" field. +func VideoCountEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v)) +} + +// VideoCountNEQ applies the NEQ predicate on the "video_count" field. +func VideoCountNEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoCount, v)) +} + +// VideoCountIn applies the In predicate on the "video_count" field. +func VideoCountIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoCount, vs...)) +} + +// VideoCountNotIn applies the NotIn predicate on the "video_count" field. +func VideoCountNotIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoCount, vs...)) +} + +// VideoCountGT applies the GT predicate on the "video_count" field. +func VideoCountGT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoCount, v)) +} + +// VideoCountGTE applies the GTE predicate on the "video_count" field. +func VideoCountGTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoCount, v)) +} + +// VideoCountLT applies the LT predicate on the "video_count" field. +func VideoCountLT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoCount, v)) +} + +// VideoCountLTE applies the LTE predicate on the "video_count" field. +func VideoCountLTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoCount, v)) +} + +// VideoResolutionEQ applies the EQ predicate on the "video_resolution" field. +func VideoResolutionEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v)) +} + +// VideoResolutionNEQ applies the NEQ predicate on the "video_resolution" field. +func VideoResolutionNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoResolution, v)) +} + +// VideoResolutionIn applies the In predicate on the "video_resolution" field. +func VideoResolutionIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoResolution, vs...)) +} + +// VideoResolutionNotIn applies the NotIn predicate on the "video_resolution" field. +func VideoResolutionNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoResolution, vs...)) +} + +// VideoResolutionGT applies the GT predicate on the "video_resolution" field. +func VideoResolutionGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoResolution, v)) +} + +// VideoResolutionGTE applies the GTE predicate on the "video_resolution" field. +func VideoResolutionGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoResolution, v)) +} + +// VideoResolutionLT applies the LT predicate on the "video_resolution" field. +func VideoResolutionLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoResolution, v)) +} + +// VideoResolutionLTE applies the LTE predicate on the "video_resolution" field. +func VideoResolutionLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoResolution, v)) +} + +// VideoResolutionContains applies the Contains predicate on the "video_resolution" field. +func VideoResolutionContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldVideoResolution, v)) +} + +// VideoResolutionHasPrefix applies the HasPrefix predicate on the "video_resolution" field. +func VideoResolutionHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldVideoResolution, v)) +} + +// VideoResolutionHasSuffix applies the HasSuffix predicate on the "video_resolution" field. +func VideoResolutionHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldVideoResolution, v)) +} + +// VideoResolutionIsNil applies the IsNil predicate on the "video_resolution" field. +func VideoResolutionIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldVideoResolution)) +} + +// VideoResolutionNotNil applies the NotNil predicate on the "video_resolution" field. +func VideoResolutionNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldVideoResolution)) +} + +// VideoResolutionEqualFold applies the EqualFold predicate on the "video_resolution" field. +func VideoResolutionEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldVideoResolution, v)) +} + +// VideoResolutionContainsFold applies the ContainsFold predicate on the "video_resolution" field. +func VideoResolutionContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldVideoResolution, v)) +} + +// VideoDurationSecondsEQ applies the EQ predicate on the "video_duration_seconds" field. +func VideoDurationSecondsEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsNEQ applies the NEQ predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsIn applies the In predicate on the "video_duration_seconds" field. +func VideoDurationSecondsIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoDurationSeconds, vs...)) +} + +// VideoDurationSecondsNotIn applies the NotIn predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNotIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoDurationSeconds, vs...)) +} + +// VideoDurationSecondsGT applies the GT predicate on the "video_duration_seconds" field. +func VideoDurationSecondsGT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsGTE applies the GTE predicate on the "video_duration_seconds" field. +func VideoDurationSecondsGTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsLT applies the LT predicate on the "video_duration_seconds" field. +func VideoDurationSecondsLT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsLTE applies the LTE predicate on the "video_duration_seconds" field. +func VideoDurationSecondsLTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsIsNil applies the IsNil predicate on the "video_duration_seconds" field. +func VideoDurationSecondsIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldVideoDurationSeconds)) +} + +// VideoDurationSecondsNotNil applies the NotNil predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldVideoDurationSeconds)) +} + // CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field. func CacheTTLOverriddenEQ(v bool) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 17e800f9ca..3326f72fc0 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -525,6 +525,48 @@ func (_c *UsageLogCreate) SetImageSizeBreakdown(v map[string]int) *UsageLogCreat return _c } +// SetVideoCount sets the "video_count" field. +func (_c *UsageLogCreate) SetVideoCount(v int) *UsageLogCreate { + _c.mutation.SetVideoCount(v) + return _c +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoCount(v *int) *UsageLogCreate { + if v != nil { + _c.SetVideoCount(*v) + } + return _c +} + +// SetVideoResolution sets the "video_resolution" field. +func (_c *UsageLogCreate) SetVideoResolution(v string) *UsageLogCreate { + _c.mutation.SetVideoResolution(v) + return _c +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoResolution(v *string) *UsageLogCreate { + if v != nil { + _c.SetVideoResolution(*v) + } + return _c +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_c *UsageLogCreate) SetVideoDurationSeconds(v int) *UsageLogCreate { + _c.mutation.SetVideoDurationSeconds(v) + return _c +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoDurationSeconds(v *int) *UsageLogCreate { + if v != nil { + _c.SetVideoDurationSeconds(*v) + } + return _c +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate { _c.mutation.SetCacheTTLOverridden(v) @@ -677,6 +719,10 @@ func (_c *UsageLogCreate) defaults() { v := usagelog.DefaultImageCount _c.mutation.SetImageCount(v) } + if _, ok := _c.mutation.VideoCount(); !ok { + v := usagelog.DefaultVideoCount + _c.mutation.SetVideoCount(v) + } if _, ok := _c.mutation.CacheTTLOverridden(); !ok { v := usagelog.DefaultCacheTTLOverridden _c.mutation.SetCacheTTLOverridden(v) @@ -817,6 +863,14 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if _, ok := _c.mutation.VideoCount(); !ok { + return &ValidationError{Name: "video_count", err: errors.New(`ent: missing required field "UsageLog.video_count"`)} + } + if v, ok := _c.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _, ok := _c.mutation.CacheTTLOverridden(); !ok { return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)} } @@ -995,6 +1049,18 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value) _node.ImageSizeBreakdown = value } + if value, ok := _c.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + _node.VideoCount = value + } + if value, ok := _c.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + _node.VideoResolution = &value + } + if value, ok := _c.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + _node.VideoDurationSeconds = &value + } if value, ok := _c.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _node.CacheTTLOverridden = value @@ -1830,6 +1896,66 @@ func (u *UsageLogUpsert) ClearImageSizeBreakdown() *UsageLogUpsert { return u } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsert) SetVideoCount(v int) *UsageLogUpsert { + u.Set(usagelog.FieldVideoCount, v) + return u +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoCount() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoCount) + return u +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsert) AddVideoCount(v int) *UsageLogUpsert { + u.Add(usagelog.FieldVideoCount, v) + return u +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsert) SetVideoResolution(v string) *UsageLogUpsert { + u.Set(usagelog.FieldVideoResolution, v) + return u +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoResolution() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoResolution) + return u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsert) ClearVideoResolution() *UsageLogUpsert { + u.SetNull(usagelog.FieldVideoResolution) + return u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsert) SetVideoDurationSeconds(v int) *UsageLogUpsert { + u.Set(usagelog.FieldVideoDurationSeconds, v) + return u +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoDurationSeconds() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoDurationSeconds) + return u +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsert) AddVideoDurationSeconds(v int) *UsageLogUpsert { + u.Add(usagelog.FieldVideoDurationSeconds, v) + return u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsert) ClearVideoDurationSeconds() *UsageLogUpsert { + u.SetNull(usagelog.FieldVideoDurationSeconds) + return u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert { u.Set(usagelog.FieldCacheTTLOverridden, v) @@ -2692,6 +2818,76 @@ func (u *UsageLogUpsertOne) ClearImageSizeBreakdown() *UsageLogUpsertOne { }) } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsertOne) SetVideoCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoCount(v) + }) +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsertOne) AddVideoCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoCount(v) + }) +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoCount() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoCount() + }) +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsertOne) SetVideoResolution(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoResolution(v) + }) +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoResolution() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoResolution() + }) +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsertOne) ClearVideoResolution() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoResolution() + }) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) SetVideoDurationSeconds(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoDurationSeconds(v) + }) +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) AddVideoDurationSeconds(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoDurationSeconds(v) + }) +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoDurationSeconds() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoDurationSeconds() + }) +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) ClearVideoDurationSeconds() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoDurationSeconds() + }) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -3722,6 +3918,76 @@ func (u *UsageLogUpsertBulk) ClearImageSizeBreakdown() *UsageLogUpsertBulk { }) } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsertBulk) SetVideoCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoCount(v) + }) +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsertBulk) AddVideoCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoCount(v) + }) +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoCount() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoCount() + }) +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsertBulk) SetVideoResolution(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoResolution(v) + }) +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoResolution() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoResolution() + }) +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsertBulk) ClearVideoResolution() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoResolution() + }) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) SetVideoDurationSeconds(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoDurationSeconds(v) + }) +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) AddVideoDurationSeconds(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoDurationSeconds(v) + }) +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoDurationSeconds() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoDurationSeconds() + }) +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) ClearVideoDurationSeconds() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoDurationSeconds() + }) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index e8fa003c63..00a65ccff1 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -811,6 +811,74 @@ func (_u *UsageLogUpdate) ClearImageSizeBreakdown() *UsageLogUpdate { return _u } +// SetVideoCount sets the "video_count" field. +func (_u *UsageLogUpdate) SetVideoCount(v int) *UsageLogUpdate { + _u.mutation.ResetVideoCount() + _u.mutation.SetVideoCount(v) + return _u +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoCount(v *int) *UsageLogUpdate { + if v != nil { + _u.SetVideoCount(*v) + } + return _u +} + +// AddVideoCount adds value to the "video_count" field. +func (_u *UsageLogUpdate) AddVideoCount(v int) *UsageLogUpdate { + _u.mutation.AddVideoCount(v) + return _u +} + +// SetVideoResolution sets the "video_resolution" field. +func (_u *UsageLogUpdate) SetVideoResolution(v string) *UsageLogUpdate { + _u.mutation.SetVideoResolution(v) + return _u +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoResolution(v *string) *UsageLogUpdate { + if v != nil { + _u.SetVideoResolution(*v) + } + return _u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (_u *UsageLogUpdate) ClearVideoResolution() *UsageLogUpdate { + _u.mutation.ClearVideoResolution() + return _u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_u *UsageLogUpdate) SetVideoDurationSeconds(v int) *UsageLogUpdate { + _u.mutation.ResetVideoDurationSeconds() + _u.mutation.SetVideoDurationSeconds(v) + return _u +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdate { + if v != nil { + _u.SetVideoDurationSeconds(*v) + } + return _u +} + +// AddVideoDurationSeconds adds value to the "video_duration_seconds" field. +func (_u *UsageLogUpdate) AddVideoDurationSeconds(v int) *UsageLogUpdate { + _u.mutation.AddVideoDurationSeconds(v) + return _u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (_u *UsageLogUpdate) ClearVideoDurationSeconds() *UsageLogUpdate { + _u.mutation.ClearVideoDurationSeconds() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate { _u.mutation.SetCacheTTLOverridden(v) @@ -979,6 +1047,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if v, ok := _u.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -1210,6 +1283,27 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.ImageSizeBreakdownCleared() { _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) } + if value, ok := _u.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoCount(); ok { + _spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + } + if _u.mutation.VideoResolutionCleared() { + _spec.ClearField(usagelog.FieldVideoResolution, field.TypeString) + } + if value, ok := _u.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok { + _spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if _u.mutation.VideoDurationSecondsCleared() { + _spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } @@ -2157,6 +2251,74 @@ func (_u *UsageLogUpdateOne) ClearImageSizeBreakdown() *UsageLogUpdateOne { return _u } +// SetVideoCount sets the "video_count" field. +func (_u *UsageLogUpdateOne) SetVideoCount(v int) *UsageLogUpdateOne { + _u.mutation.ResetVideoCount() + _u.mutation.SetVideoCount(v) + return _u +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoCount(v *int) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoCount(*v) + } + return _u +} + +// AddVideoCount adds value to the "video_count" field. +func (_u *UsageLogUpdateOne) AddVideoCount(v int) *UsageLogUpdateOne { + _u.mutation.AddVideoCount(v) + return _u +} + +// SetVideoResolution sets the "video_resolution" field. +func (_u *UsageLogUpdateOne) SetVideoResolution(v string) *UsageLogUpdateOne { + _u.mutation.SetVideoResolution(v) + return _u +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoResolution(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoResolution(*v) + } + return _u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (_u *UsageLogUpdateOne) ClearVideoResolution() *UsageLogUpdateOne { + _u.mutation.ClearVideoResolution() + return _u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) SetVideoDurationSeconds(v int) *UsageLogUpdateOne { + _u.mutation.ResetVideoDurationSeconds() + _u.mutation.SetVideoDurationSeconds(v) + return _u +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoDurationSeconds(*v) + } + return _u +} + +// AddVideoDurationSeconds adds value to the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) AddVideoDurationSeconds(v int) *UsageLogUpdateOne { + _u.mutation.AddVideoDurationSeconds(v) + return _u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) ClearVideoDurationSeconds() *UsageLogUpdateOne { + _u.mutation.ClearVideoDurationSeconds() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne { _u.mutation.SetCacheTTLOverridden(v) @@ -2338,6 +2500,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if v, ok := _u.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -2586,6 +2753,27 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.ImageSizeBreakdownCleared() { _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) } + if value, ok := _u.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoCount(); ok { + _spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + } + if _u.mutation.VideoResolutionCleared() { + _spec.ClearField(usagelog.FieldVideoResolution, field.TypeString) + } + if value, ok := _u.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok { + _spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if _u.mutation.VideoDurationSecondsCleared() { + _spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 3235c7404a..7ef98dd700 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -49,6 +49,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "image_output_size", "character varying", 32, true) requireColumn(t, tx, "usage_logs", "image_size_source", "character varying", 16, true) requireColumn(t, tx, "usage_logs", "image_size_breakdown", "jsonb", 0, true) + requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false) + requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true) + requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true) requireConstraintDefinitionContains( t, tx, @@ -68,6 +71,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { "image_count", "billing_mode", "'video'", + "video_count", "image_size IS NOT NULL", "'1K'", "'2K'", diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index bb978ddc1a..dfd8969512 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -63,6 +63,9 @@ var usageLogInsertArgTypes = [...]string{ "text", // image_output_size "text", // image_size_source "jsonb", // image_size_breakdown + "integer", // video_count + "text", // video_resolution + "integer", // video_duration_seconds "text", // service_tier "text", // reasoning_effort "text", // inbound_endpoint @@ -252,6 +255,9 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -269,7 +275,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -700,6 +706,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -713,7 +722,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*50) + args := make([]any, 0, len(keys)*53) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -781,6 +790,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -833,6 +845,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -925,6 +940,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -938,7 +956,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*50) + args := make([]any, 0, len(preparedList)*53) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1003,6 +1021,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1055,6 +1076,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1115,6 +1139,9 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1132,7 +1159,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1163,6 +1190,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { imageOutputSize := nullString(log.ImageOutputSize) imageSizeSource := nullString(log.ImageSizeSource) imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown) + videoResolution := nullString(log.VideoResolution) + videoDurationSeconds := nullInt(log.VideoDurationSeconds) serviceTier := nullString(log.ServiceTier) reasoningEffort := nullString(log.ReasoningEffort) inboundEndpoint := nullString(log.InboundEndpoint) @@ -1227,6 +1256,9 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { imageOutputSize, imageSizeSource, imageSizeBreakdown, + log.VideoCount, + videoResolution, + videoDurationSeconds, serviceTier, reasoningEffort, inboundEndpoint, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 0ff4aeb936..c178429bab 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -465,6 +465,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e imageOutputSize sql.NullString imageSizeSource sql.NullString imageSizeBreakdown sql.NullString + videoCount int + videoResolution sql.NullString + videoDurationSeconds sql.NullInt64 serviceTier sql.NullString reasoningEffort sql.NullString inboundEndpoint sql.NullString @@ -519,6 +522,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &imageOutputSize, &imageSizeSource, &imageSizeBreakdown, + &videoCount, + &videoResolution, + &videoDurationSeconds, &serviceTier, &reasoningEffort, &inboundEndpoint, @@ -560,6 +566,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e BillingType: int8(billingType), RequestType: service.RequestTypeFromInt16(requestTypeRaw), ImageCount: imageCount, + VideoCount: videoCount, CacheTTLOverridden: cacheTTLOverridden, CreatedAt: createdAt, } @@ -607,6 +614,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e log.ImageSizeSource = &imageSizeSource.String } log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown) + if videoResolution.Valid { + log.VideoResolution = &videoResolution.String + } + if videoDurationSeconds.Valid { + value := int(videoDurationSeconds.Int64) + log.VideoDurationSeconds = &value + } if serviceTier.Valid { log.ServiceTier = &serviceTier.String } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 4a32557e71..c32ad2b63f 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -80,6 +80,9 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // image_output_size sqlmock.AnyArg(), // image_size_source sqlmock.AnyArg(), // image_size_breakdown + sqlmock.AnyArg(), // video_count + sqlmock.AnyArg(), // video_resolution + sqlmock.AnyArg(), // video_duration_seconds sqlmock.AnyArg(), // service_tier sqlmock.AnyArg(), // reasoning_effort sqlmock.AnyArg(), // inbound_endpoint @@ -163,6 +166,9 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // image_output_size sqlmock.AnyArg(), // image_size_source sqlmock.AnyArg(), // image_size_breakdown + sqlmock.AnyArg(), // video_count + sqlmock.AnyArg(), // video_resolution + sqlmock.AnyArg(), // video_duration_seconds serviceTier, sqlmock.AnyArg(), sqlmock.AnyArg(), @@ -799,6 +805,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{Valid: true, String: "3840x2160"}, sql.NullString{Valid: true, String: "output"}, sql.NullString{Valid: true, String: `{"4K":2}`}, + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{}, sql.NullString{}, sql.NullString{}, @@ -867,6 +876,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, @@ -919,6 +931,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "flex"}, sql.NullString{}, sql.NullString{}, @@ -971,6 +986,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a17b68fe12..4b17ab3c07 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1231,11 +1231,11 @@ type ImagePriceConfig struct { Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值) } -// VideoPriceConfig 视频生成计费配置。 +// VideoPriceConfig 视频生成计费配置。所有价格均为**每秒**单价(USD/s),与 xAI 官方计费口径一致。 type VideoPriceConfig struct { - Price480P *float64 // 480p 视频价格(nil 表示使用默认值) - Price720P *float64 // 720p 视频价格(nil 表示使用默认值) - Price1080P *float64 // 1080p 视频价格(nil 表示使用默认值) + Price480P *float64 // 480p 每秒价格(nil 表示使用默认值) + Price720P *float64 // 720p 每秒价格(nil 表示使用默认值) + Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值) } const ( @@ -1246,6 +1246,7 @@ const ( defaultGrokImagineImageQualityPrice1K = 0.05 defaultGrokImagineImageQualityPrice2K = 0.07 + // 视频默认价为 xAI 官方**每秒**输出价格(USD/s),总价 = 每秒价 × 时长(秒)。 defaultGrokImagineVideoPrice480P = 0.05 defaultGrokImagineVideoPrice720P = 0.07 defaultGrokImagineVideo15Price480P = 0.08 @@ -1284,20 +1285,22 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag } } -// CalculateVideoCost 计算视频生成费用。 +// CalculateVideoCost 计算视频生成费用(按秒计费,与 xAI 口径一致)。 // model: 请求的模型名称(用于获取默认价格) // resolution: 视频分辨率 "480p", "720p", "1080p" // videoCount: 生成的视频数量 -// groupConfig: 分组配置的价格(可能为 nil,表示使用默认值) +// durationSeconds: 单个视频时长(秒),<=0 时按上游默认时长计 +// groupConfig: 分组配置的每秒价格(可能为 nil,表示使用默认值) // rateMultiplier: 费率倍数 -func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown { +func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, durationSeconds int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown { if videoCount <= 0 { return &CostBreakdown{} } resolution = NormalizeVideoBillingResolutionOrDefault(resolution) + durationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds) - unitPrice := s.getVideoUnitPrice(model, resolution, groupConfig) - totalCost := unitPrice * float64(videoCount) + perSecondPrice := s.getVideoUnitPrice(model, resolution, groupConfig) + totalCost := perSecondPrice * float64(durationSeconds) * float64(videoCount) if rateMultiplier < 0 { rateMultiplier = 0 @@ -1394,8 +1397,9 @@ func (s *BillingService) getDefaultVideoPrice(model string, resolution string) f } // The bundled LiteLLM schema does not expose an output video generation price. - // Keep the historical model default as the fallback, while letting group-level - // video prices override it independently from image prices. + // Keep the historical model default as the fallback (interpreted as a per-second + // rate; today only Grok models reach video billing, so this path is a safety net), + // while letting group-level video prices override it independently from image prices. return s.getDefaultImagePrice(model, ImageBillingSize2K) } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index f4c4213533..954de478d9 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -878,14 +878,29 @@ func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) { imagePrice := 0.4 videoPrice := 0.08 imageCost := svc.CalculateImageCost("grok-imagine-video", "2K", 1, &ImagePriceConfig{Price2K: &imagePrice}, 1.0) - videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, &VideoPriceConfig{Price480P: &videoPrice}, 0.5) + videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 10, &VideoPriceConfig{Price480P: &videoPrice}, 0.5) require.InDelta(t, 0.4, imageCost.TotalCost, 1e-10) - require.InDelta(t, 0.08, videoCost.TotalCost, 1e-10) - require.InDelta(t, 0.04, videoCost.ActualCost, 1e-10) + require.InDelta(t, 0.8, videoCost.TotalCost, 1e-10) + require.InDelta(t, 0.4, videoCost.ActualCost, 1e-10) require.Equal(t, string(BillingModeVideo), videoCost.BillingMode) } +func TestCalculateVideoCostBillsPerSecond(t *testing.T) { + svc := newTestBillingService() + + oneSecond := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0) + fifteenSeconds := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 15, nil, 1.0) + // duration <=0 时按上游默认 8 秒计费,超出上限按 15 秒收敛。 + defaultDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 0, nil, 1.0) + clampedDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 999, nil, 1.0) + + require.InDelta(t, 0.07, oneSecond.TotalCost, 1e-10) + require.InDelta(t, 0.07*15, fifteenSeconds.TotalCost, 1e-10) + require.InDelta(t, 0.07*8, defaultDuration.TotalCost, 1e-10) + require.InDelta(t, 0.07*15, clampedDuration.TotalCost, 1e-10) +} + func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { svc := newTestBillingService() @@ -903,11 +918,12 @@ func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) { svc := newTestBillingService() - standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, nil, 1.0) - standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, nil, 1.0) - video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, nil, 1.0) - video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, nil, 1.0) - video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, nil, 1.0) + // 默认价目为 xAI 官方每秒价格,按 1 秒时长验证每秒单价。 + standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 1, nil, 1.0) + standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0) + video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, 1, nil, 1.0) + video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, 1, nil, 1.0) + video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, 1, nil, 1.0) require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10) require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 70e439e46e..40ad54276c 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -43,16 +43,17 @@ func (e GrokMediaEndpoint) IsGenerationRequest() bool { } type GrokMediaRequestInfo struct { - Model string - Prompt string - N int - Size string - SizeTier string - Resolution string - InputImageURLs []string - MaskImageURL string - Uploads []OpenAIImagesUpload - MaskUpload *OpenAIImagesUpload + Model string + Prompt string + N int + Size string + SizeTier string + Resolution string + DurationSeconds int + InputImageURLs []string + MaskImageURL string + Uploads []OpenAIImagesUpload + MaskUpload *OpenAIImagesUpload } func (r GrokMediaRequestInfo) ModerationBody() []byte { @@ -116,6 +117,7 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo info.Size = strings.TrimSpace(info.Size) info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution) + info.DurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(info.DurationSeconds) if info.N <= 0 { info.N = 1 } @@ -130,6 +132,9 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String()) + if duration := gjson.GetBytes(body, "duration"); duration.Exists() && duration.Type == gjson.Number { + info.DurationSeconds = int(duration.Int()) + } if n := gjson.GetBytes(body, "n"); n.Exists() && n.Type == gjson.Number { info.N = int(n.Int()) } @@ -231,6 +236,10 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM info.Size = value case "resolution": info.Resolution = value + case "duration": + if duration, err := strconv.Atoi(value); err == nil { + info.DurationSeconds = duration + } case "n": if n, err := strconv.Atoi(value); err == nil { info.N = n @@ -356,20 +365,21 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) usage := grokMediaUsageFromResponse(endpoint, requestInfo, respBody) return &OpenAIForwardResult{ - RequestID: requestIDHeader, - ResponseID: usage.ResponseID, - Usage: usage.Usage, - Model: requestModel, - BillingModel: requestModel, - UpstreamModel: requestModel, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - ImageCount: usage.ImageCount, - ImageSize: usage.ImageSize, - ImageInputSize: usage.ImageInputSize, - ImageOutputSizes: usage.ImageOutputSizes, - VideoCount: usage.VideoCount, - VideoResolution: usage.VideoResolution, + RequestID: requestIDHeader, + ResponseID: usage.ResponseID, + Usage: usage.Usage, + Model: requestModel, + BillingModel: requestModel, + UpstreamModel: requestModel, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + ImageCount: usage.ImageCount, + ImageSize: usage.ImageSize, + ImageInputSize: usage.ImageInputSize, + ImageOutputSizes: usage.ImageOutputSizes, + VideoCount: usage.VideoCount, + VideoResolution: usage.VideoResolution, + VideoDurationSeconds: usage.VideoDurationSeconds, }, nil } @@ -472,14 +482,15 @@ func normalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string } type grokMediaUsageMetadata struct { - ResponseID string - Usage OpenAIUsage - ImageCount int - ImageSize string - ImageInputSize string - ImageOutputSizes []string - VideoCount int - VideoResolution string + ResponseID string + Usage OpenAIUsage + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSizes []string + VideoCount int + VideoResolution string + VideoDurationSeconds int } func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMediaRequestInfo, responseBody []byte) grokMediaUsageMetadata { @@ -502,6 +513,7 @@ func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMedi meta.ResponseID = extractGrokMediaVideoRequestID(responseBody) meta.VideoCount = 1 meta.VideoResolution = requestInfo.Resolution + meta.VideoDurationSeconds = requestInfo.DurationSeconds // Keep the legacy media-unit counter populated for existing usage displays. meta.ImageCount = 1 } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index c7d1cd552c..e47ffeb6cb 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -337,7 +337,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p"}`) + body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p","duration":10}`) c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -365,7 +365,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) - require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p"}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) require.Equal(t, "video-request-123", result.ResponseID) require.Equal(t, "grok-imagine-video", result.BillingModel) require.Equal(t, 3, result.Usage.InputTokens) @@ -374,6 +374,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) require.Empty(t, result.ImageSize) require.Equal(t, 1, result.VideoCount) require.Equal(t, VideoBillingResolution720P, result.VideoResolution) + require.Equal(t, 10, result.VideoDurationSeconds) } func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) { @@ -412,6 +413,8 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"image_url":"data:image/png;base64,aW1n"}}`, string(upstream.lastBody)) require.Equal(t, "video-request-456", result.ResponseID) require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) + // 未指定 duration 时按上游默认 8 秒计费。 + require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds) } func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index c3622a82e9..b9bbd137c6 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1813,14 +1813,15 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "video-request-123", - ResponseID: "video-request-123", - Model: "grok-imagine-video-1.5", - BillingModel: "grok-imagine-video-1.5", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution480P, - Duration: time.Second, + RequestID: "video-request-123", + ResponseID: "video-request-123", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution480P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10126, @@ -1851,6 +1852,11 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { require.InDelta(t, 0.25, usageRepo.lastLog.RateMultiplier, 1e-12) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoResolution) + require.Equal(t, VideoBillingResolution480P, *usageRepo.lastLog.VideoResolution) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, 1, *usageRepo.lastLog.VideoDurationSeconds) } func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing.T) { @@ -1885,11 +1891,15 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) require.Nil(t, usageRepo.lastLog.ImageSize) - require.InDelta(t, 0.14, usageRepo.lastLog.TotalCost, 1e-12) - require.InDelta(t, 0.14, usageRepo.lastLog.ActualCost, 1e-12) + // 结果未携带 duration 时按上游默认 8 秒计费:0.14 USD/s × 8s。 + require.InDelta(t, 0.14*8, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.14*8, usageRepo.lastLog.ActualCost, 1e-12) require.Equal(t, 1, usageRepo.lastLog.ImageCount) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, VideoBillingDefaultDurationSeconds, *usageRepo.lastLog.VideoDurationSeconds) } func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { @@ -1945,13 +1955,14 @@ func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePri err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "resp_grok_video_group_price", - Model: "grok-imagine-video", - BillingModel: "grok-imagine-video", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution720P, - Duration: time.Second, + RequestID: "resp_grok_video_group_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10128, @@ -2046,13 +2057,14 @@ func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshot err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "resp_grok_video_hydrated_price", - Model: "grok-imagine-video", - BillingModel: "grok-imagine-video", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution720P, - Duration: time.Second, + RequestID: "resp_grok_video_hydrated_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10131, @@ -2075,6 +2087,53 @@ func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshot require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) } +// 视频请求命中渠道 token 计费时走 token 路径;此时行是 billing_mode='token'、image_count=1、 +// image_size=NULL,必须携带 video_count>0 才能通过 usage_logs 的 image_size check 约束 +// (迁移 172),否则整个计费事务会因约束违反而丢失。 +func TestOpenAIGatewayServiceRecordUsage_GrokVideoWithTokenChannelPricingKeepsVideoMetadata(t *testing.T) { + groupID := int64(132) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_video_token_channel", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 5, + Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 200}, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10132, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 20132}, + Account: &Account{ID: 30132, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoResolution) + require.Equal(t, VideoBillingResolution720P, *usageRepo.lastLog.VideoResolution) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, 5, *usageRepo.lastLog.VideoDurationSeconds) +} + func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) { groupID := int64(123) usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 822a5f65d3..af8ae3ffe5 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -244,6 +244,8 @@ type OpenAIForwardResult struct { ImageSizeBreakdown map[string]int VideoCount int VideoResolution string + // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。 + VideoDurationSeconds int wsReplayInput []json.RawMessage wsReplayInputExists bool diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 569a7a74ba..4a16facccd 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -243,6 +243,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ImageSizeBreakdown: result.ImageSizeBreakdown, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) + if isVideoUsage { + usageLog.VideoCount = result.VideoCount + usageLog.VideoResolution = optionalTrimmedStringPtr(NormalizeVideoBillingResolutionOrDefault(result.VideoResolution)) + videoDurationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) + usageLog.VideoDurationSeconds = &videoDurationSeconds + } if cost != nil { usageLog.InputCost = cost.InputCost usageLog.OutputCost = cost.OutputCost @@ -499,19 +505,21 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( videoCount = 1 } resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution) + durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) groupConfig := videoPriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { apiKey = refreshed groupConfig = videoPriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } } if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { + // 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。 gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, @@ -530,13 +538,16 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err) } - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey { if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 { return apiKey } + if !groupMediaPricingLooksIncomplete(apiKey.Group) { + return apiKey + } if s == nil || s.channelService == nil || s.channelService.groupRepo == nil { return apiKey } @@ -549,6 +560,24 @@ func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Cont return &clone } +// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体计费字段(例如由不含 +// 这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在数据库中的 +// 默认值均为 1.0,正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、全部媒体 +// 价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条媒体用量都多打一次 DB 查询。 +func groupMediaPricingLooksIncomplete(group *Group) bool { + if group == nil { + return true + } + if group.ImageRateIndependent || group.VideoRateIndependent { + return false + } + if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 { + return false + } + return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil && + group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil +} + func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { if s.resolver == nil || apiKey == nil || apiKey.Group == nil { return nil diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 66cdb62b2b..62e48fc8f9 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -175,6 +175,11 @@ type UsageLog struct { ImageSizeBreakdown map[string]int MediaType *string + // 视频生成字段(Grok 视频按秒计费;video_count>0 的行不要求 image_size) + VideoCount int + VideoResolution *string + VideoDurationSeconds *int + CreatedAt time.Time User *User diff --git a/backend/internal/service/video_billing_resolution.go b/backend/internal/service/video_billing_resolution.go index bca068f097..cb713f6877 100644 --- a/backend/internal/service/video_billing_resolution.go +++ b/backend/internal/service/video_billing_resolution.go @@ -8,6 +8,29 @@ const ( VideoBillingResolution1080P = "1080p" ) +// xAI 视频生成按秒计费,duration 请求参数允许 1-15 秒;未指定时上游默认生成 8 秒。 +// 计费时长必须与上游实际消耗对齐,否则用户可通过拉长 duration 套利(提交时长由用户控制)。 +const ( + VideoBillingMinDurationSeconds = 1 + VideoBillingMaxDurationSeconds = 15 + VideoBillingDefaultDurationSeconds = 8 +) + +// NormalizeVideoBillingDurationSecondsOrDefault 归一化计费用视频时长: +// 未指定(<=0)按上游默认 8 秒计,超出上游允许区间按边界收敛。 +func NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds int) int { + if durationSeconds <= 0 { + return VideoBillingDefaultDurationSeconds + } + if durationSeconds < VideoBillingMinDurationSeconds { + return VideoBillingMinDurationSeconds + } + if durationSeconds > VideoBillingMaxDurationSeconds { + return VideoBillingMaxDurationSeconds + } + return durationSeconds +} + func NormalizeVideoBillingResolutionOrDefault(resolution string) string { switch strings.ToLower(strings.TrimSpace(resolution)) { case "480", "480p", "sd": diff --git a/backend/migrations/172_video_per_second_billing_metadata.sql b/backend/migrations/172_video_per_second_billing_metadata.sql new file mode 100644 index 0000000000..e54b83dafe --- /dev/null +++ b/backend/migrations/172_video_per_second_billing_metadata.sql @@ -0,0 +1,38 @@ +-- Grok video billing is per second of generated output (xAI rate card), so usage +-- rows must record the billed resolution and duration for auditability. The +-- image-size check constraint must also exempt any video row by video_count +-- instead of billing_mode='video' alone: a video request billed through a +-- token-mode channel price produces billing_mode='token' with image_count=1 +-- (legacy media counter) and no image_size, which the previous constraint +-- rejected and silently dropped the whole billing transaction. + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS video_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS video_resolution VARCHAR(10), + ADD COLUMN IF NOT EXISTS video_duration_seconds INTEGER; + +COMMENT ON COLUMN usage_logs.video_count IS '视频生成数量;>0 表示本行是视频生成用量'; +COMMENT ON COLUMN usage_logs.video_resolution IS '计费用视频分辨率 480p/720p/1080p'; +COMMENT ON COLUMN usage_logs.video_duration_seconds IS '提交时请求的视频时长(秒),按秒计费的乘数'; + +ALTER TABLE usage_logs + DROP CONSTRAINT IF EXISTS usage_logs_image_billing_size_check; + +ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_image_billing_size_check + CHECK ( + image_count <= 0 + OR billing_mode = 'video' + OR COALESCE(video_count, 0) > 0 + OR ( + image_size IS NOT NULL + AND image_size IN ('1K', '2K', '4K', 'mixed') + ) + ) NOT VALID; + +-- Group video prices are per-second rates (USD/s), matching the xAI rate card; +-- total cost = per-second price x duration seconds. Clarify the column docs +-- introduced by migration 170, which read as per-video prices. +COMMENT ON COLUMN groups.video_price_480p IS '480p 视频生成每秒单价 (USD/s),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_720p IS '720p 视频生成每秒单价 (USD/s),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_1080p IS '1080p 视频生成每秒单价 (USD/s),Grok 平台使用'; diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 545135d3a1..ffe9723126 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -841,26 +841,15 @@ export default { finalPricePreview: 'Final per-image price preview', notConfigured: 'Not configured' }, - mediaPricing: { - title: 'Image / Video Generation Pricing', - description: - 'Configure Grok image and video generation access plus base media prices. Leave empty to use default prices.', - allowImageGeneration: 'Allow image and video generation for this group', - independentMultiplier: 'Use independent media multiplier', - imageMultiplier: 'Media multiplier', - modeHint: - 'By default, Grok media billing uses media price × current effective group multiplier. Independent mode uses media price × media multiplier. One video generation is billed as one media unit.', - finalPricePreview: 'Final per-media-unit price preview', - notConfigured: 'Not configured' - }, videoPricing: { title: 'Video Generation Pricing', - description: 'Configure Grok video generation base prices. Leave empty to use default video prices.', + description: + 'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).', independentMultiplier: 'Use independent video multiplier', videoMultiplier: 'Video multiplier', modeHint: - 'By default, video billing uses video price × current effective group multiplier. Independent mode uses video price × video multiplier.', - finalPricePreview: 'Final per-video price preview', + 'Videos are billed per second: per-second price × duration (1-15s, default 8s). By default the current effective group multiplier applies; independent mode uses the video multiplier instead.', + finalPricePreview: 'Final per-second price preview', notConfigured: 'Not configured' }, peakRate: { diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b56fcde5d0..a40fe4afe2 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -919,25 +919,15 @@ export default { finalPricePreview: '最终单张价格预览', notConfigured: '未配置' }, - mediaPricing: { - title: '图片/视频生成计费', - description: '配置 Grok 图片和视频生成能力及媒体基础单价,留空则使用默认价格', - allowImageGeneration: '允许当前分组生图和视频生成', - independentMultiplier: '媒体倍率独立', - imageMultiplier: '媒体独立倍率', - modeHint: - '默认关闭独立倍率时,Grok 媒体费用 = 媒体价格 × 当前分组有效倍率;开启独立倍率后,Grok 媒体费用 = 媒体价格 × 媒体独立倍率。一次视频生成按 1 个媒体单位计费。', - finalPricePreview: '最终单次媒体价格预览', - notConfigured: '未配置' - }, videoPricing: { title: '视频生成计费', - description: '配置 Grok 视频生成基础单价,留空则使用默认视频价格', + description: + '配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)', independentMultiplier: '视频倍率独立', videoMultiplier: '视频独立倍率', modeHint: - '默认关闭独立倍率时,视频费用 = 视频价格 × 当前分组有效倍率;开启独立倍率后,视频费用 = 视频价格 × 视频独立倍率。', - finalPricePreview: '最终单次视频价格预览', + '视频按秒计费:费用 = 每秒价格 × 时长(1-15 秒,未指定默认 8 秒)。默认叠加当前分组有效倍率;开启独立倍率后改用视频独立倍率。', + finalPricePreview: '最终每秒价格预览', notConfigured: '未配置' }, peakRate: { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index ef75e6ab97..1f22216100 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1010,7 +1010,7 @@
- +
- +
- +
- +
- +
- + { requestData.video_rate_multiplier = normalizeRateMultiplier( requestData.video_rate_multiplier, ); + // 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400), + // 创建时按"未配置"(null)处理。 + requestData.image_price_1k = emptyToNull(requestData.image_price_1k); + requestData.image_price_2k = emptyToNull(requestData.image_price_2k); + requestData.image_price_4k = emptyToNull(requestData.image_price_4k); + requestData.video_price_480p = emptyToNull(requestData.video_price_480p); + requestData.video_price_720p = emptyToNull(requestData.video_price_720p); + requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p); requestData.peak_rate_enabled = createForm.peak_rate_enabled; requestData.peak_start = createForm.peak_start; requestData.peak_end = createForm.peak_end; @@ -4896,6 +4904,16 @@ const handleUpdateGroup = async () => { payload.video_rate_multiplier = normalizeRateMultiplier( payload.video_rate_multiplier, ); + // 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400)。 + // 更新语义中 null 表示"不修改",因此清空后的字段发送 -1:后端 normalizePrice 将负价归一为 + // NULL,从而真正清除已配置的价格。 + const emptyPriceToClear = (v: any) => (v === "" || v === null ? -1 : v); + payload.image_price_1k = emptyPriceToClear(payload.image_price_1k); + payload.image_price_2k = emptyPriceToClear(payload.image_price_2k); + payload.image_price_4k = emptyPriceToClear(payload.image_price_4k); + payload.video_price_480p = emptyPriceToClear(payload.video_price_480p); + payload.video_price_720p = emptyPriceToClear(payload.video_price_720p); + payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p); payload.peak_rate_enabled = editForm.peak_rate_enabled; payload.peak_start = editForm.peak_start; payload.peak_end = editForm.peak_end; diff --git a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts index e83e22319d..eadd5d711c 100644 --- a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts @@ -37,8 +37,10 @@ describe("groups image pricing platform support", () => { it("uses Grok media defaults instead of generic image fallback placeholders", () => { expect(getImagePricePlaceholder("grok", "image_price_1k")).toBe("0.02"); expect(getImagePricePlaceholder("grok", "image_price_2k")).toBe("0.02"); - expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.08"); - expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.14"); + // 视频 placeholder 为每秒单价:480p/720p 取 grok-imagine-video 官方每秒价, + // 1080p 仅 video-1.5 支持、取 1.5 每秒价。 + expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.05"); + expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.07"); expect(getVideoPricePlaceholder("grok", "video_price_1080p")).toBe("0.25"); }); diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts index d88c479995..7ac4fafd07 100644 --- a/frontend/src/views/admin/groupsImagePricing.ts +++ b/frontend/src/views/admin/groupsImagePricing.ts @@ -39,13 +39,15 @@ const defaultImagePricePlaceholders: Record< }, }; +// 视频价为每秒单价(USD/s)。480p/720p 取 grok-imagine-video(文生视频实际走该模型)的 +// 官方每秒价;1080p 仅 grok-imagine-video-1.5 图生视频支持,取 1.5 的每秒价。 const defaultVideoPricePlaceholders: Record< string, Record > = { grok: { - video_price_480p: "0.08", - video_price_720p: "0.14", + video_price_480p: "0.05", + video_price_720p: "0.07", video_price_1080p: "0.25", }, }; From 7468427e447a5264640e7ebcc23907af476cedf8 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 15:49:10 +0800 Subject: [PATCH 27/29] =?UTF-8?q?fix(messages):=20/v1/messages=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E5=B1=82=E9=94=99=E8=AF=AF=E5=AF=B9=E9=BD=90=20failov?= =?UTF-8?q?er=20=E9=93=BE=E8=B7=AF=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=20502?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3850 (part 2) --- .../service/openai_gateway_messages.go | 13 +-- ...ateway_messages_transport_failover_test.go | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/openai_gateway_messages_transport_failover_test.go diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 8c5c801944..92ee773166 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -302,18 +302,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_gateway_messages_transport_failover_test.go b/backend/internal/service/openai_gateway_messages_transport_failover_test.go new file mode 100644 index 0000000000..a61da140f5 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_transport_failover_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestForwardAsAnthropic_TransportError_ReturnsFailoverError(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`), + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "transport error should return UpstreamFailoverError for handler failover, got: %T", err) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) +} + +func TestForwardAsAnthropic_TransportError_DoesNotWriteResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: errors.New(`read tcp: connection reset by peer`), + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Equal(t, http.StatusOK, rec.Code, "transport error must not write HTTP response — handler owns the response for failover") + require.Empty(t, rec.Body.String(), "response body must be empty so handler can write the correct error or failover") +} + +func TestForwardAsAnthropic_TransportError_ClientCanceled_NoFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)).WithContext(cancelCtx) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: context.Canceled, + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "client-canceled transport error should NOT trigger failover") +} From 243678e166f82e2643d448b9df98684ca61df217 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Thu, 9 Jul 2026 15:50:58 +0800 Subject: [PATCH 28/29] Fix Grok 4.5 alias test expectations --- backend/internal/service/openai_gateway_grok_test.go | 12 ++++++------ .../internal/service/openai_ws_http_bridge_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index b0223c8580..cf6ad9ee29 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -592,9 +592,9 @@ func TestForwardAsChatCompletionsForGrokUsesXAIChatCompletionsAndSnapshots(t *te require.NoError(t, err) require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.3", result.UpstreamModel) + require.Equal(t, "grok-4.5", result.UpstreamModel) require.Equal(t, 1, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.NotNil(t, repo.updates[51][grokQuotaSnapshotExtraKey]) @@ -657,7 +657,7 @@ func TestForwardGrokResponsesStreamingUsesXAIResponsesAndSnapshots(t *testing.T) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.True(t, result.Stream) require.Equal(t, "resp_grok", result.ResponseID) @@ -727,7 +727,7 @@ func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *te require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) require.Equal(t, 6, result.Usage.InputTokens) @@ -844,11 +844,11 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.NotContains(t, string(upstream.lastBody), "chatgpt.com") require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.3", result.UpstreamModel) + require.Equal(t, "grok-4.5", result.UpstreamModel) require.Equal(t, 5, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Contains(t, recorder.Body.String(), `"type":"message"`) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 0acb39ec83..4d1e4a0374 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -283,7 +283,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_retention").Exists()) From 104fd2b6ec61c12856a70bf0e154a439c60fc397 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 16:07:15 +0800 Subject: [PATCH 29/29] =?UTF-8?q?fix(messages):=20/v1/messages=20=E9=9D=9E?= =?UTF-8?q?=20cyber=20response.failed=20=E8=A1=A5=E5=85=A8=20failover=20?= =?UTF-8?q?=E5=92=8C=E9=94=99=E8=AF=AF=E5=9B=9E=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_gateway_messages.go | 42 ++++++- ...i_gateway_messages_failed_response_test.go | 107 ++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/openai_gateway_messages_failed_response_test.go diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 92ee773166..c2f51795c8 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -348,7 +348,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( result, handleErr = s.handleAnthropicStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime) } else { // Client wants JSON: buffer the streaming response and assemble a JSON reply. - result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, originalModel, billingModel, upstreamModel, startTime) + result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime) } // cyber_policy:标记已设、error 已按 Anthropic 格式发给客户端。丢弃 result、返回哨兵, @@ -424,6 +424,7 @@ func (s *OpenAIGatewayService) handleAnthropicErrorResponse( func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( resp *http.Response, c *gin.Context, + account *Account, originalModel string, billingModel string, upstreamModel string, @@ -441,8 +442,6 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( return nil, fmt.Errorf("upstream stream ended without terminal event") } - // cyber_policy:上游硬阻断(response.failed)。anthropic buffered 原对 failed 无特殊分支, - // 此处仅为 cyber 增加:以 Anthropic 错误格式回写,标记供 handler 事后写风控/邮件/tokens=0 用量行。 if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) if hit, code, msg := detectOpenAICyberPolicy(payload); hit { @@ -461,6 +460,13 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", clientMsg) return nil, fmt.Errorf("openai cyber_policy: %s", msg) } + message := openAICompatFailedResponseMessage(finalResponse) + if openAIStreamFailedEventShouldFailover(payload, message) { + return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message) + } + message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message) + writeAnthropicError(c, http.StatusBadGateway, "api_error", message) + return nil, fmt.Errorf("upstream response failed: %s", message) } // When the terminal event has an empty output array, reconstruct from @@ -701,6 +707,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( firstChunk := true clientDisconnected := false clientOutputStarted := false + var streamFailoverErr error + var streamNonFailoverErr error scanner := s.newUpstreamSSEScanner(resp.Body) @@ -767,7 +775,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // cyber_policy 致命不可重试:标记供 handler 事后记录;以 Anthropic SSE error 事件 // 回写让客户端感知并停止重试(F4),丢弃后续转换输出。 if strings.TrimSpace(event.Type) == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy([]byte(payload)); hit { + payloadBytes := []byte(payload) + if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit { MarkOpsCyberPolicy(c, CyberPolicyMark{ Code: code, Message: msg, @@ -789,6 +798,25 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } return true } + message := extractOpenAISSEErrorMessage(payloadBytes) + if openAIStreamFailedEventShouldFailover(payloadBytes, message) { + streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message) + return true + } + message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message) + if !clientDisconnected { + if !clientOutputStarted { + writeAnthropicError(c, http.StatusBadGateway, "api_error", message) + clientOutputStarted = true + } else { + writeStreamHeaders() + if _, err := fmt.Fprint(c.Writer, buildAnthropicStreamErrorSSE("api_error", message)); err == nil { + c.Writer.Flush() + } + } + } + streamNonFailoverErr = fmt.Errorf("upstream response failed: %s", message) + return true } } @@ -823,6 +851,12 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // finalizeStream sends any remaining Anthropic events and returns the result. finalizeStream := func() (*OpenAIForwardResult, error) { + if streamFailoverErr != nil { + return resultWithUsage(), streamFailoverErr + } + if streamNonFailoverErr != nil { + return resultWithUsage(), streamNonFailoverErr + } if finalEvents := apicompat.FinalizeResponsesAnthropicStream(state); len(finalEvents) > 0 && !clientDisconnected { for _, evt := range finalEvents { sse, err := apicompat.ResponsesAnthropicEventToSSE(evt) diff --git a/backend/internal/service/openai_gateway_messages_failed_response_test.go b/backend/internal/service/openai_gateway_messages_failed_response_test.go new file mode 100644 index 0000000000..a03efca42d --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_failed_response_test.go @@ -0,0 +1,107 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func buildResponsesFailedSSEStream(errType, errorMessage string) string { + failed := fmt.Sprintf(`{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"type":"%s","message":"%s"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}`, errType, errorMessage) + return fmt.Sprintf("data: %s\n\n", failed) +} + +func TestForwardAsAnthropic_BufferedResponseFailed_ReturnsError(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ssePayload)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err, "non-cyber response.failed must return an error, not swallow as 200") + require.Contains(t, err.Error(), "upstream response failed") + require.Equal(t, http.StatusBadGateway, rec.Code, "should write 502 for non-failover failed response") +} + +func TestForwardAsAnthropic_StreamingResponseFailed_ReturnsError(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ssePayload)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err, "streaming response.failed must return an error") + require.Contains(t, err.Error(), "upstream response failed") +} + +func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + ssePayload := buildResponsesFailedSSEStream("rate_limit_error", "Rate limit reached") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ssePayload)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "rate_limit_error should trigger UpstreamFailoverError for failover, got: %T: %v", err, err) +}