From 0f8e2d0934434e5c99d2c2a0b128eb07b6586f1d Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Fri, 8 May 2026 03:43:50 +0800 Subject: [PATCH 01/70] =?UTF-8?q?fix(security):=20=E5=B1=8F=E8=94=BD=20adm?= =?UTF-8?q?in=20=E8=B4=A6=E5=8F=B7=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=E6=95=8F=E6=84=9F=E5=87=AD=E8=AF=81=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account.Credentials 是 JSONB map,混合存放可编辑的非敏感配置(base_url、 model_mapping、project_id 等)与敏感秘钥(OAuth access/refresh/id token、 API key、AWS secret、Vertex private key 等)。当前所有 admin 账号接口直接 透传该 map,token 经由浏览器 DevTools、抓包、日志等途径泄漏。 - service 包新增 SensitiveCredentialKeys 清单与 MergePreservingSensitiveCreds 作为单一权威定义。 - dto 层 RedactCredentials 在响应里剥离敏感子键,输出 credentials_status (has_ 布尔标识)告知前端存在性,不暴露原值。 - AccountFromServiceShallow 接入脱敏,覆盖 list、get、create、update、 refresh、batch、bulk-update、OAuth 创建等 9 个 handler。 - service.UpdateAccount 改为合并语义:incoming 没传敏感键则保留 existing, 让前端"全对象 PUT"流程在脱敏后无感工作;显式提供新 token 仍会覆盖。 - 前端 EditAccountModal 修复脱敏后会崩的两处兜底:apikey 必填检查和 Vertex SA JSON 存在性校验改读 credentials_status.has_*。 - 导出端点 /admin/accounts/data 走独立的 DataAccount 结构,按设计保留 完整 credentials 作为管理员备份路径。 测试:RedactCredentials 单元测试、mapper 端到端 JSON 断言(确认序列化 后无 token 子串)、UpdateAccount 合并语义三种场景(保留 / 覆盖 / 空 map 跳过)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handler/admin/account_data.go | 3 + .../handler/dto/account_mapper_redact_test.go | 67 ++++++++++ .../handler/dto/credentials_redact.go | 44 +++++++ .../handler/dto/credentials_redact_test.go | 97 +++++++++++++++ backend/internal/handler/dto/mappers.go | 4 +- backend/internal/handler/dto/types.go | 41 +++--- .../service/account_credentials_redact.go | 50 ++++++++ .../account_credentials_redact_test.go | 90 ++++++++++++++ backend/internal/service/admin_service.go | 4 +- .../admin_service_credentials_merge_test.go | 117 ++++++++++++++++++ .../components/account/EditAccountModal.vue | 15 ++- frontend/src/types/index.ts | 5 + 12 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 backend/internal/handler/dto/account_mapper_redact_test.go create mode 100644 backend/internal/handler/dto/credentials_redact.go create mode 100644 backend/internal/handler/dto/credentials_redact_test.go create mode 100644 backend/internal/service/account_credentials_redact.go create mode 100644 backend/internal/service/account_credentials_redact_test.go create mode 100644 backend/internal/service/admin_service_credentials_merge_test.go diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 00da48212aa..50beadf68e6 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -43,6 +43,9 @@ type DataProxy struct { Status string `json:"status"` } +// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径, +// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本, +// 应新增独立结构而非修改这里。 type DataAccount struct { Name string `json:"name"` Notes *string `json:"notes,omitempty"` diff --git a/backend/internal/handler/dto/account_mapper_redact_test.go b/backend/internal/handler/dto/account_mapper_redact_test.go new file mode 100644 index 00000000000..bd584e11237 --- /dev/null +++ b/backend/internal/handler/dto/account_mapper_redact_test.go @@ -0,0 +1,67 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) { + src := &service.Account{ + ID: 42, + Name: "demo", + Platform: "anthropic", + Type: "oauth", + Credentials: map[string]any{ + "access_token": "at-secret", + "refresh_token": "rt-secret", + "id_token": "id-secret", + "api_key": "sk-secret", + "base_url": "https://api.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + }, + } + + got := AccountFromServiceShallow(src) + require.NotNil(t, got) + + // 敏感键不在 Credentials 里 + require.NotContains(t, got.Credentials, "access_token") + require.NotContains(t, got.Credentials, "refresh_token") + require.NotContains(t, got.Credentials, "id_token") + require.NotContains(t, got.Credentials, "api_key") + // 非敏感键保留 + require.Equal(t, "https://api.example.com", got.Credentials["base_url"]) + require.Equal(t, map[string]any{"foo": "bar"}, got.Credentials["model_mapping"]) + + // 状态 map 标记敏感键存在 + require.True(t, got.CredentialsStatus["has_access_token"]) + require.True(t, got.CredentialsStatus["has_refresh_token"]) + require.True(t, got.CredentialsStatus["has_id_token"]) + require.True(t, got.CredentialsStatus["has_api_key"]) + + // JSON 序列化校验:响应体里不会出现敏感子串 + raw, err := json.Marshal(got) + require.NoError(t, err) + require.NotContains(t, string(raw), "rt-secret") + require.NotContains(t, string(raw), "at-secret") + require.NotContains(t, string(raw), "sk-secret") + require.NotContains(t, string(raw), "id-secret") + // 状态标识应序列化进 JSON + require.Contains(t, string(raw), "credentials_status") + require.Contains(t, string(raw), "has_refresh_token") + + // 原始 service.Account 不应被改动 + require.Equal(t, "rt-secret", src.Credentials["refresh_token"]) +} + +func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) { + src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"} + got := AccountFromServiceShallow(src) + require.NotNil(t, got) + require.Nil(t, got.Credentials) + require.Nil(t, got.CredentialsStatus) +} diff --git a/backend/internal/handler/dto/credentials_redact.go b/backend/internal/handler/dto/credentials_redact.go new file mode 100644 index 00000000000..e65a8007060 --- /dev/null +++ b/backend/internal/handler/dto/credentials_redact.go @@ -0,0 +1,44 @@ +// Package dto provides data transfer objects for HTTP handlers. +package dto + +import "github.com/Wei-Shaw/sub2api/internal/service" + +// RedactCredentials 复制一份 in,剥离 service.SensitiveCredentialKeys 列出的所有敏感子键, +// 并产出一个 has_ 状态 map 表示哪些敏感键存在且非零值。 +// +// 输入 nil 时返回 nil, nil(避免响应里出现空对象)。 +// 不修改入参;调用方拿到的 out 可安全序列化进 JSON 返回前端。 +func RedactCredentials(in map[string]any) (out map[string]any, status map[string]bool) { + if in == nil { + return nil, nil + } + out = make(map[string]any, len(in)) + for k, v := range in { + if service.IsSensitiveCredentialKey(k) { + if isCredentialValuePresent(v) { + if status == nil { + status = make(map[string]bool, 4) + } + status["has_"+k] = true + } + continue + } + out[k] = v + } + return out, status +} + +// isCredentialValuePresent 判断值是否"存在且非零"。空字符串、nil、false 均视为未配置; +// 其余非零类型(数字、对象、字符串等)视为已配置。 +func isCredentialValuePresent(v any) bool { + switch x := v.(type) { + case nil: + return false + case string: + return x != "" + case bool: + return x + default: + return true + } +} diff --git a/backend/internal/handler/dto/credentials_redact_test.go b/backend/internal/handler/dto/credentials_redact_test.go new file mode 100644 index 00000000000..431078fafdd --- /dev/null +++ b/backend/internal/handler/dto/credentials_redact_test.go @@ -0,0 +1,97 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedactCredentials_NilInput(t *testing.T) { + out, status := RedactCredentials(nil) + require.Nil(t, out) + require.Nil(t, status) +} + +func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) { + in := map[string]any{ + "refresh_token": "rt-secret", + "access_token": "at-secret", + "api_key": "sk-secret", + "aws_secret_access_key": "aws-secret", + "service_account_json": map[string]any{"private_key": "..."}, + "private_key": "raw-key", + // 非敏感 + "base_url": "https://api.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + "project_id": "proj-1", + "expires_at": int64(123456), + } + + out, status := RedactCredentials(in) + + require.NotContains(t, out, "refresh_token") + require.NotContains(t, out, "access_token") + require.NotContains(t, out, "api_key") + require.NotContains(t, out, "aws_secret_access_key") + require.NotContains(t, out, "service_account_json") + require.NotContains(t, out, "private_key") + + require.Equal(t, "https://api.example.com", out["base_url"]) + require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"]) + require.Equal(t, "proj-1", out["project_id"]) + require.Equal(t, int64(123456), out["expires_at"]) + + require.True(t, status["has_refresh_token"]) + require.True(t, status["has_access_token"]) + require.True(t, status["has_api_key"]) + require.True(t, status["has_aws_secret_access_key"]) + require.True(t, status["has_service_account_json"]) + require.True(t, status["has_private_key"]) + + // 状态 map 不应携带非敏感键的 has_* + require.NotContains(t, status, "has_base_url") + require.NotContains(t, status, "has_project_id") +} + +func TestRedactCredentials_EmptyValuesNotMarkedPresent(t *testing.T) { + in := map[string]any{ + "refresh_token": "", + "access_token": nil, + "api_key": false, + "id_token": "actual-id", + } + out, status := RedactCredentials(in) + require.Empty(t, out, "敏感键即使为空也不应出现在 redacted output") + require.False(t, status["has_refresh_token"]) + require.False(t, status["has_access_token"]) + require.False(t, status["has_api_key"]) + require.True(t, status["has_id_token"]) +} + +func TestRedactCredentials_DoesNotMutateInput(t *testing.T) { + in := map[string]any{ + "refresh_token": "secret", + "base_url": "x", + } + _, _ = RedactCredentials(in) + require.Equal(t, "secret", in["refresh_token"], "原始 map 不应被修改") + require.Equal(t, "x", in["base_url"]) +} + +func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) { + keys := []string{ + "access_token", "refresh_token", "id_token", + "api_key", "session_key", "cookie", + "aws_secret_access_key", "aws_session_token", + "service_account_json", "service_account", "private_key", + } + in := make(map[string]any, len(keys)) + for _, k := range keys { + in[k] = "filled" + } + out, status := RedactCredentials(in) + require.Empty(t, out) + for _, k := range keys { + require.True(t, status["has_"+k], "key %s 应在 status 中标记为已配置", k) + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2559b112cb9..c6e79922a5d 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -198,13 +198,15 @@ func AccountFromServiceShallow(a *service.Account) *Account { if a == nil { return nil } + redactedCreds, credsStatus := RedactCredentials(a.Credentials) out := &Account{ ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, - Credentials: a.Credentials, + Credentials: redactedCreds, + CredentialsStatus: credsStatus, Extra: a.Extra, ProxyID: a.ProxyID, Concurrency: a.Concurrency, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e15a916eec4..168f93757be 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -149,25 +149,28 @@ type AdminGroup struct { } type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - LoadFactor *int `json:"load_factor,omitempty"` - Priority int `json:"priority"` - RateMultiplier float64 `json:"rate_multiplier"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - ExpiresAt *int64 `json:"expires_at"` - AutoPauseOnExpired bool `json:"auto_pause_on_expired"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + // Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥 + // 的存在性通过 CredentialsStatus(has_)暴露,原始值不返回前端。 + Credentials map[string]any `json:"credentials"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` diff --git a/backend/internal/service/account_credentials_redact.go b/backend/internal/service/account_credentials_redact.go new file mode 100644 index 00000000000..76c2d1de5be --- /dev/null +++ b/backend/internal/service/account_credentials_redact.go @@ -0,0 +1,50 @@ +package service + +// SensitiveCredentialKeys 列出 Account.Credentials JSON map 中绝不允许返回到前端的子键。 +// dto 层做响应脱敏、service 层做更新合并都引用此清单——新增凭证类型时务必同步。 +var SensitiveCredentialKeys = []string{ + // OAuth + "access_token", "refresh_token", "id_token", + // API Key 类 + "api_key", "session_key", "cookie", + // 云服务凭据 + "aws_secret_access_key", "aws_session_token", + "service_account_json", "service_account", "private_key", +} + +var sensitiveCredentialKeySet = func() map[string]struct{} { + m := make(map[string]struct{}, len(SensitiveCredentialKeys)) + for _, k := range SensitiveCredentialKeys { + m[k] = struct{}{} + } + return m +}() + +// IsSensitiveCredentialKey 判断指定键是否为敏感凭证子键。 +func IsSensitiveCredentialKey(key string) bool { + _, ok := sensitiveCredentialKeySet[key] + return ok +} + +// MergePreservingSensitiveCreds 把 incoming 写入 existing 之上,但敏感子键采用"incoming 没提供就保留 existing" +// 的语义。返回新的 map,不修改入参。 +// +// 用途:前端编辑账号通常采用"全对象 PUT"模式;脱敏后前端 spread 旧 credentials 时不会带上敏感键, +// 直接覆盖会清空已有 token。此函数保证: +// - 非敏感键:完全由 incoming 决定(用户可以编辑、删除非敏感字段)。 +// - 敏感键:incoming 显式提供则覆盖(用户主动旋转 token),否则保留 existing。 +func MergePreservingSensitiveCreds(existing, incoming map[string]any) map[string]any { + out := make(map[string]any, len(incoming)+len(SensitiveCredentialKeys)) + for k, v := range incoming { + out[k] = v + } + for _, key := range SensitiveCredentialKeys { + if _, hasIncoming := incoming[key]; hasIncoming { + continue + } + if existingVal, ok := existing[key]; ok { + out[key] = existingVal + } + } + return out +} diff --git a/backend/internal/service/account_credentials_redact_test.go b/backend/internal/service/account_credentials_redact_test.go new file mode 100644 index 00000000000..05f37da9da2 --- /dev/null +++ b/backend/internal/service/account_credentials_redact_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergePreservingSensitiveCreds_PreservesSensitiveWhenIncomingMissing(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt-old", + "access_token": "at-old", + "api_key": "sk-old", + "base_url": "https://old.example.com", + } + incoming := map[string]any{ + "base_url": "https://new.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + } + + out := MergePreservingSensitiveCreds(existing, incoming) + + require.Equal(t, "rt-old", out["refresh_token"], "incoming 没传 refresh_token,应保留 existing") + require.Equal(t, "at-old", out["access_token"]) + require.Equal(t, "sk-old", out["api_key"]) + require.Equal(t, "https://new.example.com", out["base_url"], "非敏感键由 incoming 决定") + require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"]) +} + +func TestMergePreservingSensitiveCreds_OverwritesWhenIncomingProvidesSensitive(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt-old", + "api_key": "sk-old", + } + incoming := map[string]any{ + "refresh_token": "rt-new", + // 显式没传 api_key —— 应保留 + } + out := MergePreservingSensitiveCreds(existing, incoming) + require.Equal(t, "rt-new", out["refresh_token"], "incoming 显式传入应覆盖") + require.Equal(t, "sk-old", out["api_key"], "incoming 没传应保留") +} + +func TestMergePreservingSensitiveCreds_DoesNotMutateInputs(t *testing.T) { + existing := map[string]any{"refresh_token": "rt"} + incoming := map[string]any{"base_url": "x"} + + _ = MergePreservingSensitiveCreds(existing, incoming) + + require.Equal(t, "rt", existing["refresh_token"]) + require.NotContains(t, existing, "base_url") + require.Equal(t, "x", incoming["base_url"]) + require.NotContains(t, incoming, "refresh_token") +} + +func TestMergePreservingSensitiveCreds_NilInputs(t *testing.T) { + out := MergePreservingSensitiveCreds(nil, map[string]any{"base_url": "x"}) + require.Equal(t, "x", out["base_url"]) + require.NotContains(t, out, "refresh_token") + + out2 := MergePreservingSensitiveCreds(map[string]any{"refresh_token": "rt"}, nil) + require.Equal(t, "rt", out2["refresh_token"]) +} + +func TestMergePreservingSensitiveCreds_NonSensitiveDeletionAllowed(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt", + "base_url": "https://old", + "project_id": "p1", + } + incoming := map[string]any{ + "base_url": "https://new", + // 不带 project_id —— 等同删除(非敏感键由 incoming 决定) + } + out := MergePreservingSensitiveCreds(existing, incoming) + require.Equal(t, "rt", out["refresh_token"], "敏感键保留") + require.Equal(t, "https://new", out["base_url"]) + require.NotContains(t, out, "project_id", "非敏感键 incoming 不传 = 删除") +} + +func TestIsSensitiveCredentialKey(t *testing.T) { + require.True(t, IsSensitiveCredentialKey("refresh_token")) + require.True(t, IsSensitiveCredentialKey("api_key")) + require.True(t, IsSensitiveCredentialKey("private_key")) + require.False(t, IsSensitiveCredentialKey("base_url")) + require.False(t, IsSensitiveCredentialKey("")) + require.False(t, IsSensitiveCredentialKey("model_mapping")) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index eb5994d5498..ff65fdeca26 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2470,7 +2470,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Notes = normalizeAccountNotes(input.Notes) } if len(input.Credentials) > 0 { - account.Credentials = input.Credentials + // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, + // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) } // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 diff --git a/backend/internal/service/admin_service_credentials_merge_test.go b/backend/internal/service/admin_service_credentials_merge_test.go new file mode 100644 index 00000000000..8250db281cb --- /dev/null +++ b/backend/internal/service/admin_service_credentials_merge_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type updateAccountCredsRepoStub struct { + mockAccountRepoForGemini + account *Account + updateCalls int +} + +func (r *updateAccountCredsRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) { + return r.account, nil +} + +func (r *updateAccountCredsRepoStub) Update(ctx context.Context, account *Account) error { + r.updateCalls++ + r.account = account + return nil +} + +func TestUpdateAccount_PreservesSensitiveCredsWhenIncomingOmits(t *testing.T) { + accountID := int64(202) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-existing", + "access_token": "at-existing", + "id_token": "id-existing", + "base_url": "https://old.example.com", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + // 模拟前端编辑:仅修改 base_url,没有传 token(脱敏后前端 spread 拿不到敏感键) + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{ + "base_url": "https://new.example.com", + }, + }) + + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, 1, repo.updateCalls) + + // 敏感键应保留 + require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"]) + require.Equal(t, "at-existing", repo.account.Credentials["access_token"]) + require.Equal(t, "id-existing", repo.account.Credentials["id_token"]) + // 非敏感键被替换 + require.Equal(t, "https://new.example.com", repo.account.Credentials["base_url"]) +} + +func TestUpdateAccount_ExplicitNewTokenOverwrites(t *testing.T) { + accountID := int64(203) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-old", + "api_key": "sk-old", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{ + "refresh_token": "rt-new", + // api_key 没传 → 应保留旧值 + }, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + require.Equal(t, "rt-new", repo.account.Credentials["refresh_token"]) + require.Equal(t, "sk-old", repo.account.Credentials["api_key"]) +} + +func TestUpdateAccount_EmptyCredentialsSkipsUpdate(t *testing.T) { + accountID := int64(204) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-existing", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + _, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{}, // len == 0 → 闸门跳过 + Name: "renamed", + }) + require.NoError(t, err) + + require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"], "空 credentials 不应触碰已有 token") + require.Equal(t, "renamed", repo.account.Name) +} diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 80f0b8908d0..059813c9827 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3343,13 +3343,12 @@ const handleSubmit = async () => { } // Handle API key + // 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。 + // 用户填入新值则覆盖;留空且后端已存在旧 key(看 credentials_status)则不带 api_key 字段, + // 由后端合并保留;两者都无才报错。 if (editApiKey.value.trim()) { - // User provided a new API key newCredentials.api_key = editApiKey.value.trim() - } else if (currentCredentials.api_key) { - // Preserve existing api_key - newCredentials.api_key = currentCredentials.api_key - } else { + } else if (!props.account.credentials_status?.has_api_key) { appStore.showError(t('admin.accounts.apiKeyIsRequired')) return } @@ -3434,7 +3433,11 @@ const handleSubmit = async () => { return } - if (!currentCredentials.service_account_json && !currentCredentials.service_account) { + // SA JSON 已脱敏不再随 credentials 返回,存在性改读 credentials_status。 + const hasExistingServiceAccountJson = + props.account.credentials_status?.has_service_account_json || + props.account.credentials_status?.has_service_account + if (!hasExistingServiceAccountJson) { appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 328b7c04e14..756da1b9001 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -795,7 +795,12 @@ export interface Account { notes?: string | null platform: AccountPlatform type: AccountType + // 后端响应里 credentials 已脱敏:access_token / refresh_token / id_token / + // api_key / session_key / cookie / aws_secret_access_key / aws_session_token / + // service_account_json / service_account / private_key 不会出现, + // 改为通过 credentials_status.has_ 暴露存在性。 credentials?: Record + credentials_status?: Record // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record From 4467922199f8dd2bc777c5eca7fe39befbcbf14c Mon Sep 17 00:00:00 2001 From: hoobnn <111053672+hoobnn@users.noreply.github.com> Date: Tue, 12 May 2026 13:53:38 +0800 Subject: [PATCH 02/70] fix: add autocomplete="one-time-code" for TOTP autofill support Add a hidden input with autocomplete="one-time-code" so password managers (1Password, Bitwarden, Chrome, Apple Keychain) can detect and auto-fill TOTP verification codes during 2FA login. --- .../src/components/auth/TotpLoginModal.vue | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frontend/src/components/auth/TotpLoginModal.vue b/frontend/src/components/auth/TotpLoginModal.vue index 0ae2f482937..5f68b9a796b 100644 --- a/frontend/src/components/auth/TotpLoginModal.vue +++ b/frontend/src/components/auth/TotpLoginModal.vue @@ -24,6 +24,18 @@
+ +
(['', '', '', '', '', '']) const inputRefs = ref<(HTMLInputElement | null)[]>([]) +const hiddenOtpInputRef = ref(null) // Watch for code changes and auto-submit when 6 digits are entered watch( @@ -104,6 +118,10 @@ defineExpose({ inputRefs.value.forEach(input => { if (input) input.value = '' }) + // Clear hidden autofill input + if (hiddenOtpInputRef.value) { + hiddenOtpInputRef.value.value = '' + } nextTick(() => { inputRefs.value[0]?.focus() }) @@ -126,6 +144,26 @@ const handleCodeInput = (event: Event, index: number) => { } } +// Handle autofill from password managers via the hidden autocomplete="one-time-code" input +const handleHiddenOtpInput = (event: Event) => { + const input = event.target as HTMLInputElement + const digits = input.value.replace(/[^0-9]/g, '').slice(0, 6).split('') + + digits.forEach((digit, i) => { + code.value[i] = digit + if (inputRefs.value[i]) { + inputRefs.value[i]!.value = digit + } + }) + + for (let i = digits.length; i < 6; i++) { + code.value[i] = '' + if (inputRefs.value[i]) { + inputRefs.value[i]!.value = '' + } + } +} + const handleKeydown = (event: KeyboardEvent, index: number) => { if (event.key === 'Backspace') { const input = event.target as HTMLInputElement From 224e9fc6c2bdd4a20d66e7cd2ec96fc68a0f43a2 Mon Sep 17 00:00:00 2001 From: imlewc Date: Tue, 12 May 2026 14:35:20 +0800 Subject: [PATCH 03/70] fix(auth): prefer OIDC compat email in pending flow --- frontend/src/views/auth/OidcCallbackView.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue index 51b17dbfa00..e17f05e91d0 100644 --- a/frontend/src/views/auth/OidcCallbackView.vue +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -338,6 +338,7 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & { pending_email?: string resolved_email?: string existing_account_email?: string + compat_email?: string email?: string suggested_email?: string provider_fallback?: string @@ -461,6 +462,7 @@ function extractPendingAccountEmail(completion: PendingOidcCompletion): string { return ( completion.pending_email || completion.existing_account_email || + completion.compat_email || completion.resolved_email || completion.email || completion.suggested_email || From bb4c1abe285661aaaea2bf4241bc2c3c03912439 Mon Sep 17 00:00:00 2001 From: 2ue Date: Tue, 12 May 2026 15:21:31 +0800 Subject: [PATCH 04/70] Fix image billing size normalization --- backend/ent/migrate/schema.go | 32 +- backend/ent/mutation.go | 294 +++++++++++++++- backend/ent/runtime/runtime.go | 16 +- backend/ent/schema/usage_log.go | 15 + backend/ent/usagelog.go | 60 +++- backend/ent/usagelog/usagelog.go | 33 ++ backend/ent/usagelog/where.go | 250 ++++++++++++++ backend/ent/usagelog_create.go | 319 ++++++++++++++++++ backend/ent/usagelog_update.go | 222 ++++++++++++ backend/go.sum | 8 + backend/internal/handler/dto/mappers.go | 4 + .../handler/dto/mappers_usage_test.go | 59 ++++ backend/internal/handler/dto/types.go | 10 +- backend/internal/handler/page_handler_test.go | 14 +- .../migrations_schema_integration_test.go | 27 ++ backend/internal/repository/usage_log_repo.go | 125 ++++++- .../usage_log_repo_request_type_test.go | 137 ++++++++ backend/internal/server/api_contract_test.go | 4 + .../service/antigravity_gateway_service.go | 17 +- backend/internal/service/billing_service.go | 1 + .../service/billing_service_image_test.go | 15 + .../service/gateway_record_usage_test.go | 40 +++ backend/internal/service/gateway_service.go | 19 +- .../service/gemini_messages_compat_service.go | 57 ++-- .../internal/service/image_billing_size.go | 260 ++++++++++++++ .../service/image_billing_size_test.go | 110 ++++++ .../service/image_generation_intent.go | 30 +- .../service/image_generation_intent_test.go | 38 ++- .../service/image_output_accounting.go | 59 +++- .../openai_gateway_record_usage_test.go | 126 +++++++ .../service/openai_gateway_service.go | 120 +++++-- backend/internal/service/openai_images.go | 138 +++----- .../service/openai_images_responses.go | 130 ++++--- .../internal/service/openai_images_test.go | 6 +- .../internal/service/openai_ws_forwarder.go | 41 ++- backend/internal/service/usage_log.go | 10 +- .../136_usage_log_image_size_metadata.sql | 51 +++ .../src/components/admin/usage/UsageTable.vue | 69 +++- .../admin/usage/__tests__/UsageTable.spec.ts | 175 +++++++++- frontend/src/i18n/locales/en.ts | 13 + frontend/src/i18n/locales/zh.ts | 13 + frontend/src/types/index.ts | 6 + frontend/src/utils/imageUsage.ts | 56 +++ frontend/src/views/user/UsageView.vue | 85 +++-- .../views/user/__tests__/UsageView.spec.ts | 265 ++++++++++++++- 45 files changed, 3268 insertions(+), 311 deletions(-) create mode 100644 backend/internal/service/image_billing_size.go create mode 100644 backend/internal/service/image_billing_size_test.go create mode 100644 backend/migrations/136_usage_log_image_size_metadata.sql create mode 100644 frontend/src/utils/imageUsage.ts diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 525ff0927c1..5e768e2c8a0 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1318,6 +1318,10 @@ var ( {Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45}, {Name: "image_count", Type: field.TypeInt, Default: 0}, {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, + {Name: "image_input_size", Type: field.TypeString, Nullable: true, Size: 32}, + {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: "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}, @@ -1334,31 +1338,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[33]}, + Columns: []*schema.Column{UsageLogsColumns[37]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[34]}, + Columns: []*schema.Column{UsageLogsColumns[38]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[35]}, + Columns: []*schema.Column{UsageLogsColumns[39]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -1367,32 +1371,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[33]}, + Columns: []*schema.Column{UsageLogsColumns[37]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[34]}, + Columns: []*schema.Column{UsageLogsColumns[38]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[35]}, + Columns: []*schema.Column{UsageLogsColumns[39]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[36]}, }, { Name: "usagelog_model", @@ -1412,17 +1416,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[36], UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[33], UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[35], UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 13f6193d89a..db7b8f4926c 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -34260,6 +34260,10 @@ type UsageLogMutation struct { image_count *int addimage_count *int image_size *string + image_input_size *string + image_output_size *string + image_size_source *string + image_size_breakdown *map[string]int cache_ttl_overridden *bool created_at *time.Time clearedFields map[string]struct{} @@ -36202,6 +36206,202 @@ func (m *UsageLogMutation) ResetImageSize() { delete(m.clearedFields, usagelog.FieldImageSize) } +// SetImageInputSize sets the "image_input_size" field. +func (m *UsageLogMutation) SetImageInputSize(s string) { + m.image_input_size = &s +} + +// ImageInputSize returns the value of the "image_input_size" field in the mutation. +func (m *UsageLogMutation) ImageInputSize() (r string, exists bool) { + v := m.image_input_size + if v == nil { + return + } + return *v, true +} + +// OldImageInputSize returns the old "image_input_size" 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) OldImageInputSize(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageInputSize is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageInputSize requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageInputSize: %w", err) + } + return oldValue.ImageInputSize, nil +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (m *UsageLogMutation) ClearImageInputSize() { + m.image_input_size = nil + m.clearedFields[usagelog.FieldImageInputSize] = struct{}{} +} + +// ImageInputSizeCleared returns if the "image_input_size" field was cleared in this mutation. +func (m *UsageLogMutation) ImageInputSizeCleared() bool { + _, ok := m.clearedFields[usagelog.FieldImageInputSize] + return ok +} + +// ResetImageInputSize resets all changes to the "image_input_size" field. +func (m *UsageLogMutation) ResetImageInputSize() { + m.image_input_size = nil + delete(m.clearedFields, usagelog.FieldImageInputSize) +} + +// SetImageOutputSize sets the "image_output_size" field. +func (m *UsageLogMutation) SetImageOutputSize(s string) { + m.image_output_size = &s +} + +// ImageOutputSize returns the value of the "image_output_size" field in the mutation. +func (m *UsageLogMutation) ImageOutputSize() (r string, exists bool) { + v := m.image_output_size + if v == nil { + return + } + return *v, true +} + +// OldImageOutputSize returns the old "image_output_size" 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) OldImageOutputSize(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageOutputSize is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageOutputSize requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageOutputSize: %w", err) + } + return oldValue.ImageOutputSize, nil +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (m *UsageLogMutation) ClearImageOutputSize() { + m.image_output_size = nil + m.clearedFields[usagelog.FieldImageOutputSize] = struct{}{} +} + +// ImageOutputSizeCleared returns if the "image_output_size" field was cleared in this mutation. +func (m *UsageLogMutation) ImageOutputSizeCleared() bool { + _, ok := m.clearedFields[usagelog.FieldImageOutputSize] + return ok +} + +// ResetImageOutputSize resets all changes to the "image_output_size" field. +func (m *UsageLogMutation) ResetImageOutputSize() { + m.image_output_size = nil + delete(m.clearedFields, usagelog.FieldImageOutputSize) +} + +// SetImageSizeSource sets the "image_size_source" field. +func (m *UsageLogMutation) SetImageSizeSource(s string) { + m.image_size_source = &s +} + +// ImageSizeSource returns the value of the "image_size_source" field in the mutation. +func (m *UsageLogMutation) ImageSizeSource() (r string, exists bool) { + v := m.image_size_source + if v == nil { + return + } + return *v, true +} + +// OldImageSizeSource returns the old "image_size_source" 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) OldImageSizeSource(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageSizeSource is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageSizeSource requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageSizeSource: %w", err) + } + return oldValue.ImageSizeSource, nil +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (m *UsageLogMutation) ClearImageSizeSource() { + m.image_size_source = nil + m.clearedFields[usagelog.FieldImageSizeSource] = struct{}{} +} + +// ImageSizeSourceCleared returns if the "image_size_source" field was cleared in this mutation. +func (m *UsageLogMutation) ImageSizeSourceCleared() bool { + _, ok := m.clearedFields[usagelog.FieldImageSizeSource] + return ok +} + +// ResetImageSizeSource resets all changes to the "image_size_source" field. +func (m *UsageLogMutation) ResetImageSizeSource() { + m.image_size_source = nil + delete(m.clearedFields, usagelog.FieldImageSizeSource) +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (m *UsageLogMutation) SetImageSizeBreakdown(value map[string]int) { + m.image_size_breakdown = &value +} + +// ImageSizeBreakdown returns the value of the "image_size_breakdown" field in the mutation. +func (m *UsageLogMutation) ImageSizeBreakdown() (r map[string]int, exists bool) { + v := m.image_size_breakdown + if v == nil { + return + } + return *v, true +} + +// OldImageSizeBreakdown returns the old "image_size_breakdown" 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) OldImageSizeBreakdown(ctx context.Context) (v map[string]int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageSizeBreakdown is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageSizeBreakdown requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageSizeBreakdown: %w", err) + } + return oldValue.ImageSizeBreakdown, nil +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (m *UsageLogMutation) ClearImageSizeBreakdown() { + m.image_size_breakdown = nil + m.clearedFields[usagelog.FieldImageSizeBreakdown] = struct{}{} +} + +// ImageSizeBreakdownCleared returns if the "image_size_breakdown" field was cleared in this mutation. +func (m *UsageLogMutation) ImageSizeBreakdownCleared() bool { + _, ok := m.clearedFields[usagelog.FieldImageSizeBreakdown] + return ok +} + +// ResetImageSizeBreakdown resets all changes to the "image_size_breakdown" field. +func (m *UsageLogMutation) ResetImageSizeBreakdown() { + m.image_size_breakdown = nil + delete(m.clearedFields, usagelog.FieldImageSizeBreakdown) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) { m.cache_ttl_overridden = &b @@ -36443,7 +36643,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, 37) + fields := make([]string, 0, 41) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -36549,6 +36749,18 @@ func (m *UsageLogMutation) Fields() []string { if m.image_size != nil { fields = append(fields, usagelog.FieldImageSize) } + if m.image_input_size != nil { + fields = append(fields, usagelog.FieldImageInputSize) + } + if m.image_output_size != nil { + fields = append(fields, usagelog.FieldImageOutputSize) + } + if m.image_size_source != nil { + fields = append(fields, usagelog.FieldImageSizeSource) + } + if m.image_size_breakdown != nil { + fields = append(fields, usagelog.FieldImageSizeBreakdown) + } if m.cache_ttl_overridden != nil { fields = append(fields, usagelog.FieldCacheTTLOverridden) } @@ -36633,6 +36845,14 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.ImageCount() case usagelog.FieldImageSize: return m.ImageSize() + case usagelog.FieldImageInputSize: + return m.ImageInputSize() + case usagelog.FieldImageOutputSize: + return m.ImageOutputSize() + case usagelog.FieldImageSizeSource: + return m.ImageSizeSource() + case usagelog.FieldImageSizeBreakdown: + return m.ImageSizeBreakdown() case usagelog.FieldCacheTTLOverridden: return m.CacheTTLOverridden() case usagelog.FieldCreatedAt: @@ -36716,6 +36936,14 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldImageCount(ctx) case usagelog.FieldImageSize: return m.OldImageSize(ctx) + case usagelog.FieldImageInputSize: + return m.OldImageInputSize(ctx) + case usagelog.FieldImageOutputSize: + return m.OldImageOutputSize(ctx) + case usagelog.FieldImageSizeSource: + return m.OldImageSizeSource(ctx) + case usagelog.FieldImageSizeBreakdown: + return m.OldImageSizeBreakdown(ctx) case usagelog.FieldCacheTTLOverridden: return m.OldCacheTTLOverridden(ctx) case usagelog.FieldCreatedAt: @@ -36974,6 +37202,34 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetImageSize(v) return nil + case usagelog.FieldImageInputSize: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageInputSize(v) + return nil + case usagelog.FieldImageOutputSize: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageOutputSize(v) + return nil + case usagelog.FieldImageSizeSource: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageSizeSource(v) + return nil + case usagelog.FieldImageSizeBreakdown: + v, ok := value.(map[string]int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageSizeBreakdown(v) + return nil case usagelog.FieldCacheTTLOverridden: v, ok := value.(bool) if !ok { @@ -37291,6 +37547,18 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldImageSize) { fields = append(fields, usagelog.FieldImageSize) } + if m.FieldCleared(usagelog.FieldImageInputSize) { + fields = append(fields, usagelog.FieldImageInputSize) + } + if m.FieldCleared(usagelog.FieldImageOutputSize) { + fields = append(fields, usagelog.FieldImageOutputSize) + } + if m.FieldCleared(usagelog.FieldImageSizeSource) { + fields = append(fields, usagelog.FieldImageSizeSource) + } + if m.FieldCleared(usagelog.FieldImageSizeBreakdown) { + fields = append(fields, usagelog.FieldImageSizeBreakdown) + } return fields } @@ -37347,6 +37615,18 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldImageSize: m.ClearImageSize() return nil + case usagelog.FieldImageInputSize: + m.ClearImageInputSize() + return nil + case usagelog.FieldImageOutputSize: + m.ClearImageOutputSize() + return nil + case usagelog.FieldImageSizeSource: + m.ClearImageSizeSource() + return nil + case usagelog.FieldImageSizeBreakdown: + m.ClearImageSizeBreakdown() + return nil } return fmt.Errorf("unknown UsageLog nullable field %s", name) } @@ -37460,6 +37740,18 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldImageSize: m.ResetImageSize() return nil + case usagelog.FieldImageInputSize: + m.ResetImageInputSize() + return nil + case usagelog.FieldImageOutputSize: + m.ResetImageOutputSize() + return nil + case usagelog.FieldImageSizeSource: + m.ResetImageSizeSource() + return nil + case usagelog.FieldImageSizeBreakdown: + m.ResetImageSizeBreakdown() + return nil case usagelog.FieldCacheTTLOverridden: m.ResetCacheTTLOverridden() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index a282d9ba39d..e48dc7814f8 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1722,12 +1722,24 @@ func init() { usagelogDescImageSize := usagelogFields[34].Descriptor() // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) + // usagelogDescImageInputSize is the schema descriptor for image_input_size field. + usagelogDescImageInputSize := usagelogFields[35].Descriptor() + // usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save. + usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error) + // usagelogDescImageOutputSize is the schema descriptor for image_output_size field. + usagelogDescImageOutputSize := usagelogFields[36].Descriptor() + // usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save. + usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error) + // usagelogDescImageSizeSource is the schema descriptor for image_size_source field. + 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) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[35].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[39].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[36].Descriptor() + usagelogDescCreatedAt := usagelogFields[40].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 bd3ebfcc3ce..db9e5178922 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -134,6 +134,21 @@ func (UsageLog) Fields() []ent.Field { MaxLen(10). Optional(). Nillable(), + field.String("image_input_size"). + MaxLen(32). + Optional(). + Nillable(), + field.String("image_output_size"). + MaxLen(32). + Optional(). + Nillable(), + field.String("image_size_source"). + MaxLen(16). + Optional(). + Nillable(), + field.JSON("image_size_breakdown", map[string]int{}). + Optional(). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}), // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) field.Bool("cache_ttl_overridden"). Default(false), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index a8e0cc6ce8d..283fe828a97 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -3,6 +3,7 @@ package ent import ( + "encoding/json" "fmt" "strings" "time" @@ -92,6 +93,14 @@ type UsageLog struct { ImageCount int `json:"image_count,omitempty"` // ImageSize holds the value of the "image_size" field. ImageSize *string `json:"image_size,omitempty"` + // ImageInputSize holds the value of the "image_input_size" field. + ImageInputSize *string `json:"image_input_size,omitempty"` + // ImageOutputSize holds the value of the "image_output_size" field. + ImageOutputSize *string `json:"image_output_size,omitempty"` + // ImageSizeSource holds the value of the "image_size_source" field. + 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"` // 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. @@ -179,13 +188,15 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case usagelog.FieldImageSizeBreakdown: + values[i] = new([]byte) case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden: 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: 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: + 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: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -434,6 +445,35 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.ImageSize = new(string) *_m.ImageSize = value.String } + case usagelog.FieldImageInputSize: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field image_input_size", values[i]) + } else if value.Valid { + _m.ImageInputSize = new(string) + *_m.ImageInputSize = value.String + } + case usagelog.FieldImageOutputSize: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field image_output_size", values[i]) + } else if value.Valid { + _m.ImageOutputSize = new(string) + *_m.ImageOutputSize = value.String + } + case usagelog.FieldImageSizeSource: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field image_size_source", values[i]) + } else if value.Valid { + _m.ImageSizeSource = new(string) + *_m.ImageSizeSource = value.String + } + case usagelog.FieldImageSizeBreakdown: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field image_size_breakdown", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.ImageSizeBreakdown); err != nil { + return fmt.Errorf("unmarshal field image_size_breakdown: %w", err) + } + } case usagelog.FieldCacheTTLOverridden: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i]) @@ -640,6 +680,24 @@ func (_m *UsageLog) String() string { builder.WriteString(*v) } builder.WriteString(", ") + if v := _m.ImageInputSize; v != nil { + builder.WriteString("image_input_size=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.ImageOutputSize; v != nil { + builder.WriteString("image_output_size=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.ImageSizeSource; v != nil { + builder.WriteString("image_size_source=") + builder.WriteString(*v) + } + builder.WriteString(", ") + builder.WriteString("image_size_breakdown=") + builder.WriteString(fmt.Sprintf("%v", _m.ImageSizeBreakdown)) + 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 a7438e604fb..297e0b41ad5 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -84,6 +84,14 @@ const ( FieldImageCount = "image_count" // FieldImageSize holds the string denoting the image_size field in the database. FieldImageSize = "image_size" + // FieldImageInputSize holds the string denoting the image_input_size field in the database. + FieldImageInputSize = "image_input_size" + // FieldImageOutputSize holds the string denoting the image_output_size field in the database. + FieldImageOutputSize = "image_output_size" + // FieldImageSizeSource holds the string denoting the image_size_source field in the database. + FieldImageSizeSource = "image_size_source" + // FieldImageSizeBreakdown holds the string denoting the image_size_breakdown field in the database. + FieldImageSizeBreakdown = "image_size_breakdown" // 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. @@ -175,6 +183,10 @@ var Columns = []string{ FieldIPAddress, FieldImageCount, FieldImageSize, + FieldImageInputSize, + FieldImageOutputSize, + FieldImageSizeSource, + FieldImageSizeBreakdown, FieldCacheTTLOverridden, FieldCreatedAt, } @@ -242,6 +254,12 @@ var ( DefaultImageCount int // ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. ImageSizeValidator func(string) error + // ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save. + ImageInputSizeValidator func(string) error + // ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save. + 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 // 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. @@ -431,6 +449,21 @@ func ByImageSize(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImageSize, opts...).ToFunc() } +// ByImageInputSize orders the results by the image_input_size field. +func ByImageInputSize(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageInputSize, opts...).ToFunc() +} + +// ByImageOutputSize orders the results by the image_output_size field. +func ByImageOutputSize(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageOutputSize, opts...).ToFunc() +} + +// ByImageSizeSource orders the results by the image_size_source field. +func ByImageSizeSource(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageSizeSource, 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 b8439a03978..2987f179303 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -230,6 +230,21 @@ func ImageSize(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v)) } +// ImageInputSize applies equality check predicate on the "image_input_size" field. It's identical to ImageInputSizeEQ. +func ImageInputSize(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageInputSize, v)) +} + +// ImageOutputSize applies equality check predicate on the "image_output_size" field. It's identical to ImageOutputSizeEQ. +func ImageOutputSize(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageOutputSize, v)) +} + +// ImageSizeSource applies equality check predicate on the "image_size_source" field. It's identical to ImageSizeSourceEQ. +func ImageSizeSource(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, 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)) @@ -1900,6 +1915,241 @@ func ImageSizeContainsFold(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v)) } +// ImageInputSizeEQ applies the EQ predicate on the "image_input_size" field. +func ImageInputSizeEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageInputSize, v)) +} + +// ImageInputSizeNEQ applies the NEQ predicate on the "image_input_size" field. +func ImageInputSizeNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldImageInputSize, v)) +} + +// ImageInputSizeIn applies the In predicate on the "image_input_size" field. +func ImageInputSizeIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldImageInputSize, vs...)) +} + +// ImageInputSizeNotIn applies the NotIn predicate on the "image_input_size" field. +func ImageInputSizeNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldImageInputSize, vs...)) +} + +// ImageInputSizeGT applies the GT predicate on the "image_input_size" field. +func ImageInputSizeGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldImageInputSize, v)) +} + +// ImageInputSizeGTE applies the GTE predicate on the "image_input_size" field. +func ImageInputSizeGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldImageInputSize, v)) +} + +// ImageInputSizeLT applies the LT predicate on the "image_input_size" field. +func ImageInputSizeLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldImageInputSize, v)) +} + +// ImageInputSizeLTE applies the LTE predicate on the "image_input_size" field. +func ImageInputSizeLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldImageInputSize, v)) +} + +// ImageInputSizeContains applies the Contains predicate on the "image_input_size" field. +func ImageInputSizeContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldImageInputSize, v)) +} + +// ImageInputSizeHasPrefix applies the HasPrefix predicate on the "image_input_size" field. +func ImageInputSizeHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldImageInputSize, v)) +} + +// ImageInputSizeHasSuffix applies the HasSuffix predicate on the "image_input_size" field. +func ImageInputSizeHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldImageInputSize, v)) +} + +// ImageInputSizeIsNil applies the IsNil predicate on the "image_input_size" field. +func ImageInputSizeIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldImageInputSize)) +} + +// ImageInputSizeNotNil applies the NotNil predicate on the "image_input_size" field. +func ImageInputSizeNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldImageInputSize)) +} + +// ImageInputSizeEqualFold applies the EqualFold predicate on the "image_input_size" field. +func ImageInputSizeEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldImageInputSize, v)) +} + +// ImageInputSizeContainsFold applies the ContainsFold predicate on the "image_input_size" field. +func ImageInputSizeContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldImageInputSize, v)) +} + +// ImageOutputSizeEQ applies the EQ predicate on the "image_output_size" field. +func ImageOutputSizeEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageOutputSize, v)) +} + +// ImageOutputSizeNEQ applies the NEQ predicate on the "image_output_size" field. +func ImageOutputSizeNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldImageOutputSize, v)) +} + +// ImageOutputSizeIn applies the In predicate on the "image_output_size" field. +func ImageOutputSizeIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldImageOutputSize, vs...)) +} + +// ImageOutputSizeNotIn applies the NotIn predicate on the "image_output_size" field. +func ImageOutputSizeNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldImageOutputSize, vs...)) +} + +// ImageOutputSizeGT applies the GT predicate on the "image_output_size" field. +func ImageOutputSizeGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldImageOutputSize, v)) +} + +// ImageOutputSizeGTE applies the GTE predicate on the "image_output_size" field. +func ImageOutputSizeGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldImageOutputSize, v)) +} + +// ImageOutputSizeLT applies the LT predicate on the "image_output_size" field. +func ImageOutputSizeLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldImageOutputSize, v)) +} + +// ImageOutputSizeLTE applies the LTE predicate on the "image_output_size" field. +func ImageOutputSizeLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldImageOutputSize, v)) +} + +// ImageOutputSizeContains applies the Contains predicate on the "image_output_size" field. +func ImageOutputSizeContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldImageOutputSize, v)) +} + +// ImageOutputSizeHasPrefix applies the HasPrefix predicate on the "image_output_size" field. +func ImageOutputSizeHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldImageOutputSize, v)) +} + +// ImageOutputSizeHasSuffix applies the HasSuffix predicate on the "image_output_size" field. +func ImageOutputSizeHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldImageOutputSize, v)) +} + +// ImageOutputSizeIsNil applies the IsNil predicate on the "image_output_size" field. +func ImageOutputSizeIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldImageOutputSize)) +} + +// ImageOutputSizeNotNil applies the NotNil predicate on the "image_output_size" field. +func ImageOutputSizeNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldImageOutputSize)) +} + +// ImageOutputSizeEqualFold applies the EqualFold predicate on the "image_output_size" field. +func ImageOutputSizeEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldImageOutputSize, v)) +} + +// ImageOutputSizeContainsFold applies the ContainsFold predicate on the "image_output_size" field. +func ImageOutputSizeContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldImageOutputSize, v)) +} + +// ImageSizeSourceEQ applies the EQ predicate on the "image_size_source" field. +func ImageSizeSourceEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v)) +} + +// ImageSizeSourceNEQ applies the NEQ predicate on the "image_size_source" field. +func ImageSizeSourceNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldImageSizeSource, v)) +} + +// ImageSizeSourceIn applies the In predicate on the "image_size_source" field. +func ImageSizeSourceIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldImageSizeSource, vs...)) +} + +// ImageSizeSourceNotIn applies the NotIn predicate on the "image_size_source" field. +func ImageSizeSourceNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldImageSizeSource, vs...)) +} + +// ImageSizeSourceGT applies the GT predicate on the "image_size_source" field. +func ImageSizeSourceGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldImageSizeSource, v)) +} + +// ImageSizeSourceGTE applies the GTE predicate on the "image_size_source" field. +func ImageSizeSourceGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldImageSizeSource, v)) +} + +// ImageSizeSourceLT applies the LT predicate on the "image_size_source" field. +func ImageSizeSourceLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldImageSizeSource, v)) +} + +// ImageSizeSourceLTE applies the LTE predicate on the "image_size_source" field. +func ImageSizeSourceLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldImageSizeSource, v)) +} + +// ImageSizeSourceContains applies the Contains predicate on the "image_size_source" field. +func ImageSizeSourceContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldImageSizeSource, v)) +} + +// ImageSizeSourceHasPrefix applies the HasPrefix predicate on the "image_size_source" field. +func ImageSizeSourceHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldImageSizeSource, v)) +} + +// ImageSizeSourceHasSuffix applies the HasSuffix predicate on the "image_size_source" field. +func ImageSizeSourceHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldImageSizeSource, v)) +} + +// ImageSizeSourceIsNil applies the IsNil predicate on the "image_size_source" field. +func ImageSizeSourceIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldImageSizeSource)) +} + +// ImageSizeSourceNotNil applies the NotNil predicate on the "image_size_source" field. +func ImageSizeSourceNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeSource)) +} + +// ImageSizeSourceEqualFold applies the EqualFold predicate on the "image_size_source" field. +func ImageSizeSourceEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldImageSizeSource, v)) +} + +// ImageSizeSourceContainsFold applies the ContainsFold predicate on the "image_size_source" field. +func ImageSizeSourceContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldImageSizeSource, v)) +} + +// ImageSizeBreakdownIsNil applies the IsNil predicate on the "image_size_breakdown" field. +func ImageSizeBreakdownIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldImageSizeBreakdown)) +} + +// ImageSizeBreakdownNotNil applies the NotNil predicate on the "image_size_breakdown" field. +func ImageSizeBreakdownNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeBreakdown)) +} + // 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 fded364e0e6..17e800f9ca3 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -477,6 +477,54 @@ func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate { return _c } +// SetImageInputSize sets the "image_input_size" field. +func (_c *UsageLogCreate) SetImageInputSize(v string) *UsageLogCreate { + _c.mutation.SetImageInputSize(v) + return _c +} + +// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableImageInputSize(v *string) *UsageLogCreate { + if v != nil { + _c.SetImageInputSize(*v) + } + return _c +} + +// SetImageOutputSize sets the "image_output_size" field. +func (_c *UsageLogCreate) SetImageOutputSize(v string) *UsageLogCreate { + _c.mutation.SetImageOutputSize(v) + return _c +} + +// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableImageOutputSize(v *string) *UsageLogCreate { + if v != nil { + _c.SetImageOutputSize(*v) + } + return _c +} + +// SetImageSizeSource sets the "image_size_source" field. +func (_c *UsageLogCreate) SetImageSizeSource(v string) *UsageLogCreate { + _c.mutation.SetImageSizeSource(v) + return _c +} + +// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableImageSizeSource(v *string) *UsageLogCreate { + if v != nil { + _c.SetImageSizeSource(*v) + } + return _c +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (_c *UsageLogCreate) SetImageSizeBreakdown(v map[string]int) *UsageLogCreate { + _c.mutation.SetImageSizeBreakdown(v) + return _c +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate { _c.mutation.SetCacheTTLOverridden(v) @@ -754,6 +802,21 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} } } + if v, ok := _c.mutation.ImageInputSize(); ok { + if err := usagelog.ImageInputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)} + } + } + if v, ok := _c.mutation.ImageOutputSize(); ok { + if err := usagelog.ImageOutputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)} + } + } + if v, ok := _c.mutation.ImageSizeSource(); ok { + if err := usagelog.ImageSizeSourceValidator(v); err != nil { + return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %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"`)} } @@ -916,6 +979,22 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldImageSize, field.TypeString, value) _node.ImageSize = &value } + if value, ok := _c.mutation.ImageInputSize(); ok { + _spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value) + _node.ImageInputSize = &value + } + if value, ok := _c.mutation.ImageOutputSize(); ok { + _spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value) + _node.ImageOutputSize = &value + } + if value, ok := _c.mutation.ImageSizeSource(); ok { + _spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value) + _node.ImageSizeSource = &value + } + if value, ok := _c.mutation.ImageSizeBreakdown(); ok { + _spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value) + _node.ImageSizeBreakdown = value + } if value, ok := _c.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _node.CacheTTLOverridden = value @@ -1679,6 +1758,78 @@ func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert { return u } +// SetImageInputSize sets the "image_input_size" field. +func (u *UsageLogUpsert) SetImageInputSize(v string) *UsageLogUpsert { + u.Set(usagelog.FieldImageInputSize, v) + return u +} + +// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageInputSize() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageInputSize) + return u +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (u *UsageLogUpsert) ClearImageInputSize() *UsageLogUpsert { + u.SetNull(usagelog.FieldImageInputSize) + return u +} + +// SetImageOutputSize sets the "image_output_size" field. +func (u *UsageLogUpsert) SetImageOutputSize(v string) *UsageLogUpsert { + u.Set(usagelog.FieldImageOutputSize, v) + return u +} + +// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageOutputSize() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageOutputSize) + return u +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (u *UsageLogUpsert) ClearImageOutputSize() *UsageLogUpsert { + u.SetNull(usagelog.FieldImageOutputSize) + return u +} + +// SetImageSizeSource sets the "image_size_source" field. +func (u *UsageLogUpsert) SetImageSizeSource(v string) *UsageLogUpsert { + u.Set(usagelog.FieldImageSizeSource, v) + return u +} + +// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageSizeSource() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageSizeSource) + return u +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (u *UsageLogUpsert) ClearImageSizeSource() *UsageLogUpsert { + u.SetNull(usagelog.FieldImageSizeSource) + return u +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (u *UsageLogUpsert) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsert { + u.Set(usagelog.FieldImageSizeBreakdown, v) + return u +} + +// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageSizeBreakdown() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageSizeBreakdown) + return u +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (u *UsageLogUpsert) ClearImageSizeBreakdown() *UsageLogUpsert { + u.SetNull(usagelog.FieldImageSizeBreakdown) + return u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert { u.Set(usagelog.FieldCacheTTLOverridden, v) @@ -2457,6 +2608,90 @@ func (u *UsageLogUpsertOne) ClearImageSize() *UsageLogUpsertOne { }) } +// SetImageInputSize sets the "image_input_size" field. +func (u *UsageLogUpsertOne) SetImageInputSize(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageInputSize(v) + }) +} + +// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageInputSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageInputSize() + }) +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (u *UsageLogUpsertOne) ClearImageInputSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageInputSize() + }) +} + +// SetImageOutputSize sets the "image_output_size" field. +func (u *UsageLogUpsertOne) SetImageOutputSize(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageOutputSize(v) + }) +} + +// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageOutputSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageOutputSize() + }) +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (u *UsageLogUpsertOne) ClearImageOutputSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageOutputSize() + }) +} + +// SetImageSizeSource sets the "image_size_source" field. +func (u *UsageLogUpsertOne) SetImageSizeSource(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSizeSource(v) + }) +} + +// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageSizeSource() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSizeSource() + }) +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (u *UsageLogUpsertOne) ClearImageSizeSource() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSizeSource() + }) +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (u *UsageLogUpsertOne) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSizeBreakdown(v) + }) +} + +// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageSizeBreakdown() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSizeBreakdown() + }) +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (u *UsageLogUpsertOne) ClearImageSizeBreakdown() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSizeBreakdown() + }) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -3403,6 +3638,90 @@ func (u *UsageLogUpsertBulk) ClearImageSize() *UsageLogUpsertBulk { }) } +// SetImageInputSize sets the "image_input_size" field. +func (u *UsageLogUpsertBulk) SetImageInputSize(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageInputSize(v) + }) +} + +// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageInputSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageInputSize() + }) +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (u *UsageLogUpsertBulk) ClearImageInputSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageInputSize() + }) +} + +// SetImageOutputSize sets the "image_output_size" field. +func (u *UsageLogUpsertBulk) SetImageOutputSize(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageOutputSize(v) + }) +} + +// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageOutputSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageOutputSize() + }) +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (u *UsageLogUpsertBulk) ClearImageOutputSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageOutputSize() + }) +} + +// SetImageSizeSource sets the "image_size_source" field. +func (u *UsageLogUpsertBulk) SetImageSizeSource(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSizeSource(v) + }) +} + +// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageSizeSource() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSizeSource() + }) +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (u *UsageLogUpsertBulk) ClearImageSizeSource() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSizeSource() + }) +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (u *UsageLogUpsertBulk) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSizeBreakdown(v) + }) +} + +// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageSizeBreakdown() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSizeBreakdown() + }) +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (u *UsageLogUpsertBulk) ClearImageSizeBreakdown() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSizeBreakdown() + }) +} + // 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 bb5ac86c78a..e8fa003c63e 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -739,6 +739,78 @@ func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate { return _u } +// SetImageInputSize sets the "image_input_size" field. +func (_u *UsageLogUpdate) SetImageInputSize(v string) *UsageLogUpdate { + _u.mutation.SetImageInputSize(v) + return _u +} + +// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableImageInputSize(v *string) *UsageLogUpdate { + if v != nil { + _u.SetImageInputSize(*v) + } + return _u +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (_u *UsageLogUpdate) ClearImageInputSize() *UsageLogUpdate { + _u.mutation.ClearImageInputSize() + return _u +} + +// SetImageOutputSize sets the "image_output_size" field. +func (_u *UsageLogUpdate) SetImageOutputSize(v string) *UsageLogUpdate { + _u.mutation.SetImageOutputSize(v) + return _u +} + +// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableImageOutputSize(v *string) *UsageLogUpdate { + if v != nil { + _u.SetImageOutputSize(*v) + } + return _u +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (_u *UsageLogUpdate) ClearImageOutputSize() *UsageLogUpdate { + _u.mutation.ClearImageOutputSize() + return _u +} + +// SetImageSizeSource sets the "image_size_source" field. +func (_u *UsageLogUpdate) SetImageSizeSource(v string) *UsageLogUpdate { + _u.mutation.SetImageSizeSource(v) + return _u +} + +// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableImageSizeSource(v *string) *UsageLogUpdate { + if v != nil { + _u.SetImageSizeSource(*v) + } + return _u +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (_u *UsageLogUpdate) ClearImageSizeSource() *UsageLogUpdate { + _u.mutation.ClearImageSizeSource() + return _u +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (_u *UsageLogUpdate) SetImageSizeBreakdown(v map[string]int) *UsageLogUpdate { + _u.mutation.SetImageSizeBreakdown(v) + return _u +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (_u *UsageLogUpdate) ClearImageSizeBreakdown() *UsageLogUpdate { + _u.mutation.ClearImageSizeBreakdown() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate { _u.mutation.SetCacheTTLOverridden(v) @@ -892,6 +964,21 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} } } + if v, ok := _u.mutation.ImageInputSize(); ok { + if err := usagelog.ImageInputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)} + } + } + if v, ok := _u.mutation.ImageOutputSize(); ok { + if err := usagelog.ImageOutputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)} + } + } + if v, ok := _u.mutation.ImageSizeSource(); ok { + if err := usagelog.ImageSizeSourceValidator(v); err != nil { + return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -1099,6 +1186,30 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.ImageSizeCleared() { _spec.ClearField(usagelog.FieldImageSize, field.TypeString) } + if value, ok := _u.mutation.ImageInputSize(); ok { + _spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value) + } + if _u.mutation.ImageInputSizeCleared() { + _spec.ClearField(usagelog.FieldImageInputSize, field.TypeString) + } + if value, ok := _u.mutation.ImageOutputSize(); ok { + _spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value) + } + if _u.mutation.ImageOutputSizeCleared() { + _spec.ClearField(usagelog.FieldImageOutputSize, field.TypeString) + } + if value, ok := _u.mutation.ImageSizeSource(); ok { + _spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value) + } + if _u.mutation.ImageSizeSourceCleared() { + _spec.ClearField(usagelog.FieldImageSizeSource, field.TypeString) + } + if value, ok := _u.mutation.ImageSizeBreakdown(); ok { + _spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value) + } + if _u.mutation.ImageSizeBreakdownCleared() { + _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } @@ -1974,6 +2085,78 @@ func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne { return _u } +// SetImageInputSize sets the "image_input_size" field. +func (_u *UsageLogUpdateOne) SetImageInputSize(v string) *UsageLogUpdateOne { + _u.mutation.SetImageInputSize(v) + return _u +} + +// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableImageInputSize(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetImageInputSize(*v) + } + return _u +} + +// ClearImageInputSize clears the value of the "image_input_size" field. +func (_u *UsageLogUpdateOne) ClearImageInputSize() *UsageLogUpdateOne { + _u.mutation.ClearImageInputSize() + return _u +} + +// SetImageOutputSize sets the "image_output_size" field. +func (_u *UsageLogUpdateOne) SetImageOutputSize(v string) *UsageLogUpdateOne { + _u.mutation.SetImageOutputSize(v) + return _u +} + +// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableImageOutputSize(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetImageOutputSize(*v) + } + return _u +} + +// ClearImageOutputSize clears the value of the "image_output_size" field. +func (_u *UsageLogUpdateOne) ClearImageOutputSize() *UsageLogUpdateOne { + _u.mutation.ClearImageOutputSize() + return _u +} + +// SetImageSizeSource sets the "image_size_source" field. +func (_u *UsageLogUpdateOne) SetImageSizeSource(v string) *UsageLogUpdateOne { + _u.mutation.SetImageSizeSource(v) + return _u +} + +// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableImageSizeSource(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetImageSizeSource(*v) + } + return _u +} + +// ClearImageSizeSource clears the value of the "image_size_source" field. +func (_u *UsageLogUpdateOne) ClearImageSizeSource() *UsageLogUpdateOne { + _u.mutation.ClearImageSizeSource() + return _u +} + +// SetImageSizeBreakdown sets the "image_size_breakdown" field. +func (_u *UsageLogUpdateOne) SetImageSizeBreakdown(v map[string]int) *UsageLogUpdateOne { + _u.mutation.SetImageSizeBreakdown(v) + return _u +} + +// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field. +func (_u *UsageLogUpdateOne) ClearImageSizeBreakdown() *UsageLogUpdateOne { + _u.mutation.ClearImageSizeBreakdown() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne { _u.mutation.SetCacheTTLOverridden(v) @@ -2140,6 +2323,21 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} } } + if v, ok := _u.mutation.ImageInputSize(); ok { + if err := usagelog.ImageInputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)} + } + } + if v, ok := _u.mutation.ImageOutputSize(); ok { + if err := usagelog.ImageOutputSizeValidator(v); err != nil { + return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)} + } + } + if v, ok := _u.mutation.ImageSizeSource(); ok { + if err := usagelog.ImageSizeSourceValidator(v); err != nil { + return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -2364,6 +2562,30 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.ImageSizeCleared() { _spec.ClearField(usagelog.FieldImageSize, field.TypeString) } + if value, ok := _u.mutation.ImageInputSize(); ok { + _spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value) + } + if _u.mutation.ImageInputSizeCleared() { + _spec.ClearField(usagelog.FieldImageInputSize, field.TypeString) + } + if value, ok := _u.mutation.ImageOutputSize(); ok { + _spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value) + } + if _u.mutation.ImageOutputSizeCleared() { + _spec.ClearField(usagelog.FieldImageOutputSize, field.TypeString) + } + if value, ok := _u.mutation.ImageSizeSource(); ok { + _spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value) + } + if _u.mutation.ImageSizeSourceCleared() { + _spec.ClearField(usagelog.FieldImageSizeSource, field.TypeString) + } + if value, ok := _u.mutation.ImageSizeBreakdown(); ok { + _spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value) + } + if _u.mutation.ImageSizeBreakdownCleared() { + _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } diff --git a/backend/go.sum b/backend/go.sum index e16a9fc08c1..db410b49ce2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -216,6 +216,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -249,6 +251,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -278,6 +282,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -310,6 +316,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2559b112cb9..f4f1d036d55 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -600,6 +600,10 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { FirstTokenMs: l.FirstTokenMs, ImageCount: l.ImageCount, ImageSize: l.ImageSize, + ImageInputSize: l.ImageInputSize, + ImageOutputSize: l.ImageOutputSize, + ImageSizeSource: l.ImageSizeSource, + ImageSizeBreakdown: l.ImageSizeBreakdown, MediaType: l.MediaType, UserAgent: l.UserAgent, CacheTTLOverridden: l.CacheTTLOverridden, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index c2635e339a5..eca838b9aff 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -148,6 +148,65 @@ func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t * require.Equal(t, "claude-3", adminDTO.Model) } +func TestUsageLogFromService_IncludesImageBillingMetadataForUserAndAdmin(t *testing.T) { + t.Parallel() + + imageSize := "4K" + inputSize := "1024x1024" + outputSize := "3840x2160" + source := "output" + log := &service.UsageLog{ + RequestID: "req_image_metadata", + Model: "gpt-image-2", + ImageCount: 2, + ImageSize: &imageSize, + ImageInputSize: &inputSize, + ImageOutputSize: &outputSize, + ImageSizeSource: &source, + ImageSizeBreakdown: map[string]int{"4K": 2}, + } + + userDTO := UsageLogFromService(log) + adminDTO := UsageLogFromServiceAdmin(log) + + for _, got := range []*UsageLog{userDTO, &adminDTO.UsageLog} { + require.Equal(t, 2, got.ImageCount) + require.NotNil(t, got.ImageSize) + require.Equal(t, imageSize, *got.ImageSize) + require.NotNil(t, got.ImageInputSize) + require.Equal(t, inputSize, *got.ImageInputSize) + require.NotNil(t, got.ImageOutputSize) + require.Equal(t, outputSize, *got.ImageOutputSize) + require.NotNil(t, got.ImageSizeSource) + require.Equal(t, source, *got.ImageSizeSource) + require.Equal(t, map[string]int{"4K": 2}, got.ImageSizeBreakdown) + } +} + +func TestUsageLogFromService_PreservesHistoricalMissingImageSize(t *testing.T) { + t.Parallel() + + log := &service.UsageLog{ + RequestID: "req_legacy_image_missing_size", + Model: "gpt-image-2", + ImageCount: 1, + ImageSize: nil, + } + + dto := UsageLogFromService(log) + require.Equal(t, 1, dto.ImageCount) + require.Nil(t, dto.ImageSize) + require.Nil(t, dto.ImageInputSize) + require.Nil(t, dto.ImageOutputSize) + require.Nil(t, dto.ImageSizeSource) + require.Nil(t, dto.ImageSizeBreakdown) + + body, err := json.Marshal(dto) + require.NoError(t, err) + require.Contains(t, string(body), `"image_size":null`) + require.NotContains(t, string(body), `"image_size":"2K"`) +} + func f64Ptr(value float64) *float64 { return &value } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e15a916eec4..957d4fa34dc 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -400,9 +400,13 @@ type UsageLog struct { FirstTokenMs *int `json:"first_token_ms"` // 图片生成字段 - ImageCount int `json:"image_count"` - ImageSize *string `json:"image_size"` - MediaType *string `json:"media_type"` + ImageCount int `json:"image_count"` + ImageSize *string `json:"image_size"` + ImageInputSize *string `json:"image_input_size"` + ImageOutputSize *string `json:"image_output_size"` + ImageSizeSource *string `json:"image_size_source"` + ImageSizeBreakdown map[string]int `json:"image_size_breakdown"` + MediaType *string `json:"media_type"` // User-Agent UserAgent *string `json:"user_agent"` diff --git a/backend/internal/handler/page_handler_test.go b/backend/internal/handler/page_handler_test.go index 0a9f0d96157..a6813cdfa07 100644 --- a/backend/internal/handler/page_handler_test.go +++ b/backend/internal/handler/page_handler_test.go @@ -58,7 +58,7 @@ func TestResolvePageImagePath(t *testing.T) { if !ok { t.Fatal("expected direct image path to be accepted") } - want := filepath.Join(base, "logo.png") + want := mustEvalSymlinks(t, filepath.Join(base, "logo.png")) if got != want { t.Fatalf("path = %q, want %q", got, want) } @@ -67,7 +67,7 @@ func TestResolvePageImagePath(t *testing.T) { if !ok { t.Fatal("expected nested image path to be accepted") } - want = filepath.Join(base, "images", "logo.png") + want = mustEvalSymlinks(t, filepath.Join(base, "images", "logo.png")) if got != want { t.Fatalf("path = %q, want %q", got, want) } @@ -100,3 +100,13 @@ func TestResolvePageImagePathRejectsSymlinkEscape(t *testing.T) { t.Fatalf("expected symlink escape to be rejected, got %q", got) } } + +func mustEvalSymlinks(t *testing.T, path string) string { + t.Helper() + + realPath, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("eval symlinks for %q: %v", path, err) + } + return realPath +} diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index eeee5c23f40..7ef82f0cb30 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -44,6 +44,33 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false) + requireColumn(t, tx, "usage_logs", "image_input_size", "character varying", 32, true) + 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) + requireConstraintDefinitionContains( + t, + tx, + "usage_logs", + "usage_logs_image_size_source_check", + "image_size_source", + "'output'", + "'input'", + "'default'", + "'legacy'", + ) + requireConstraintDefinitionContains( + t, + tx, + "usage_logs", + "usage_logs_image_billing_size_check", + "image_count", + "image_size IS NOT NULL", + "'1K'", + "'2K'", + "'4K'", + "'mixed'", + ) // usage_billing_dedup: billing idempotency narrow table var usageBillingDedupRegclass sql.NullString diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index f2fb87da33e..c4f35d4d8c0 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -28,7 +28,7 @@ import ( gocache "github.com/patrickmn/go-cache" ) -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, 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, 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" // usageLogInsertArgTypes must stay in the same order as: // 1. prepareUsageLogInsert().args @@ -73,6 +73,10 @@ var usageLogInsertArgTypes = [...]string{ "text", // ip_address "integer", // image_count "text", // image_size + "text", // image_input_size + "text", // image_output_size + "text", // image_size_source + "jsonb", // image_size_breakdown "text", // service_tier "text", // reasoning_effort "text", // inbound_endpoint @@ -120,6 +124,24 @@ func appendRawUsageLogModelWhereCondition(conditions []string, args []any, model return conditions, args } +func appendUsageLogBillingModeWhereCondition(conditions []string, args []any, billingMode string) ([]string, []any) { + mode := strings.TrimSpace(billingMode) + if mode == "" { + return conditions, args + } + placeholder := fmt.Sprintf("$%d", len(args)+1) + switch service.BillingMode(mode) { + case service.BillingModeImage: + conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR COALESCE(image_count, 0) > 0)", placeholder)) + case service.BillingModeToken: + conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))", placeholder)) + default: + conditions = append(conditions, fmt.Sprintf("billing_mode = %s", placeholder)) + } + args = append(args, mode) + return conditions, args +} + // appendRawUsageLogModelQueryFilter keeps direct model filters on the raw model column for backward // compatibility with historical rows. Requested/upstream analytics must use // resolveModelDimensionExpression instead. @@ -352,6 +374,10 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -369,7 +395,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 + $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 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -790,6 +816,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -803,7 +833,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*46) + args := make([]any, 0, len(keys)*50) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -867,6 +897,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -915,6 +949,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -1003,6 +1041,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -1016,7 +1058,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*46) + args := make([]any, 0, len(preparedList)*50) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1077,6 +1119,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -1125,6 +1171,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -1181,6 +1231,10 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared ip_address, image_count, image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, @@ -1198,7 +1252,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 + $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 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1225,6 +1279,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { userAgent := nullString(log.UserAgent) ipAddress := nullString(log.IPAddress) imageSize := nullString(log.ImageSize) + imageInputSize := nullString(log.ImageInputSize) + imageOutputSize := nullString(log.ImageOutputSize) + imageSizeSource := nullString(log.ImageSizeSource) + imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown) serviceTier := nullString(log.ServiceTier) reasoningEffort := nullString(log.ReasoningEffort) inboundEndpoint := nullString(log.InboundEndpoint) @@ -1285,6 +1343,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { ipAddress, log.ImageCount, imageSize, + imageInputSize, + imageOutputSize, + imageSizeSource, + imageSizeBreakdown, serviceTier, reasoningEffort, inboundEndpoint, @@ -2662,10 +2724,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) args = append(args, int16(*filters.BillingType)) } - if filters.BillingMode != "" { - conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1)) - args = append(args, filters.BillingMode) - } + conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) @@ -3363,10 +3422,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) args = append(args, int16(*filters.BillingType)) } - if filters.BillingMode != "" { - conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1)) - args = append(args, filters.BillingMode) - } + conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) @@ -4084,6 +4140,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e ipAddress sql.NullString imageCount int imageSize sql.NullString + imageInputSize sql.NullString + imageOutputSize sql.NullString + imageSizeSource sql.NullString + imageSizeBreakdown sql.NullString serviceTier sql.NullString reasoningEffort sql.NullString inboundEndpoint sql.NullString @@ -4134,6 +4194,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &ipAddress, &imageCount, &imageSize, + &imageInputSize, + &imageOutputSize, + &imageSizeSource, + &imageSizeBreakdown, &serviceTier, &reasoningEffort, &inboundEndpoint, @@ -4212,6 +4276,16 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if imageSize.Valid { log.ImageSize = &imageSize.String } + if imageInputSize.Valid { + log.ImageInputSize = &imageInputSize.String + } + if imageOutputSize.Valid { + log.ImageOutputSize = &imageOutputSize.String + } + if imageSizeSource.Valid { + log.ImageSizeSource = &imageSizeSource.String + } + log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown) if serviceTier.Valid { log.ServiceTier = &serviceTier.String } @@ -4378,6 +4452,31 @@ func nullString(v *string) sql.NullString { return sql.NullString{String: *v, Valid: true} } +func nullStringIntMapJSON(v map[string]int) any { + if len(v) == 0 { + return nil + } + payload, err := json.Marshal(v) + if err != nil { + return nil + } + return string(payload) +} + +func stringIntMapFromNullJSON(v sql.NullString) map[string]int { + if !v.Valid || strings.TrimSpace(v.String) == "" { + return nil + } + var out map[string]int + if err := json.Unmarshal([]byte(v.String), &out); err != nil { + return nil + } + if len(out) == 0 { + return nil + } + return out +} + func coalesceTrimmedString(v sql.NullString, fallback string) string { if v.Valid && strings.TrimSpace(v.String) != "" { return v.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 a5ff4bc177a..670ef6e74b6 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -76,6 +76,10 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // ip_address log.ImageCount, sqlmock.AnyArg(), // image_size + sqlmock.AnyArg(), // image_input_size + sqlmock.AnyArg(), // image_output_size + sqlmock.AnyArg(), // image_size_source + sqlmock.AnyArg(), // image_size_breakdown sqlmock.AnyArg(), // service_tier sqlmock.AnyArg(), // reasoning_effort sqlmock.AnyArg(), // inbound_endpoint @@ -155,6 +159,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), log.ImageCount, sqlmock.AnyArg(), + sqlmock.AnyArg(), // image_input_size + sqlmock.AnyArg(), // image_output_size + sqlmock.AnyArg(), // image_size_source + sqlmock.AnyArg(), // image_size_breakdown serviceTier, sqlmock.AnyArg(), sqlmock.AnyArg(), @@ -230,12 +238,72 @@ func TestPrepareUsageLogInsert_ArgCountMatchesTypes(t *testing.T) { require.Len(t, prepared.args, len(usageLogInsertArgTypes)) } +func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) { + imageSize := "4K" + inputSize := "1024x1024" + outputSize := "3840x2160" + source := "output" + prepared := prepareUsageLogInsert(&service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "req-image-metadata", + Model: "gpt-image-2", + RequestedModel: "gpt-image-2", + ImageCount: 2, + ImageSize: &imageSize, + ImageInputSize: &inputSize, + ImageOutputSize: &outputSize, + ImageSizeSource: &source, + ImageSizeBreakdown: map[string]int{"1K": 1, "4K": 1}, + CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC), + }) + + require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[34]) + require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[35]) + require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[36]) + require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[37]) + require.JSONEq(t, `{"1K":1,"4K":1}`, prepared.args[38].(string)) +} + func TestCoalesceTrimmedString(t *testing.T) { require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{}, "fallback")) require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{Valid: true, String: " "}, "fallback")) require.Equal(t, "value", coalesceTrimmedString(sql.NullString{Valid: true, String: "value"}, "fallback")) } +func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) { + tests := []struct { + name string + billingMode string + wantCondition string + }{ + { + name: "image includes legacy image rows", + billingMode: string(service.BillingModeImage), + wantCondition: "(billing_mode = $1 OR COALESCE(image_count, 0) > 0)", + }, + { + name: "token includes legacy non-image rows", + billingMode: string(service.BillingModeToken), + wantCondition: "(billing_mode = $1 OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))", + }, + { + name: "per request remains exact", + billingMode: string(service.BillingModePerRequest), + wantCondition: "billing_mode = $1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conditions, args := appendUsageLogBillingModeWhereCondition(nil, nil, tt.billingMode) + require.Equal(t, []string{tt.wantCondition}, conditions) + require.Equal(t, []any{tt.billingMode}, args) + }) + } +} + func anySliceToDriverValues(values []any) []driver.Value { out := make([]driver.Value, 0, len(values)) for _, value := range values { @@ -528,6 +596,63 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { + t.Run("image_size_metadata_is_scanned", func(t *testing.T) { + now := time.Now().UTC() + log, err := scanUsageLog(usageLogScannerStub{values: []any{ + int64(4), + int64(13), + int64(23), + int64(33), + sql.NullString{Valid: true, String: "req-image-metadata"}, + "gpt-image-2", + sql.NullString{Valid: true, String: "gpt-image-2"}, + sql.NullString{}, + sql.NullInt64{}, + sql.NullInt64{}, + 0, 0, 0, 0, 0, 0, + 0, 0.0, // image_output_tokens, image_output_cost + 0.0, 0.0, 0.0, 0.0, 0.8, 0.8, + 1.0, + sql.NullFloat64{}, + int16(service.BillingTypeBalance), + int16(service.RequestTypeSync), + false, + false, + sql.NullInt64{}, + sql.NullInt64{}, + sql.NullString{}, + sql.NullString{}, + 2, + sql.NullString{Valid: true, String: "4K"}, + sql.NullString{Valid: true, String: "1024x1024"}, + sql.NullString{Valid: true, String: "3840x2160"}, + sql.NullString{Valid: true, String: "output"}, + sql.NullString{Valid: true, String: `{"4K":2}`}, + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + false, + sql.NullInt64{}, + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + sql.NullFloat64{}, + now, + }}) + require.NoError(t, err) + require.Equal(t, 2, log.ImageCount) + require.NotNil(t, log.ImageSize) + require.Equal(t, "4K", *log.ImageSize) + require.NotNil(t, log.ImageInputSize) + require.Equal(t, "1024x1024", *log.ImageInputSize) + require.NotNil(t, log.ImageOutputSize) + require.Equal(t, "3840x2160", *log.ImageOutputSize) + require.NotNil(t, log.ImageSizeSource) + require.Equal(t, "output", *log.ImageSizeSource) + require.Equal(t, map[string]int{"4K": 2}, log.ImageSizeBreakdown) + }) + t.Run("request_type_ws_v2_overrides_legacy", func(t *testing.T) { now := time.Now().UTC() log, err := scanUsageLog(usageLogScannerStub{values: []any{ @@ -567,6 +692,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, 0, sql.NullString{}, + sql.NullString{}, // image_input_size + sql.NullString{}, // image_output_size + sql.NullString{}, // image_size_source + sql.NullString{}, // image_size_breakdown sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, @@ -615,6 +744,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, 0, sql.NullString{}, + sql.NullString{}, // image_input_size + sql.NullString{}, // image_output_size + sql.NullString{}, // image_size_source + sql.NullString{}, // image_size_breakdown sql.NullString{Valid: true, String: "flex"}, sql.NullString{}, sql.NullString{}, @@ -663,6 +796,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, 0, sql.NullString{}, + sql.NullString{}, // image_input_size + sql.NullString{}, // image_output_size + sql.NullString{}, // image_size_source + sql.NullString{}, // image_size_breakdown sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 39869d4dffc..24b5ef68fa4 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -554,6 +554,10 @@ func TestAPIContracts(t *testing.T) { "first_token_ms": 50, "image_count": 0, "image_size": null, + "image_input_size": null, + "image_output_size": null, + "image_size_source": null, + "image_size_breakdown": null, "media_type": null, "cache_ttl_overridden": false, "created_at": "2025-01-02T03:04:05Z", diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index a76e59fbea9..9491b8f628c 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -2094,7 +2094,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } // 解析请求以获取 image_size(用于图片计费) - imageSize := s.extractImageSize(body) + imageInputSize := s.extractImageInputSize(body) + imageSize := normalizeOpenAIImageSizeTier(imageInputSize) switch action { case "generateContent", "streamGenerateContent": @@ -2465,6 +2466,7 @@ handleSuccess: ClientDisconnect: clientDisconnect, ImageCount: imageCount, ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -4065,19 +4067,20 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context // extractImageSize 从 Gemini 请求中提取 image_size 参数 func (s *AntigravityGatewayService) extractImageSize(body []byte) string { + return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body)) +} + +func (s *AntigravityGatewayService) extractImageInputSize(body []byte) string { var req antigravity.GeminiRequest if err := json.Unmarshal(body, &req); err != nil { - return "2K" // 默认 2K + return "" } if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { - size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) - if size == "1K" || size == "2K" || size == "4K" { - return size - } + return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize) } - return "2K" // 默认 2K + return "" } // isImageGenerationModel 判断模型是否为图片生成模型 diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 45025fe6bf6..47975c8cf7a 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -809,6 +809,7 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag if imageCount <= 0 { return &CostBreakdown{} } + imageSize = NormalizeImageBillingTierOrDefault(imageSize) // 获取单价 unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig) diff --git a/backend/internal/service/billing_service_image_test.go b/backend/internal/service/billing_service_image_test.go index 8d3ca987787..0232a2580b2 100644 --- a/backend/internal/service/billing_service_image_test.go +++ b/backend/internal/service/billing_service_image_test.go @@ -48,6 +48,21 @@ func TestCalculateImageCost_GroupCustomPricing(t *testing.T) { require.InDelta(t, 0.30, cost.TotalCost, 0.0001) } +func TestCalculateImageCost_NormalizesInvalidSizeTo2K(t *testing.T) { + svc := &BillingService{} + + price2K := 0.25 + groupConfig := &ImagePriceConfig{Price2K: &price2K} + + for _, imageSize := range []string{"", "auto", "not-a-size"} { + t.Run(imageSize, func(t *testing.T) { + cost := svc.CalculateImageCost("gemini-3-pro-image", imageSize, 2, groupConfig, 1.0) + require.InDelta(t, 0.50, cost.TotalCost, 0.0001) + require.InDelta(t, 0.50, cost.ActualCost, 0.0001) + }) + } +} + // TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍 func TestCalculateImageCost_4KDoublePrice(t *testing.T) { svc := &BillingService{} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 140bdc67e29..09b07a5e67e 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -192,6 +192,46 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) { + imagePrice2K := 0.19 + groupID := int64(901) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_image_default_size", + Model: "gemini-image", + ImageCount: 1, + ImageInputSize: "auto", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 801, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 1.0, + ImagePrice2K: &imagePrice2K, + }, + }, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.ImageSize) + require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize) + require.NotNil(t, usageRepo.lastLog.ImageInputSize) + require.Equal(t, "auto", *usageRepo.lastLog.ImageInputSize) + require.NotNil(t, usageRepo.lastLog.ImageSizeSource) + require.Equal(t, ImageSizeSourceDefault, *usageRepo.lastLog.ImageSizeSource) + require.InDelta(t, 0.19, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.19, usageRepo.lastLog.ActualCost, 1e-12) +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 6151d78ecde..6a0b247b30b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -501,8 +501,13 @@ type ForwardResult struct { ReasoningEffort *string // 图片生成计费字段(图片生成模型使用) - ImageCount int // 生成的图片数量 - ImageSize string // 图片尺寸 "1K", "2K", "4K" + ImageCount int // 生成的图片数量 + ImageSize string // 最终计费尺寸 "1K", "2K", "4K" + ImageInputSize string // 请求中的原始图片尺寸 + ImageOutputSize string // 上游响应中的图片尺寸 + ImageOutputSizes []string + ImageSizeSource string + ImageSizeBreakdown map[string]int } // UpstreamFailoverError indicates an upstream error that should trigger account failover. @@ -8369,6 +8374,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage user := input.User account := input.Account subscription := input.Subscription + ApplyForwardImageBillingResolution(result) // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens // 用于粘性会话切换时的特殊计费处理 @@ -8514,6 +8520,7 @@ func (s *GatewayService) calculateImageCost( billingModel string, multiplier float64, ) *CostBreakdown { + sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { tokens := UsageTokens{ InputTokens: result.Usage.InputTokens, @@ -8527,7 +8534,7 @@ func (s *GatewayService) calculateImageCost( GroupID: &gid, Tokens: tokens, RequestCount: result.ImageCount, - SizeTier: result.ImageSize, + SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, @@ -8547,7 +8554,7 @@ func (s *GatewayService) calculateImageCost( Price4K: apiKey.Group.ImagePrice4K, } } - return s.billingService.CalculateImageCost(billingModel, result.ImageSize, result.ImageCount, groupConfig, multiplier) + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } // calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 @@ -8648,6 +8655,10 @@ func (s *GatewayService) buildRecordUsageLog( FirstTokenMs: result.FirstTokenMs, ImageCount: result.ImageCount, ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, CacheTTLOverridden: cacheTTLOverridden, ChannelID: optionalInt64Ptr(input.ChannelID), ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index ea0c0d7dd39..4342f87d474 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1072,21 +1072,23 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex // 图片生成计费 imageCount := 0 - imageSize := s.extractImageSize(body) + imageInputSize := s.extractImageInputSize(body) + imageSize := normalizeOpenAIImageSizeTier(imageInputSize) if isImageGenerationModel(originalModel) { imageCount = 1 } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: req.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + Stream: req.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -1600,21 +1602,23 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. // 图片生成计费 imageCount := 0 - imageSize := s.extractImageSize(body) + imageInputSize := s.extractImageInputSize(body) + imageSize := normalizeOpenAIImageSizeTier(imageInputSize) if isImageGenerationModel(originalModel) { imageCount = 1 } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -3432,6 +3436,10 @@ func convertClaudeGenerationConfig(req map[string]any) map[string]any { // extractImageSize 从 Gemini 请求中提取 image_size 参数 func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string { + return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body)) +} + +func (s *GeminiMessagesCompatService) extractImageInputSize(body []byte) string { var req struct { GenerationConfig *struct { ImageConfig *struct { @@ -3440,15 +3448,12 @@ func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string { } `json:"generationConfig"` } if err := json.Unmarshal(body, &req); err != nil { - return "2K" + return "" } if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { - size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) - if size == "1K" || size == "2K" || size == "4K" { - return size - } + return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize) } - return "2K" + return "" } diff --git a/backend/internal/service/image_billing_size.go b/backend/internal/service/image_billing_size.go new file mode 100644 index 00000000000..0ca69ac4bbf --- /dev/null +++ b/backend/internal/service/image_billing_size.go @@ -0,0 +1,260 @@ +package service + +import ( + "sort" + "strconv" + "strings" +) + +const ( + ImageBillingSize1K = "1K" + ImageBillingSize2K = "2K" + ImageBillingSize4K = "4K" + + ImageSizeSourceOutput = "output" + ImageSizeSourceInput = "input" + ImageSizeSourceDefault = "default" + ImageSizeSourceLegacy = "legacy" +) + +type ImageBillingSizeResolution struct { + BillingSize string + InputSize string + OutputSize string + Source string + Breakdown map[string]int +} + +func ClassifyImageBillingTier(size string) (string, bool) { + trimmed := strings.TrimSpace(size) + normalized := strings.ToLower(trimmed) + switch normalized { + case "", "auto": + return "", false + case "1k": + return ImageBillingSize1K, true + case "2k": + return ImageBillingSize2K, true + case "4k": + return ImageBillingSize4K, true + case "2048x2048", "2048x1152": + return ImageBillingSize2K, true + case "3840x2160", "2160x3840": + return ImageBillingSize4K, true + } + + width, height, ok := parseImageBillingDimensions(trimmed) + if !ok { + return "", false + } + maxEdge := width + if height > maxEdge { + maxEdge = height + } + switch { + case maxEdge <= 1024: + return ImageBillingSize1K, true + case maxEdge <= 2048: + return ImageBillingSize2K, true + default: + return ImageBillingSize4K, true + } +} + +func NormalizeImageBillingTierOrDefault(size string) string { + if tier, ok := ClassifyImageBillingTier(size); ok { + return tier + } + return ImageBillingSize2K +} + +func ResolveImageBillingSize(inputSize string, outputSizes []string) ImageBillingSizeResolution { + inputSize = strings.TrimSpace(inputSize) + outputSizes = compactTrimmedStrings(outputSizes) + + breakdown := map[string]int{} + outputSize := firstDisplayImageOutputSize(outputSizes) + outputTier := "" + for _, output := range outputSizes { + tier, ok := ClassifyImageBillingTier(output) + if !ok { + continue + } + breakdown[tier]++ + if imageTierRank(tier) > imageTierRank(outputTier) { + outputTier = tier + } + } + if outputTier != "" { + return ImageBillingSizeResolution{ + BillingSize: outputTier, + InputSize: inputSize, + OutputSize: outputSize, + Source: ImageSizeSourceOutput, + Breakdown: normalizeImageSizeBreakdown(breakdown), + } + } + + if tier, ok := ClassifyImageBillingTier(inputSize); ok { + return ImageBillingSizeResolution{ + BillingSize: tier, + InputSize: inputSize, + OutputSize: outputSize, + Source: ImageSizeSourceInput, + } + } + + return ImageBillingSizeResolution{ + BillingSize: ImageBillingSize2K, + InputSize: inputSize, + OutputSize: outputSize, + Source: ImageSizeSourceDefault, + } +} + +func ApplyOpenAIImageBillingResolution(result *OpenAIForwardResult) { + if result == nil || result.ImageCount <= 0 { + return + } + inputSize := strings.TrimSpace(result.ImageInputSize) + if inputSize == "" && strings.TrimSpace(result.ImageSize) != ImageBillingSize2K { + inputSize = strings.TrimSpace(result.ImageSize) + } + outputSizes := result.ImageOutputSizes + if len(outputSizes) == 0 && strings.TrimSpace(result.ImageOutputSize) != "" { + outputSizes = []string{result.ImageOutputSize} + } + resolved := ResolveImageBillingSize(inputSize, outputSizes) + applyImageBillingResolution( + &result.ImageSize, + &result.ImageInputSize, + &result.ImageOutputSize, + &result.ImageSizeSource, + &result.ImageSizeBreakdown, + resolved, + ) +} + +func ApplyForwardImageBillingResolution(result *ForwardResult) { + if result == nil || result.ImageCount <= 0 { + return + } + inputSize := strings.TrimSpace(result.ImageInputSize) + if inputSize == "" && strings.TrimSpace(result.ImageSize) != ImageBillingSize2K { + inputSize = strings.TrimSpace(result.ImageSize) + } + outputSizes := result.ImageOutputSizes + if len(outputSizes) == 0 && strings.TrimSpace(result.ImageOutputSize) != "" { + outputSizes = []string{result.ImageOutputSize} + } + resolved := ResolveImageBillingSize(inputSize, outputSizes) + applyImageBillingResolution( + &result.ImageSize, + &result.ImageInputSize, + &result.ImageOutputSize, + &result.ImageSizeSource, + &result.ImageSizeBreakdown, + resolved, + ) +} + +func applyImageBillingResolution( + billingSize *string, + inputSize *string, + outputSize *string, + source *string, + breakdown *map[string]int, + resolved ImageBillingSizeResolution, +) { + *billingSize = resolved.BillingSize + *inputSize = resolved.InputSize + *outputSize = resolved.OutputSize + *source = resolved.Source + *breakdown = resolved.Breakdown +} + +func parseImageBillingDimensions(size string) (int, int, bool) { + parts := strings.Split(strings.ToLower(strings.TrimSpace(size)), "x") + if len(parts) != 2 { + return 0, 0, false + } + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, false + } + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, false + } + if width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func compactTrimmedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func firstDisplayImageOutputSize(outputSizes []string) string { + for _, output := range outputSizes { + if trimmed := strings.TrimSpace(output); trimmed != "" { + return trimmed + } + } + return "" +} + +func imageTierRank(tier string) int { + switch strings.ToUpper(strings.TrimSpace(tier)) { + case ImageBillingSize1K: + return 1 + case ImageBillingSize2K: + return 2 + case ImageBillingSize4K: + return 3 + default: + return 0 + } +} + +func normalizeImageSizeBreakdown(in map[string]int) map[string]int { + if len(in) == 0 { + return nil + } + out := make(map[string]int, len(in)) + for _, tier := range []string{ImageBillingSize1K, ImageBillingSize2K, ImageBillingSize4K} { + if count := in[tier]; count > 0 { + out[tier] = count + } + } + if len(out) == 0 { + return nil + } + return out +} + +func SortedImageBillingBreakdownKeys(breakdown map[string]int) []string { + keys := make([]string, 0, len(breakdown)) + for key := range breakdown { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + left, right := imageTierRank(keys[i]), imageTierRank(keys[j]) + if left == right { + return keys[i] < keys[j] + } + return left < right + }) + return keys +} diff --git a/backend/internal/service/image_billing_size_test.go b/backend/internal/service/image_billing_size_test.go new file mode 100644 index 00000000000..48c9ac340e7 --- /dev/null +++ b/backend/internal/service/image_billing_size_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClassifyImageBillingTier(t *testing.T) { + tests := []struct { + name string + size string + wantTier string + wantOK bool + }{ + {name: "explicit 2k square", size: "2048x2048", wantTier: "2K", wantOK: true}, + {name: "explicit 2k landscape", size: "2048x1152", wantTier: "2K", wantOK: true}, + {name: "explicit 4k landscape", size: "3840x2160", wantTier: "4K", wantOK: true}, + {name: "explicit 4k portrait", size: "2160x3840", wantTier: "4K", wantOK: true}, + {name: "long edge 1k", size: "1024X768", wantTier: "1K", wantOK: true}, + {name: "long edge 2k", size: "1280x768", wantTier: "2K", wantOK: true}, + {name: "long edge 4k", size: "2560x1600", wantTier: "4K", wantOK: true}, + {name: "tier string 1k", size: "1k", wantTier: "1K", wantOK: true}, + {name: "empty", size: "", wantOK: false}, + {name: "auto", size: "auto", wantOK: false}, + {name: "invalid", size: "not-a-size", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTier, gotOK := ClassifyImageBillingTier(tt.size) + require.Equal(t, tt.wantOK, gotOK) + require.Equal(t, tt.wantTier, gotTier) + }) + } +} + +func TestResolveImageBillingSize(t *testing.T) { + tests := []struct { + name string + inputSize string + outputSizes []string + wantBilling string + wantOutput string + wantSource string + wantBreakdown map[string]int + }{ + { + name: "output wins over input", + inputSize: "1024x1024", + outputSizes: []string{"3840x2160"}, + wantBilling: "4K", + wantOutput: "3840x2160", + wantSource: ImageSizeSourceOutput, + wantBreakdown: map[string]int{"4K": 1}, + }, + { + name: "input fallback", + inputSize: "1024x1024", + wantBilling: "1K", + wantSource: ImageSizeSourceInput, + }, + { + name: "auto defaults", + inputSize: "auto", + wantBilling: "2K", + wantSource: ImageSizeSourceDefault, + }, + { + name: "empty defaults", + inputSize: "", + wantBilling: "2K", + wantSource: ImageSizeSourceDefault, + }, + { + name: "invalid defaults", + inputSize: "largest", + wantBilling: "2K", + wantSource: ImageSizeSourceDefault, + }, + { + name: "mixed output chooses highest tier", + inputSize: "1024x1024", + outputSizes: []string{"1024x1024", "3840x2160", "1280x720"}, + wantBilling: "4K", + wantOutput: "1024x1024", + wantSource: ImageSizeSourceOutput, + wantBreakdown: map[string]int{"1K": 1, "2K": 1, "4K": 1}, + }, + { + name: "unparseable output falls back to parseable input", + inputSize: "2048x1152", + outputSizes: []string{"auto"}, + wantBilling: "2K", + wantOutput: "auto", + wantSource: ImageSizeSourceInput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveImageBillingSize(tt.inputSize, tt.outputSizes) + require.Equal(t, tt.wantBilling, got.BillingSize) + require.Equal(t, tt.inputSize, got.InputSize) + require.Equal(t, tt.wantOutput, got.OutputSize) + require.Equal(t, tt.wantSource, got.Source) + require.Equal(t, tt.wantBreakdown, got.Breakdown) + }) + } +} diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index b6ef106509d..f51b7ee7444 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -170,7 +170,21 @@ func cloneRequestMapForImageIntent(body []byte) map[string]any { return out } +type OpenAIResponsesImageBillingConfig struct { + Model string + SizeTier string + InputSize string +} + func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) { + cfg, err := resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, fallbackModel) + if err != nil { + return "", "", err + } + return cfg.Model, cfg.SizeTier, nil +} + +func resolveOpenAIResponsesImageBillingConfigDetailed(reqBody map[string]any, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) { imageModel := "" imageSize := "" hasImageTool := false @@ -203,12 +217,24 @@ func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackMo imageModel = strings.TrimSpace(fallbackModel) } sizeTier := normalizeOpenAIImageSizeTier(imageSize) - return imageModel, sizeTier, nil + return OpenAIResponsesImageBillingConfig{ + Model: imageModel, + SizeTier: sizeTier, + InputSize: imageSize, + }, nil } func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) { + cfg, err := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, fallbackModel) + if err != nil { + return "", "", err + } + return cfg.Model, cfg.SizeTier, nil +} + +func resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body []byte, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) { reqBody := cloneRequestMapForImageIntent(body) - return resolveOpenAIResponsesImageBillingConfig(reqBody, fallbackModel) + return resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, fallbackModel) } func isOpenAIImageBillingModelAlias(model string) bool { diff --git a/backend/internal/service/image_generation_intent_test.go b/backend/internal/service/image_generation_intent_test.go index 5e7bec79b7f..4621e9d9857 100644 --- a/backend/internal/service/image_generation_intent_test.go +++ b/backend/internal/service/image_generation_intent_test.go @@ -140,9 +140,10 @@ func TestResolveOpenAIResponsesImageBillingConfigDoesNotRejectUnknownSizes(t *te func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) { counter := newOpenAIImageOutputCounter() counter.AddSSEData([]byte(`{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}`)) - counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a"}}`)) - counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b"}]}}`)) + counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a","size":"1024x1024"}}`)) + counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b","size":"3840x2160"}]}}`)) require.Equal(t, 2, counter.Count()) + require.Equal(t, []string{"1024x1024", "3840x2160"}, counter.Sizes()) } func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) { @@ -182,3 +183,36 @@ func TestOpenAIImageOutputCounterFallsBackForInvalidMultilineSSEBody(t *testing. ) require.Equal(t, 2, counter.Count()) } + +func TestCollectOpenAIResponseImageOutputSizesFromJSONBytes(t *testing.T) { + body := []byte(`{ + "output": [ + {"id":"ig_1","type":"image_generation_call","result":"final-a","size":"3840x2160"}, + {"id":"ig_2","type":"image_generation_call","result":"final-b","size":"1024x1024"} + ] + }`) + + require.Equal(t, 2, countOpenAIResponseImageOutputsFromJSONBytes(body)) + require.Equal(t, []string{"3840x2160", "1024x1024"}, collectOpenAIResponseImageOutputSizesFromJSONBytes(body)) +} + +func TestCollectOpenAIResponseImageOutputSizesFromImagesAPIData(t *testing.T) { + body := []byte(`{ + "data": [ + {"b64_json":"final-a","size":"2048x1152"}, + {"b64_json":"final-b","size":"2048x1152"} + ] + }`) + + require.Equal(t, 2, countOpenAIResponseImageOutputsFromJSONBytes(body)) + require.Equal(t, []string{"2048x1152", "2048x1152"}, collectOpenAIResponseImageOutputSizesFromJSONBytes(body)) +} + +func TestCollectOpenAIImageOutputSizesFromSSEBody(t *testing.T) { + body := "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_1\",\"type\":\"image_generation_call\",\"result\":\"final-a\",\"size\":\"3840x2160\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"id\":\"ig_1\",\"type\":\"image_generation_call\",\"result\":\"final-a\"},{\"id\":\"ig_2\",\"type\":\"image_generation_call\",\"result\":\"final-b\",\"size\":\"1024x1024\"}]}}\n\n" + + "data: [DONE]\n\n" + + require.Equal(t, 2, countOpenAIImageOutputsFromSSEBody(body)) + require.Equal(t, []string{"3840x2160", "1024x1024"}, collectOpenAIImageOutputSizesFromSSEBody(body)) +} diff --git a/backend/internal/service/image_output_accounting.go b/backend/internal/service/image_output_accounting.go index 219c0c59609..2f2bd6ae840 100644 --- a/backend/internal/service/image_output_accounting.go +++ b/backend/internal/service/image_output_accounting.go @@ -10,12 +10,18 @@ import ( type openAIImageOutputCounter struct { seen map[string]struct{} + seenSizes map[string]string + seenOrder []string + dataSizes []string count int maxDataCount int } func newOpenAIImageOutputCounter() *openAIImageOutputCounter { - return &openAIImageOutputCounter{seen: make(map[string]struct{})} + return &openAIImageOutputCounter{ + seen: make(map[string]struct{}), + seenSizes: make(map[string]string), + } } func (c *openAIImageOutputCounter) Count() int { @@ -28,6 +34,25 @@ func (c *openAIImageOutputCounter) Count() int { return c.count } +func (c *openAIImageOutputCounter) Sizes() []string { + if c == nil { + return nil + } + sizes := make([]string, 0, len(c.seenOrder)+len(c.dataSizes)) + for _, key := range c.seenOrder { + if size := strings.TrimSpace(c.seenSizes[key]); size != "" { + sizes = append(sizes, size) + } + } + if len(sizes) == 0 && len(c.dataSizes) > 0 { + sizes = append(sizes, c.dataSizes...) + } + if len(sizes) == 0 { + return nil + } + return sizes +} + func (c *openAIImageOutputCounter) AddJSONResponse(body []byte) { if c == nil || len(body) == 0 || !gjson.ValidBytes(body) { return @@ -73,10 +98,20 @@ func (c *openAIImageOutputCounter) addDataArray(data gjson.Result) { if !data.IsArray() { return } - count := len(data.Array()) + items := data.Array() + count := len(items) if count > c.maxDataCount { c.maxDataCount = count } + sizes := make([]string, 0, len(items)) + for _, item := range items { + if size := strings.TrimSpace(item.Get("size").String()); size != "" { + sizes = append(sizes, size) + } + } + if len(sizes) > 0 { + c.dataSizes = sizes + } } func (c *openAIImageOutputCounter) addOutputArray(output gjson.Result) { @@ -120,10 +155,18 @@ func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) { if key == "" { return } + size := strings.TrimSpace(item.Get("size").String()) if _, exists := c.seen[key]; exists { + if size != "" && strings.TrimSpace(c.seenSizes[key]) == "" { + c.seenSizes[key] = size + } return } c.seen[key] = struct{}{} + c.seenOrder = append(c.seenOrder, key) + if size != "" { + c.seenSizes[key] = size + } c.count++ } @@ -142,8 +185,20 @@ func countOpenAIResponseImageOutputsFromJSONBytes(body []byte) int { return counter.Count() } +func collectOpenAIResponseImageOutputSizesFromJSONBytes(body []byte) []string { + counter := newOpenAIImageOutputCounter() + counter.AddJSONResponse(body) + return counter.Sizes() +} + func countOpenAIImageOutputsFromSSEBody(body string) int { counter := newOpenAIImageOutputCounter() counter.AddSSEBody(body) return counter.Count() } + +func collectOpenAIImageOutputSizesFromSSEBody(body string) []string { + counter := newOpenAIImageOutputCounter() + counter.AddSSEBody(body) + return counter.Sizes() +} diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index cf909ec98e9..096f5b1079b 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1320,6 +1320,93 @@ func TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists(t *testing. require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) } +func TestOpenAIGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) { + imagePrice2K := 0.31 + groupID := int64(1201) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_default_size", + Model: "gpt-image-2", + ImageCount: 2, + ImageSize: "", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 11201, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 1.0, + ImagePrice2K: &imagePrice2K, + }, + }, + User: &User{ID: 21201}, + Account: &Account{ID: 31201}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 2, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.ImageSize) + require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize) + require.NotNil(t, usageRepo.lastLog.ImageSizeSource) + require.Equal(t, ImageSizeSourceDefault, *usageRepo.lastLog.ImageSizeSource) + require.Nil(t, usageRepo.lastLog.ImageInputSize) + require.Nil(t, usageRepo.lastLog.ImageOutputSize) + require.InDelta(t, 0.62, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.62, usageRepo.lastLog.ActualCost, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPersistence(t *testing.T) { + imagePrice1K := 0.11 + imagePrice4K := 0.44 + groupID := int64(1202) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_output_size", + Model: "gpt-image-2", + ImageCount: 1, + ImageInputSize: "1024x1024", + ImageOutputSizes: []string{"3840x2160"}, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 11202, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 1.0, + ImagePrice1K: &imagePrice1K, + ImagePrice4K: &imagePrice4K, + }, + }, + User: &User{ID: 21202}, + Account: &Account{ID: 31202}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ImageSize) + require.Equal(t, ImageBillingSize4K, *usageRepo.lastLog.ImageSize) + require.NotNil(t, usageRepo.lastLog.ImageInputSize) + require.Equal(t, "1024x1024", *usageRepo.lastLog.ImageInputSize) + require.NotNil(t, usageRepo.lastLog.ImageOutputSize) + require.Equal(t, "3840x2160", *usageRepo.lastLog.ImageOutputSize) + require.NotNil(t, usageRepo.lastLog.ImageSizeSource) + require.Equal(t, ImageSizeSourceOutput, *usageRepo.lastLog.ImageSizeSource) + require.Equal(t, map[string]int{ImageBillingSize4K: 1}, usageRepo.lastLog.ImageSizeBreakdown) + require.InDelta(t, 0.44, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.44, usageRepo.lastLog.ActualCost, 1e-12) +} + func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTokens(t *testing.T) { imagePrice := 0.02 groupID := int64(12) @@ -1641,3 +1728,42 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( require.InDelta(t, 0.80, cost.TotalCost, 1e-12) require.InDelta(t, 0.80, cost.ActualCost, 1e-12) } + +func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMissingSizeTier(t *testing.T) { + groupID := int64(128) + defaultPrice := 0.10 + price2K := 0.22 + cache := newEmptyChannelCache() + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: "gemini-image"}] = &ChannelModelPricing{ + BillingMode: BillingModeImage, + PerRequestPrice: &defaultPrice, + Intervals: []PricingInterval{{ + TierLabel: "2K", + PerRequestPrice: &price2K, + }}, + } + cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive} + cache.loadedAt = time.Now() + channelService := &ChannelService{} + channelService.cache.Store(cache) + + svc := &GatewayService{ + billingService: NewBillingService(&config.Config{}, nil), + resolver: NewModelPricingResolver(channelService, NewBillingService(&config.Config{}, nil)), + } + + cost := svc.calculateRecordUsageCost( + context.Background(), + &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: ""}, + &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}}, + "gemini-image", + 1.0, + 1.0, + nil, + ) + + require.NotNil(t, cost) + require.Equal(t, string(BillingModeImage), cost.BillingMode) + require.InDelta(t, 0.44, cost.TotalCost, 1e-12) + require.InDelta(t, 0.44, cost.ActualCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index e12b208e372..df2b5a3a1ed 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -228,14 +228,19 @@ type OpenAIForwardResult struct { ServiceTier *string // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // Stored for usage records display; nil means not provided / not applicable. - ReasoningEffort *string - Stream bool - OpenAIWSMode bool - ResponseHeaders http.Header - Duration time.Duration - FirstTokenMs *int - ImageCount int - ImageSize string + ReasoningEffort *string + Stream bool + OpenAIWSMode bool + ResponseHeaders http.Header + Duration time.Duration + FirstTokenMs *int + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSize string + ImageOutputSizes []string + ImageSizeSource string + ImageSizeBreakdown map[string]int } type OpenAIWSRetryMetricsSnapshot struct { @@ -2416,9 +2421,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } imageBillingModel := "" imageSizeTier := "" + imageInputSize := "" if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) { var imageCfgErr error - imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfig(reqBody, billingModel) + imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel) if imageCfgErr != nil { setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{ @@ -2430,6 +2436,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) return nil, imageCfgErr } + imageBillingModel = imageCfg.Model + imageSizeTier = imageCfg.SizeTier + imageInputSize = imageCfg.InputSize } // Re-serialize body only if modified @@ -2671,6 +2680,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco wsResult.UpstreamModel = upstreamModel if wsResult.ImageCount > 0 { wsResult.ImageSize = imageSizeTier + wsResult.ImageInputSize = imageInputSize wsResult.BillingModel = imageBillingModel } return wsResult, nil @@ -2777,6 +2787,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco var usage *OpenAIUsage var firstTokenMs *int imageCount := 0 + var imageOutputSizes []string if reqStream { streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) if err != nil { @@ -2785,6 +2796,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco usage = streamResult.usage firstTokenMs = streamResult.firstTokenMs imageCount = streamResult.imageCount + imageOutputSizes = streamResult.imageOutputSizes } else { nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { @@ -2792,6 +2804,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } usage = nonStreamResult.usage imageCount = nonStreamResult.imageCount + imageOutputSizes = nonStreamResult.imageOutputSizes } // Extract and save Codex usage snapshot from response headers (for OAuth accounts) @@ -2823,6 +2836,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if imageCount > 0 { forwardResult.ImageCount = imageCount forwardResult.ImageSize = imageSizeTier + forwardResult.ImageInputSize = imageInputSize + forwardResult.ImageOutputSizes = imageOutputSizes forwardResult.BillingModel = imageBillingModel } return forwardResult, nil @@ -2927,9 +2942,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } imageBillingModel := "" imageSizeTier := "" + imageInputSize := "" if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) { var imageCfgErr error - imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(body, reqModel) + imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel) if imageCfgErr != nil { setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{ @@ -2941,6 +2957,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( }) return nil, imageCfgErr } + imageBillingModel = imageCfg.Model + imageSizeTier = imageCfg.SizeTier + imageInputSize = imageCfg.InputSize } logger.LegacyPrintf("service.openai_gateway", @@ -3026,6 +3045,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( var usage *OpenAIUsage var firstTokenMs *int imageCount := 0 + var imageOutputSizes []string if reqStream { result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) if err != nil { @@ -3034,6 +3054,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( usage = result.usage firstTokenMs = result.firstTokenMs imageCount = result.imageCount + imageOutputSizes = result.imageOutputSizes } else { result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) if err != nil { @@ -3041,6 +3062,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } usage = result.usage imageCount = result.imageCount + imageOutputSizes = result.imageOutputSizes } if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { @@ -3066,6 +3088,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if imageCount > 0 { forwardResult.ImageCount = imageCount forwardResult.ImageSize = imageSizeTier + forwardResult.ImageInputSize = imageInputSize + forwardResult.ImageOutputSizes = imageOutputSizes forwardResult.BillingModel = imageBillingModel } return forwardResult, nil @@ -3361,15 +3385,17 @@ func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string { } type openaiStreamingResultPassthrough struct { - usage *OpenAIUsage - firstTokenMs *int - imageCount int + usage *OpenAIUsage + firstTokenMs *int + imageCount int + imageOutputSizes []string } type openaiNonStreamingResultPassthrough struct { *OpenAIUsage - usage *OpenAIUsage - imageCount int + usage *OpenAIUsage + imageCount int + imageOutputSizes []string } func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { @@ -3539,7 +3565,12 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel) resultWithUsage := func() *openaiStreamingResultPassthrough { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} + return &openaiStreamingResultPassthrough{ + usage: usage, + firstTokenMs: firstTokenMs, + imageCount: imageCounter.Count(), + imageOutputSizes: imageCounter.Sizes(), + } } for scanner.Scan() { @@ -3696,9 +3727,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( } c.Data(resp.StatusCode, contentType, body) return &openaiNonStreamingResultPassthrough{ - OpenAIUsage: usage, - usage: usage, - imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), }, nil } @@ -3758,9 +3790,10 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c c.Data(resp.StatusCode, contentType, body) return &openaiNonStreamingResultPassthrough{ - OpenAIUsage: usage, - usage: usage, - imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), }, nil } @@ -4182,15 +4215,17 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse( // openaiStreamingResult streaming response result type openaiStreamingResult struct { - usage *OpenAIUsage - firstTokenMs *int - imageCount int + usage *OpenAIUsage + firstTokenMs *int + imageCount int + imageOutputSizes []string } type openaiNonStreamingResult struct { *OpenAIUsage - usage *OpenAIUsage - imageCount int + usage *OpenAIUsage + imageCount int + imageOutputSizes []string } func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { @@ -4303,7 +4338,12 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp needModelReplace := originalModel != mappedModel resultWithUsage := func() *openaiStreamingResult { - return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} + return &openaiStreamingResult{ + usage: usage, + firstTokenMs: firstTokenMs, + imageCount: imageCounter.Count(), + imageOutputSizes: imageCounter.Sizes(), + } } finalizeStream := func() (*openaiStreamingResult, error) { if !sawTerminalEvent { @@ -4711,9 +4751,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r c.Data(resp.StatusCode, contentType, body) return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), }, nil } @@ -4775,9 +4816,10 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte c.Data(resp.StatusCode, contentType, body) return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), }, nil } @@ -5216,6 +5258,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec user := input.User account := input.Account subscription := input.Subscription + ApplyOpenAIImageBillingResolution(result) // 计算实际的新输入token(减去缓存读取的token) // 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费 @@ -5325,6 +5368,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ImageOutputTokens: result.Usage.ImageOutputTokens, ImageCount: result.ImageCount, ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, } if cost != nil { usageLog.InputCost = cost.InputCost @@ -5493,6 +5540,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( result *OpenAIForwardResult, multiplier float64, ) *CostBreakdown { + sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { gid := apiKey.Group.ID @@ -5501,7 +5549,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Model: billingModel, GroupID: &gid, RequestCount: result.ImageCount, - SizeTier: result.ImageSize, + SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, @@ -5520,7 +5568,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Price4K: apiKey.Group.ImagePrice4K, } } - return s.billingService.CalculateImageCost(billingModel, result.ImageSize, result.ImageCount, groupConfig, multiplier) + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index afa94156867..e493f5d802d 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -532,54 +532,7 @@ func isOpenAINativeImageOption(name string) bool { } func normalizeOpenAIImageSizeTier(size string) string { - trimmed := strings.TrimSpace(size) - normalized := strings.ToLower(trimmed) - switch normalized { - case "", "auto": - return "2K" - case "1024x1024": - return "1K" - case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "2048x2048", "2048x1152", "1152x2048": - return "2K" - case "3840x2160", "2160x3840": - return "4K" - } - width, height, ok := parseOpenAIImageSizeDimensions(trimmed) - if !ok { - return "2K" - } - return classifyUnknownOpenAIImageSizeTier(width, height) -} - -const ( - openAIImage2KMaxPixels = 2560 * 1440 -) - -func parseOpenAIImageSizeDimensions(size string) (int, int, bool) { - trimmed := strings.TrimSpace(size) - parts := strings.Split(strings.ToLower(trimmed), "x") - if len(parts) != 2 { - return 0, 0, false - } - width, err := strconv.Atoi(strings.TrimSpace(parts[0])) - if err != nil { - return 0, 0, false - } - height, err := strconv.Atoi(strings.TrimSpace(parts[1])) - if err != nil { - return 0, 0, false - } - if width <= 0 || height <= 0 { - return 0, 0, false - } - return width, height, true -} - -func classifyUnknownOpenAIImageSizeTier(width int, height int) string { - if height > 0 && width > openAIImage2KMaxPixels/height { - return "4K" - } - return "2K" + return NormalizeImageBillingTierOrDefault(size) } func (s *OpenAIGatewayService) ForwardImages( @@ -704,29 +657,46 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( imageCount := parsed.N var firstTokenMs *int if parsed.Stream && isEventStreamResponse(resp.Header) { - streamUsage, streamCount, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime) + streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime) if err != nil { if streamCount > 0 { return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: streamUsage, - Model: requestModel, - UpstreamModel: upstreamModel, - Stream: parsed.Stream, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - FirstTokenMs: ttft, - ImageCount: streamCount, - ImageSize: parsed.SizeTier, + RequestID: resp.Header.Get("x-request-id"), + Usage: streamUsage, + Model: requestModel, + UpstreamModel: upstreamModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: ttft, + ImageCount: streamCount, + ImageSize: parsed.SizeTier, + ImageInputSize: parsed.Size, + ImageOutputSizes: streamSizes, }, err } return nil, err } usage = streamUsage imageCount = streamCount + imageOutputSizes := streamSizes firstTokenMs = ttft + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: usage, + Model: requestModel, + UpstreamModel: upstreamModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: parsed.SizeTier, + ImageInputSize: parsed.Size, + ImageOutputSizes: imageOutputSizes, + }, nil } else { - nonStreamUsage, nonStreamCount, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) + nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) if err != nil { return nil, err } @@ -734,19 +704,21 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if nonStreamCount > 0 { imageCount = nonStreamCount } + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: usage, + Model: requestModel, + UpstreamModel: upstreamModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: parsed.SizeTier, + ImageInputSize: parsed.Size, + ImageOutputSizes: nonStreamSizes, + }, nil } - return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: usage, - Model: requestModel, - UpstreamModel: upstreamModel, - Stream: parsed.Stream, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: parsed.SizeTier, - }, nil } func (s *OpenAIGatewayService) buildOpenAIImagesRequest( @@ -892,10 +864,10 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader { return dst } -func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, error) { +func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { - return OpenAIUsage{}, 0, err + return OpenAIUsage{}, 0, nil, err } responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := "application/json" @@ -907,14 +879,14 @@ func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http c.Data(resp.StatusCode, contentType, body) usage, _ := extractOpenAIUsageFromJSONBytes(body) - return usage, extractOpenAIImageCountFromJSONBytes(body), nil + return usage, extractOpenAIImageCountFromJSONBytes(body), collectOpenAIResponseImageOutputSizesFromJSONBytes(body), nil } func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( resp *http.Response, c *gin.Context, startTime time.Time, -) (OpenAIUsage, int, *int, error) { +) (OpenAIUsage, int, []string, *int, error) { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) if contentType == "" { @@ -925,7 +897,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( flusher, ok := c.Writer.(http.Flusher) if !ok { - return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer") + return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer") } usage := OpenAIUsage{} @@ -1010,12 +982,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( } if err != nil { flushSSEEvent() - return usage, imageCounter.Count(), firstTokenMs, err + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, err } } flushSSEEvent() finalizeFallbackBody() - return usage, imageCounter.Count(), firstTokenMs, nil + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil } type readEvent struct { @@ -1082,11 +1054,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( if !ok { flushSSEEvent() finalizeFallbackBody() - return usage, imageCounter.Count(), firstTokenMs, nil + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil } if ev.err != nil { flushSSEEvent() - return usage, imageCounter.Count(), firstTokenMs, ev.err + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, ev.err } processLine(ev.line) case <-intervalCh: @@ -1095,11 +1067,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( continue } if clientDisconnected { - return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout") + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout") } logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval) _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) - return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream data interval timeout") + return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream data interval timeout") case <-keepaliveCh: if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { continue diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 25cd8228a83..0623925734d 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -72,6 +72,22 @@ func mergeOpenAIResponsesImageMeta(dst *openAIResponsesImageResult, src openAIRe } } +func openAIResponsesImageResultSizes(results []openAIResponsesImageResult) []string { + if len(results) == 0 { + return nil + } + sizes := make([]string, 0, len(results)) + for _, result := range results { + if size := strings.TrimSpace(result.Size); size != "" { + sizes = append(sizes, size) + } + } + if len(sizes) == 0 { + return nil + } + return sizes +} + func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) { switch gjson.GetBytes(payload, "type").String() { case "response.created", "response.in_progress", "response.completed": @@ -547,10 +563,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( c *gin.Context, responseFormat string, fallbackModel string, -) (OpenAIUsage, int, error) { +) (OpenAIUsage, int, []string, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { - return OpenAIUsage{}, 0, err + return OpenAIUsage{}, 0, nil, err } var usage OpenAIUsage @@ -559,10 +575,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( }) results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body) if err != nil { - return OpenAIUsage{}, 0, err + return OpenAIUsage{}, 0, nil, err } if len(results) == 0 { - return OpenAIUsage{}, 0, fmt.Errorf("upstream did not return image output") + return OpenAIUsage{}, 0, nil, fmt.Errorf("upstream did not return image output") } if strings.TrimSpace(firstMeta.Model) == "" { firstMeta.Model = strings.TrimSpace(fallbackModel) @@ -570,11 +586,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat) if err != nil { - return OpenAIUsage{}, 0, err + return OpenAIUsage{}, 0, nil, err } responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody) - return usage, len(results), nil + return usage, len(results), openAIResponsesImageResultSizes(results), nil } func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( @@ -584,7 +600,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( responseFormat string, streamPrefix string, fallbackModel string, -) (OpenAIUsage, int, *int, error) { +) (OpenAIUsage, int, []string, *int, error) { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") @@ -593,7 +609,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( flusher, ok := c.Writer.(http.Flusher) if !ok { - return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer") + return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer") } format := strings.ToLower(strings.TrimSpace(responseFormat)) @@ -603,6 +619,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( usage := OpenAIUsage{} imageCount := 0 + var imageOutputSizes []string var firstTokenMs *int emitted := make(map[string]struct{}) pendingResults := make([]openAIResponsesImageResult, 0, 1) @@ -713,6 +730,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) } imageCount = len(emitted) + imageOutputSizes = openAIResponsesImageResultSizes(finalResults) processDataDone = true } } @@ -753,6 +771,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) } imageCount = len(emitted) + imageOutputSizes = openAIResponsesImageResultSizes(pendingResults) return nil } @@ -769,33 +788,33 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( line, err := reader.ReadBytes('\n') done, processErr := processLine(line) if processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } if err == io.EOF { break } if err != nil { if done, processErr := flushData(); processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } else if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(err.Error())) - return usage, imageCount, firstTokenMs, err + return usage, imageCount, imageOutputSizes, firstTokenMs, err } } if done, processErr := flushData(); processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } else if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } if err := finalizePending(); err != nil { - return usage, imageCount, firstTokenMs, err + return usage, imageCount, imageOutputSizes, firstTokenMs, err } - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } type readEvent struct { @@ -861,30 +880,30 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( case ev, ok := <-events: if !ok { if done, processErr := flushData(); processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } else if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } if err := finalizePending(); err != nil { - return usage, imageCount, firstTokenMs, err + return usage, imageCount, imageOutputSizes, firstTokenMs, err } - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } if ev.err != nil { if done, processErr := flushData(); processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } else if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(ev.err.Error())) - return usage, imageCount, firstTokenMs, ev.err + return usage, imageCount, imageOutputSizes, firstTokenMs, ev.err } done, processErr := processLine(ev.line) if processErr != nil { - return usage, imageCount, firstTokenMs, processErr + return usage, imageCount, imageOutputSizes, firstTokenMs, processErr } if done { - return usage, imageCount, firstTokenMs, nil + return usage, imageCount, imageOutputSizes, firstTokenMs, nil } case <-intervalCh: lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) @@ -892,11 +911,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( continue } if clientDisconnected { - return usage, imageCount, firstTokenMs, fmt.Errorf("image stream incomplete after timeout") + return usage, imageCount, imageOutputSizes, firstTokenMs, fmt.Errorf("image stream incomplete after timeout") } logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) - return usage, imageCount, firstTokenMs, fmt.Errorf("image stream data interval timeout") + return usage, imageCount, imageOutputSizes, firstTokenMs, fmt.Errorf("image stream data interval timeout") case <-keepaliveCh: if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { continue @@ -1019,31 +1038,34 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( defer func() { _ = resp.Body.Close() }() var ( - usage OpenAIUsage - imageCount int - firstTokenMs *int + usage OpenAIUsage + imageCount int + imageOutputSizes []string + firstTokenMs *int ) if parsed.Stream { - usage, imageCount, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel) + usage, imageCount, imageOutputSizes, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel) if err != nil { if imageCount > 0 { return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: usage, - Model: requestModel, - UpstreamModel: requestModel, - Stream: parsed.Stream, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: parsed.SizeTier, + RequestID: resp.Header.Get("x-request-id"), + Usage: usage, + Model: requestModel, + UpstreamModel: requestModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: parsed.SizeTier, + ImageInputSize: parsed.Size, + ImageOutputSizes: imageOutputSizes, }, err } return nil, err } } else { - usage, imageCount, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel) + usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel) if err != nil { return nil, err } @@ -1052,15 +1074,17 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( imageCount = parsed.N } return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: usage, - Model: requestModel, - UpstreamModel: requestModel, - Stream: parsed.Stream, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: parsed.SizeTier, + RequestID: resp.Header.Get("x-request-id"), + Usage: usage, + Model: requestModel, + UpstreamModel: requestModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: parsed.SizeTier, + ImageInputSize: parsed.Size, + ImageOutputSizes: imageOutputSizes, }, nil } diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 45fb24e975e..583fe2b5504 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -149,9 +149,9 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCusto {size: "2048x1152", wantTier: "2K"}, {size: "3840x2160", wantTier: "4K"}, {size: "2160x3840", wantTier: "4K"}, - {size: "1024X768", wantTier: "2K"}, + {size: "1024X768", wantTier: "1K"}, {size: "1280x768", wantTier: "2K"}, - {size: "2560x1440", wantTier: "2K"}, + {size: "2560x1440", wantTier: "4K"}, {size: "2560x1600", wantTier: "4K"}, {size: "auto", wantTier: "2K"}, } @@ -186,7 +186,7 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_UnknownSizesDoNotBlockPass {size: "2048x1153", wantTier: "2K"}, {size: "4096x1024", wantTier: "4K"}, {size: "3840x1024", wantTier: "4K"}, - {size: "512x512", wantTier: "2K"}, + {size: "512x512", wantTier: "1K"}, {size: "invalid", wantTier: "2K"}, {size: "999999999999999999999999999x2", wantTier: "2K"}, } diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 77cf7d95a3f..ca8afc1603d 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2351,18 +2351,19 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ) return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - ImageCount: imageCounter.Count(), - ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + ImageCount: imageCounter.Count(), + ImageOutputSizes: imageCounter.Sizes(), + ServiceTier: extractOpenAIServiceTier(reqBody), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } @@ -2464,6 +2465,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( originalModel string imageBillingModel string imageSizeTier string + imageInputSize string payloadBytes int } @@ -2567,12 +2569,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } imageBillingModel := "" imageSizeTier := "" + imageInputSize := "" if imageIntent { var imageCfgErr error - imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(normalized, originalModel) + imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(normalized, originalModel) if imageCfgErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr) } + imageBillingModel = imageCfg.Model + imageSizeTier = imageCfg.SizeTier + imageInputSize = imageCfg.InputSize } // Apply OpenAI Fast Policy on the response.create frame using the same @@ -2621,6 +2627,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( originalModel: originalModel, imageBillingModel: imageBillingModel, imageSizeTier: imageSizeTier, + imageInputSize: imageInputSize, payloadBytes: len(normalized), }, nil } @@ -2822,7 +2829,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return payload, nil } - sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string) (*OpenAIForwardResult, error) { + sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { if lease == nil { return nil, errors.New("upstream websocket lease is nil") } @@ -3046,6 +3053,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if imageCount > 0 { result.ImageCount = imageCount result.ImageSize = imageSizeTier + result.ImageInputSize = imageInputSize + result.ImageOutputSizes = imageCounter.Sizes() result.BillingModel = imageBillingModel } return result, nil @@ -3057,6 +3066,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentOriginalModel := firstPayload.originalModel currentImageBillingModel := firstPayload.imageBillingModel currentImageSizeTier := firstPayload.imageSizeTier + currentImageInputSize := firstPayload.imageInputSize currentPayloadBytes := firstPayload.payloadBytes isStrictAffinityTurn := func(payload []byte) bool { if !storeDisabled { @@ -3534,7 +3544,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } - result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier) + result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize) if relayErr != nil { lastTurnClean = false if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { @@ -3658,6 +3668,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentOriginalModel = nextPayload.originalModel currentImageBillingModel = nextPayload.imageBillingModel currentImageSizeTier = nextPayload.imageSizeTier + currentImageInputSize = nextPayload.imageInputSize currentPayloadBytes = nextPayload.payloadBytes storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) if !storeDisabled { diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e29d282eb80..d63f47ccf0f 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -162,9 +162,13 @@ type UsageLog struct { CacheTTLOverridden bool // 图片生成字段 - ImageCount int - ImageSize *string - MediaType *string + ImageCount int + ImageSize *string + ImageInputSize *string + ImageOutputSize *string + ImageSizeSource *string + ImageSizeBreakdown map[string]int + MediaType *string CreatedAt time.Time diff --git a/backend/migrations/136_usage_log_image_size_metadata.sql b/backend/migrations/136_usage_log_image_size_metadata.sql new file mode 100644 index 00000000000..76bcb956496 --- /dev/null +++ b/backend/migrations/136_usage_log_image_size_metadata.sql @@ -0,0 +1,51 @@ +-- Add generated-image billing size audit metadata. +-- `image_size` remains the canonical billing tier used for cost calculation. + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS image_input_size VARCHAR(32); + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS image_output_size VARCHAR(32); + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS image_size_source VARCHAR(16); + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS image_size_breakdown JSONB; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'usage_logs_image_size_source_check' + AND conrelid = 'usage_logs'::regclass + ) THEN + ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_image_size_source_check + CHECK ( + image_size_source IS NULL + OR image_size_source IN ('output', 'input', 'default', 'legacy') + ) NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'usage_logs_image_billing_size_check' + AND conrelid = 'usage_logs'::regclass + ) THEN + ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_image_billing_size_check + CHECK ( + image_count <= 0 + OR ( + image_size IS NOT NULL + AND image_size IN ('1K', '2K', '4K', 'mixed') + ) + ) NOT VALID; + END IF; +END $$; diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 629e6aa2050..65ac1548f9f 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -86,19 +86,19 @@