From 66ad405dd6ad3d6f5f286b14a85835843553c2cf Mon Sep 17 00:00:00 2001 From: li Date: Mon, 3 Aug 2026 19:59:58 +0800 Subject: [PATCH 01/25] fix(upstream): set explicit TCP dial timeout on upstream transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.Transport 未设置 DialContext 时使用零值 net.Dialer(Timeout=0), DNS 解析与 TCP 握手没有任何上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。 ResponseHeaderTimeout 只约束连接建立之后等待响应头的阶段,覆盖不到建连。 上游域名被解析到 443 不可达的 IP 时(DNS 污染 / 路由异常),单个账号就要卡满 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误, 客户端表现为"长时间无首字后直接结束"。 - buildUpstreamTransport 显式设置 DialContext(10s)与 TLSHandshakeTimeout(10s) - proxyutil SOCKS5 分支的 forward dialer 由 proxy.Direct(零值 dialer,同样无超时) 换成带 10s 超时的 net.Dialer;该分支会覆盖 Transport.DialContext, 调用方设置的建连超时对它无效,必须在此补上 Fixes #5151 --- backend/internal/pkg/proxyutil/dialer.go | 21 +++++- .../pkg/proxyutil/dialer_timeout_test.go | 58 ++++++++++++++ backend/internal/repository/http_upstream.go | 27 +++++++ .../http_upstream_dial_timeout_test.go | 75 +++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/internal/pkg/proxyutil/dialer_timeout_test.go create mode 100644 backend/internal/repository/http_upstream_dial_timeout_test.go diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go index e437cae342..3e135e3eb9 100644 --- a/backend/internal/pkg/proxyutil/dialer.go +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -16,10 +16,29 @@ import ( "net/http" "net/url" "strings" + "time" "golang.org/x/net/proxy" ) +const ( + // socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。 + socks5DialTimeout = 10 * time.Second + // socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。 + socks5DialKeepAlive = 30 * time.Second +) + +// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。 +// +// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时), +// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会 +// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径 +// 无效,必须在这里补上。 +var socks5ForwardDialer = &net.Dialer{ + Timeout: socks5DialTimeout, + KeepAlive: socks5DialKeepAlive, +} + // ConfigureTransportProxy 根据代理 URL 配置 Transport // // 支持的协议: @@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error return nil case "socks5", "socks5h": - dialer, err := proxy.FromURL(proxyURL, proxy.Direct) + dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer) if err != nil { return fmt.Errorf("create socks5 dialer: %w", err) } diff --git a/backend/internal/pkg/proxyutil/dialer_timeout_test.go b/backend/internal/pkg/proxyutil/dialer_timeout_test.go new file mode 100644 index 0000000000..212bf0adac --- /dev/null +++ b/backend/internal/pkg/proxyutil/dialer_timeout_test.go @@ -0,0 +1,58 @@ +package proxyutil + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var errStub = errors.New("stub dial") + +// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext, +// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer, +// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。 +func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) { + require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0)) + require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout) + require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive) +} + +func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) { + for _, scheme := range []string{"socks5", "socks5h"} { + t.Run(scheme, func(t *testing.T) { + proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080") + require.NoError(t, err) + + transport := &http.Transport{} + require.NoError(t, ConfigureTransportProxy(transport, proxyURL)) + require.NotNil(t, transport.DialContext) + require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy") + }) + } +} + +// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。 +func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) { + proxyURL, err := url.Parse("http://127.0.0.1:8080") + require.NoError(t, err) + + called := false + transport := &http.Transport{} + transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { + called = true + return nil, errStub + } + + require.NoError(t, ConfigureTransportProxy(transport, proxyURL)) + require.NotNil(t, transport.Proxy) + require.NotNil(t, transport.DialContext) + + _, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1") + require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext") +} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index ec42e189f5..2adc9a513d 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -54,6 +54,18 @@ const ( // defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟) // LLM 请求可能排队较久,需要较长超时 defaultResponseHeaderTimeout = 300 * time.Second + // defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒) + // Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段 + // 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束 + // 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。 + // 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满 + // 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。 + defaultUpstreamDialTimeout = 10 * time.Second + // defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致 + defaultUpstreamDialKeepAlive = 30 * time.Second + // defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒) + // 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待 + defaultUpstreamTLSHandshakeTimeout = 10 * time.Second // defaultMaxUpstreamClients: 默认最大客户端缓存数量 // 超出后会淘汰最久未使用的客户端 defaultMaxUpstreamClients = 5000 @@ -1245,6 +1257,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { } } +// newUpstreamDialer 构建上游 Transport 的 TCP dialer。 +// +// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer, +// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。 +func newUpstreamDialer() *net.Dialer { + return &net.Dialer{ + Timeout: defaultUpstreamDialTimeout, + KeepAlive: defaultUpstreamDialKeepAlive, + } +} + // buildUpstreamTransport 构建上游请求的 Transport // 使用配置文件中的连接池参数,支持生产环境调优 // @@ -1257,6 +1280,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // - error: 代理配置错误 // // Transport 参数说明: +// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传) +// - TLSHandshakeTimeout: TLS 握手超时 // - MaxIdleConns: 所有主机的最大空闲连接总数 // - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率) // - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待) @@ -1264,6 +1289,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输) func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) { transport := &http.Transport{ + DialContext: newUpstreamDialer().DialContext, + TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout, MaxIdleConns: settings.maxIdleConns, MaxIdleConnsPerHost: settings.maxIdleConnsPerHost, MaxConnsPerHost: settings.maxConnsPerHost, diff --git a/backend/internal/repository/http_upstream_dial_timeout_test.go b/backend/internal/repository/http_upstream_dial_timeout_test.go new file mode 100644 index 0000000000..00d6ce1a41 --- /dev/null +++ b/backend/internal/repository/http_upstream_dial_timeout_test.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "net" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 回归:上游 Transport 必须显式配置建连超时。 +// +// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0), +// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。 +// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。 +// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。 +func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) { + settings := defaultPoolSettings(nil) + + transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer") + require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout) +} + +func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) { + dialer := newUpstreamDialer() + + require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限") + require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout) + require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive) +} + +// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext, +// 代理地址不可达时必须快速失败而不是挂满内核超时。 +func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) { + proxyURL, err := url.Parse("http://127.0.0.1:1080") + require.NoError(t, err) + + transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.Proxy) + require.NotNil(t, transport.DialContext) +} + +// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。 +func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) { + proxyURL, err := url.Parse("socks5h://127.0.0.1:1080") + require.NoError(t, err) + + transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.DialContext) +} + +// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口, +// 断言 Dialer 走的是自己的超时路径而不是无限等待。 +// (不依赖外网可达性,CI 中确定性执行。) +func TestUpstreamDialerRespectsContextCancellation(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr) + if conn != nil { + _ = conn.Close() + } + require.Error(t, err, "已取消的 context 必须立即中止拨号") +} From d3b5703b55ddf5a7e38cee0baff4767865c0cb68 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 4 Aug 2026 19:14:28 +0800 Subject: [PATCH 02/25] fix(model-plaza): show composite group models --- backend/internal/service/channel_plaza.go | 31 +++++++--- .../internal/service/channel_plaza_test.go | 62 +++++++++++++++++++ .../modelPlaza/PlazaModelPricingTable.vue | 13 +++- .../__tests__/PlazaModelPricingTable.spec.ts | 21 +++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go index a71a6e4f1e..1e4dee2717 100644 --- a/backend/internal/service/channel_plaza.go +++ b/backend/internal/service/channel_plaza.go @@ -28,7 +28,8 @@ type PlazaModel struct { // PlazaGroup 模型广场中以分组为顶层的条目。 // // 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 -// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。 +// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 +// 具体平台),与「可用渠道」页口径一致。 type PlazaGroup struct { ID int64 Name string @@ -99,8 +100,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err order = append(order, g.ID) } - // modelIdx[groupID][modelName] = index into byGroup[groupID].Models - modelIdx := make(map[int64]map[string]int, len(groups)) + type modelKey struct { + platform string + name string + } + // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models + modelIdx := make(map[int64]map[modelKey]int, len(groups)) for i := range channels { ch := &channels[i] if ch.Status != StatusActive { @@ -117,23 +122,28 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err } idx := modelIdx[gid] if idx == nil { - idx = make(map[string]int, len(supported)) + idx = make(map[modelKey]int, len(supported)) modelIdx[gid] = idx } for j := range supported { m := supported[j] - if m.Platform != pg.Platform { + if pg.Platform == PlatformComposite { + if !isConcreteRequestPlatform(m.Platform) { + continue + } + } else if m.Platform != pg.Platform { continue } pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid]) - if at, seen := idx[m.Name]; seen { + key := modelKey{platform: m.Platform, name: m.Name} + if at, seen := idx[key]; seen { // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 if pg.Models[at].Pricing == nil && pricing != nil { pg.Models[at].Pricing = pricing } continue } - idx[m.Name] = len(pg.Models) + idx[key] = len(pg.Models) pg.Models = append(pg.Models, PlazaModel{ Name: m.Name, Platform: m.Platform, @@ -150,7 +160,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err if len(pg.Models) == 0 { continue } - sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name }) + sort.SliceStable(pg.Models, func(i, j int) bool { + if pg.Models[i].Name != pg.Models[j].Name { + return pg.Models[i].Name < pg.Models[j].Name + } + return pg.Models[i].Platform < pg.Models[j].Platform + }) for j := range pg.Models { pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo) } diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/channel_plaza_test.go index 82d426ce99..554654149c 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/channel_plaza_test.go @@ -107,6 +107,68 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) { require.Equal(t, "gpt-5", byName["g-gpt"][0].Name) } +func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2e-6 + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice}, + {Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice}, + {Platform: "", Models: []string{"empty-platform"}}, + {Platform: PlatformComposite, Models: []string{"nested-composite"}}, + {Platform: "unknown-platform", Models: []string{"unknown-platform"}}, + }, + } + groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}} + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 1) + require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct") + require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform) + require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform) + require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12) + require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12) +} + +func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) { + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)}, + {Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)}, + }, + } + groups := []Group{ + {ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1}, + {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}, + } + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 2) + byName := map[string]PlazaGroup{} + for _, group := range out { + byName[group.Name] = group + } + require.Len(t, byName["anthropic-only"].Models, 1) + require.Equal(t, []PlazaModel{{ + Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing, + }}, byName["anthropic-only"].Models) + require.Len(t, byName["composite"].Models, 2) + require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{ + byName["composite"].Models[0].Name, + byName["composite"].Models[1].Name, + }) + require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{ + byName["composite"].Models[0].Platform, + byName["composite"].Models[1].Platform, + }) +} + func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) { inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet") inactive.Status = "inactive" diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 77f76e8836..edc72365ff 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -59,13 +59,22 @@
{{ m.name }} + + {{ platformLabel(m.platform) }} + { // 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价 expect(text).not.toContain('$0.000003') }) + + it('Composite 分组中相同模型名按具体平台分别展示徽章', () => { + const anthropic = tokenModel({ name: 'shared-model', platform: 'anthropic' }) + const openai = tokenModel({ name: 'shared-model', platform: 'openai' }) + const wrapper = mount(PlazaModelPricingTable, { + props: { + models: [anthropic, openai], + platform: 'composite', + rateMultiplier: 1 + } + }) + + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows.map((row) => row.find('td').text())).toEqual([ + 'shared-modelAnthropic', + 'shared-modelOpenAI' + ]) + expect(wrapper.text()).toContain('Anthropic') + expect(wrapper.text()).toContain('OpenAI') + }) }) From fc5a1b78d20977a1afe235a36328932732f41eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=85=E9=80=9A?= <993751+lenzhang@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:26:37 +0800 Subject: [PATCH 03/25] fix(ws): allow Codex prewarm continuation --- .../openai_ws_forwarder_ingress_test.go | 41 ++++++++++++++++++- .../service/openai_ws_forwarder_payload.go | 11 +++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 7753ea9598..a13248e393 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -551,13 +551,22 @@ func TestNormalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(t *testing.T) t.Parallel() normalized, err := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID( - []byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","metadata":{"b":2,"a":1}}`), + []byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","client_metadata":{"request_start_ms":"1"},"stream_options":{"include_usage":true},"generate":false,"metadata":{"b":2,"a":1}}`), ) require.NoError(t, err) require.False(t, gjson.GetBytes(normalized, "input").Exists()) require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(normalized, "client_metadata").Exists()) + require.False(t, gjson.GetBytes(normalized, "stream_options").Exists()) + require.False(t, gjson.GetBytes(normalized, "generate").Exists()) require.Equal(t, float64(1), gjson.GetBytes(normalized, "metadata.a").Float()) + normalized, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID( + []byte(`{"model":"gpt-5.1","generate":true}`), + ) + require.NoError(t, err) + require.True(t, gjson.GetBytes(normalized, "generate").Bool()) + _, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(nil) require.Error(t, err) @@ -662,6 +671,36 @@ func TestShouldKeepIngressPreviousResponseID(t *testing.T) { require.Equal(t, "strict_incremental_ok", reason) }) + t.Run("codex_prewarm_to_business_keep", func(t *testing.T) { + prewarmPayload := []byte(`{ + "type":"response.create", + "model":"gpt-5.1", + "store":false, + "generate":false, + "client_metadata":{"x-codex-ws-stream-request-start-ms":"100"}, + "stream_options":{"include_usage":true}, + "input":[{"type":"input_text","text":"hello"}] + }`) + businessPayload := []byte(`{ + "type":"response.create", + "model":"gpt-5.1", + "store":false, + "client_metadata":{"x-codex-ws-stream-request-start-ms":"200"}, + "previous_response_id":"resp_prewarm", + "input":[{"type":"input_text","text":"hello"}] + }`) + + keep, reason, err := shouldKeepIngressPreviousResponseID( + prewarmPayload, + businessPayload, + "resp_prewarm", + false, + ) + require.NoError(t, err) + require.True(t, keep) + require.Equal(t, "strict_incremental_ok", reason) + }) + t.Run("missing_previous_response_id", func(t *testing.T) { payload := []byte(`{"type":"response.create","model":"gpt-5.1","input":[]}`) keep, reason, err := shouldKeepIngressPreviousResponseID(previousPayload, payload, "resp_turn_1", false) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index a4418527cb..889be207f6 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -449,6 +449,17 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( } delete(decoded, "input") delete(decoded, "previous_response_id") + // Codex changes transport-only metadata for every response.create. These fields + // do not alter the context referenced by previous_response_id and are excluded + // from Codex's own websocket reuse comparison. + delete(decoded, "client_metadata") + delete(decoded, "stream_options") + // Official Codex prewarms a connection with generate=false, then omits the + // field on the business request that continues from the prewarm response. + // Only normalize false so a meaningful generate=true change remains visible. + if generate, ok := decoded["generate"].(bool); ok && !generate { + delete(decoded, "generate") + } return json.Marshal(decoded) } From ce149831331ea6ae41f1472c3c7322fb8fd70229 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 5 Aug 2026 22:35:01 +0800 Subject: [PATCH 04/25] fix(antigravity): support Gemini 3.6 Flash models --- backend/internal/domain/constants.go | 6 ++++++ backend/internal/domain/constants_test.go | 8 ++++++++ backend/internal/pkg/antigravity/claude_types.go | 5 +++++ backend/internal/pkg/antigravity/claude_types_test.go | 5 +++++ backend/internal/service/account.go | 5 +++++ 5 files changed, 29 insertions(+) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index d79b49a4a7..148c1cd5d3 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3.1-flash-image": "gemini-3.1-flash-image", // Gemini 3.1 image preview 映射 "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image", + // Gemini 3.6 Flash tiered models + "gemini-3.6-flash": "gemini-3.6-flash", + "gemini-3.6-flash-high": "gemini-3.6-flash-high", + "gemini-3.6-flash-low": "gemini-3.6-flash-low", + "gemini-3.6-flash-medium": "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered", // Gemini 3 image 兼容映射(向 3.1 image 迁移) "gemini-3-pro-image": "gemini-3.1-flash-image", "gemini-3-pro-image-preview": "gemini-3.1-flash-image", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index 0fb9054f7e..e847e22f30 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) { + for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} { + if got := DefaultAntigravityModelMapping[model]; got != model { + t.Fatalf("expected %s to map to itself, got %q", model, got) + } + } +} + func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index d8732d26ed..c0335c4156 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -178,6 +178,11 @@ var geminiModels = []modelDef{ {ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true}, {ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"}, {ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"}, + {ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z"}, + {ID: "gemini-3.6-flash-high", DisplayName: "Gemini 3.6 Flash High", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"}, } diff --git a/backend/internal/pkg/antigravity/claude_types_test.go b/backend/internal/pkg/antigravity/claude_types_test.go index bb45c2f0c3..65c8078c33 100644 --- a/backend/internal/pkg/antigravity/claude_types_test.go +++ b/backend/internal/pkg/antigravity/claude_types_test.go @@ -20,6 +20,11 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { "gemini-3.1-flash-image", "gemini-3.1-flash-image-preview", "gemini-3-pro-image", // legacy compatibility + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", } for _, id := range requiredIDs { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ab54d3c805..ba177627f5 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -620,6 +620,11 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri "gemini-3-flash", "gemini-3.1-pro-high", "gemini-3.1-pro-low", + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", }) applyAntigravityGemini31ProAliases(result) } From 82ccc187cde44dccb33db45cf404c27ddb99a8d9 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 6 Aug 2026 02:26:42 +0800 Subject: [PATCH 05/25] fix(ops): preserve custom range in error lists --- frontend/src/views/admin/ops/OpsDashboard.vue | 2 ++ .../ops/components/OpsErrorDetailsModal.vue | 7 +++++-- .../ops/utils/__tests__/opsErrorParams.spec.ts | 16 ++++++++++++++++ .../src/views/admin/ops/utils/opsErrorParams.ts | 11 +++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/admin/ops/utils/__tests__/opsErrorParams.spec.ts create mode 100644 frontend/src/views/admin/ops/utils/opsErrorParams.ts diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue index 8bb42f3b62..cd5c672963 100644 --- a/frontend/src/views/admin/ops/OpsDashboard.vue +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -114,6 +114,8 @@ = { page: page.value, page_size: pageSize.value, - time_range: props.timeRange, view: viewMode.value, sort_by: sortBy.value, sort_order: sortOrder.value } + Object.assign(params, buildOpsErrorTimeParams(props.timeRange, props.customStartTime, props.customEndTime)) const platform = String(props.platform || '').trim() if (platform) params.platform = platform @@ -160,7 +163,7 @@ watch( ) watch( - () => [props.timeRange, props.platform, props.groupId] as const, + () => [props.timeRange, props.customStartTime, props.customEndTime, props.platform, props.groupId] as const, () => { if (!props.show) return page.value = 1 diff --git a/frontend/src/views/admin/ops/utils/__tests__/opsErrorParams.spec.ts b/frontend/src/views/admin/ops/utils/__tests__/opsErrorParams.spec.ts new file mode 100644 index 0000000000..d4b8c296c0 --- /dev/null +++ b/frontend/src/views/admin/ops/utils/__tests__/opsErrorParams.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { buildOpsErrorTimeParams } from '../opsErrorParams' + +describe('buildOpsErrorTimeParams', () => { + it('uses explicit timestamps for a complete custom range', () => { + expect(buildOpsErrorTimeParams('custom', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z')).toEqual({ + start_time: '2026-08-01T00:00:00Z', + end_time: '2026-08-02T00:00:00Z' + }) + }) + + it('preserves predefined ranges and falls back for incomplete custom ranges', () => { + expect(buildOpsErrorTimeParams('24h')).toEqual({ time_range: '24h' }) + expect(buildOpsErrorTimeParams('custom', null, null)).toEqual({ time_range: '1h' }) + }) +}) diff --git a/frontend/src/views/admin/ops/utils/opsErrorParams.ts b/frontend/src/views/admin/ops/utils/opsErrorParams.ts new file mode 100644 index 0000000000..d994b229fc --- /dev/null +++ b/frontend/src/views/admin/ops/utils/opsErrorParams.ts @@ -0,0 +1,11 @@ +export function buildOpsErrorTimeParams( + timeRange: string, + customStartTime?: string | null, + customEndTime?: string | null +): Record { + if (timeRange === 'custom' && customStartTime && customEndTime) { + return { start_time: customStartTime, end_time: customEndTime } + } + + return { time_range: timeRange === 'custom' ? '1h' : timeRange } +} From 5b7a64334fcbaa5aca5c52cf15bdc462069e1ab1 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 6 Aug 2026 04:30:10 +0800 Subject: [PATCH 06/25] fix(grok): support task IDs for video owner binding --- backend/internal/service/grok_media.go | 2 +- .../service/openai_gateway_grok_test.go | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 6a1ddf0615..70c6ad7893 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -816,7 +816,7 @@ func extractGrokMediaVideoRequestID(body []byte) string { if len(body) == 0 || !gjson.ValidBytes(body) { return "" } - for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id"} { + for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id", "task_id", "data.task_id", "video.task_id"} { if id := strings.TrimSpace(gjson.GetBytes(body, path).String()); id != "" { return id } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index d087bd5184..19942eae7d 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1216,6 +1216,51 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) require.Equal(t, 10, result.VideoDurationSeconds) } +func TestForwardGrokMediaVideoGenerationReturnsTaskIDAsResponseID(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine-video","prompt":"waves"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 63, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"task_id":"video-task-123"}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") + require.NoError(t, err) + require.Equal(t, "video-task-123", result.ResponseID) +} + +func TestExtractGrokMediaVideoRequestIDPreservesExistingPrecedence(t *testing.T) { + body := []byte(`{ + "request_id":"request-id", + "id":"id", + "task_id":"task-id", + "data":{"request_id":"data-request-id","id":"data-id","task_id":"data-task-id"}, + "video":{"request_id":"video-request-id","id":"video-id","task_id":"video-task-id"} + }`) + + require.Equal(t, "request-id", extractGrokMediaVideoRequestID(body)) +} + func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) From e93f6b9953d55c9e862a8d57821260c840465173 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 6 Aug 2026 20:31:56 +0800 Subject: [PATCH 07/25] fix: avoid cooling OAuth accounts for HTML count token errors --- .../service/openai_gateway_count_tokens.go | 14 ++++++++++++++ .../service/openai_gateway_count_tokens_test.go | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index eccd1c4146..e56b33df45 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -345,12 +345,26 @@ func isOpenAIOAuthInputTokensUnsupported(statusCode int, body []byte) bool { return true } + // OAuth's platform endpoint can be blocked by an upstream proxy before it + // reaches the API and return an HTML 403 page without a structured error. + // Treat that endpoint-level response like the other unsupported cases so + // count_tokens remains a local, non-health-affecting convenience request. + if statusCode == http.StatusForbidden && isHTMLResponse(body) { + return true + } + return strings.Contains(msg, "input_tokens") && (strings.Contains(msg, "not found") || strings.Contains(msg, "not supported") || strings.Contains(msg, "unsupported")) } +func isHTMLResponse(body []byte) bool { + trimmed := strings.TrimSpace(strings.ToLower(string(body))) + return strings.HasPrefix(trimmed, "Forbidden", + }, { name: "404_input_tokens_unsupported", statusCode: http.StatusNotFound, From 02e50cc22d038dabf3c6af92dbb92d1e0321f8d5 Mon Sep 17 00:00:00 2001 From: Puppywang Date: Fri, 7 Aug 2026 03:09:57 +0800 Subject: [PATCH 08/25] fix(security): block OAuth account takeover via pending exchange --- .../handler/auth_oauth_pending_flow.go | 15 ++++ .../handler/auth_oauth_pending_flow_test.go | 86 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 32e669b226..7b1d71dca4 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -1998,6 +1998,21 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { response.Success(c, payload) return } + // ─── 安全修复(账号接管 0day)──────────────────────────────────────────── + // 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自 + // 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode + // 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、 + // 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的 + // applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID, + // 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。 + // 只有两类 session 允许在此处执行 adoption/binding: + // 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户; + // 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。 + // 其余状态一律只返回 payload,不绑定、不消费 session。 + if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) { + response.Success(c, payload) + return + } if !adoptionDecision.hasDecision() { adoptionRequired, _ := payload["adoption_required"].(bool) if adoptionRequired { diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 4da8db2ab6..76c29602e8 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -910,6 +910,92 @@ func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) { require.Nil(t, storedSession.ConsumedAt) } +func TestExchangePendingOAuthCompletionChoiceStateDoesNotBindIdentity(t *testing.T) { + // 回归测试:复刻"补邮箱/创建账户"路径的账号接管 0day。 + // 攻击者用自己的 OAuth 账号登录后,在 create-account 步骤提交受害者邮箱, + // 后端发现邮箱已存在会把 pending session 转入 choice 状态并指向受害者 + // (TargetUserID=受害者、无密码/验证码证明)。此时带 adoption decision 调 + // exchange 绝不能把 OAuth identity 绑定到受害者账号。 + handler, client := newOAuthPendingFlowTestHandler(t, false) + ctx := context.Background() + + victim, err := client.User.Create(). + SetEmail("victim@example.com"). + SetUsername("victim-user"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("choice-state-attack-session-token"). + SetIntent("login"). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("attacker-subject-123"). + SetTargetUserID(victim.ID). + SetResolvedEmail(victim.Email). + SetBrowserSessionKey("choice-state-attack-browser-session-key"). + SetUpstreamIdentityClaims(map[string]any{ + "username": "attacker_linuxdo_user", + "suggested_display_name": "Attacker Display Name", + "suggested_avatar_url": "https://cdn.example/attacker.png", + }). + SetLocalFlowState(map[string]any{ + oauthCompletionResponseKey: map[string]any{ + "step": oauthPendingChoiceStep, + "adoption_required": true, + "force_email_on_signup": true, + "email_binding_required": true, + "existing_account_bindable": true, + "email": victim.Email, + "resolved_email": victim.Email, + "redirect": "/dashboard", + }, + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-state-attack-browser-session-key")}) + ginCtx.Request = req + + handler.ExchangePendingOAuthCompletion(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + data := decodeJSONResponseData(t, recorder) + require.NotContains(t, data, "access_token") + require.Equal(t, oauthPendingChoiceStep, data["step"]) + + // 攻击者的 OAuth identity 绝不能绑定到受害者账号 + identityCount, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("attacker-subject-123"), + ). + Count(ctx) + require.NoError(t, err) + require.Zero(t, identityCount) + + // 受害者资料不得被 adoption 篡改 + storedVictim, err := client.User.Get(ctx, victim.ID) + require.NoError(t, err) + require.Equal(t, "victim-user", storedVictim.Username) + + // session 不得被消费(攻击者无法进入下一环) + storedSession, err := client.PendingAuthSession.Get(ctx, session.ID) + require.NoError(t, err) + require.Nil(t, storedSession.ConsumedAt) +} + func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) { payload := normalizePendingOAuthCompletionResponse(map[string]any{ "access_token": "legacy-access-token", From 99b357083e1b5a860f9987523434092e5ef2fcfa Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 7 Aug 2026 17:42:51 +0800 Subject: [PATCH 09/25] fix(subscription): restore midnight daily quota reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日额度窗口恢复日历日语义(配置时区每天 0 点刷新),修复 v0.1.170 引入的回归: - 手动重置配额后日窗口锚点漂移到重置时刻,此后 0 点不再刷新 - 新建/续费订阅日窗口按购买或首用时刻滚动,而非 0 点刷新 日窗口三处锚点(激活/手动重置/续期)统一写入当天 0 点,自动重置改为跨日历日 边界触发;存量非 0 点锚点在下一个 0 点自愈,无需数据库迁移。周/月窗口保持 期限对齐滚动语义(含到期约束)不变,不回归 issue #5051 的月额度翻倍问题。 日卡一次性额度豁免逻辑保留。 --- .../repository/user_subscription_repo.go | 16 +- ...user_subscription_repo_integration_test.go | 20 ++- backend/internal/server/api_contract_test.go | 4 +- .../middleware/api_key_auth_google_test.go | 10 +- .../server/middleware/api_key_auth_test.go | 12 +- .../subscription_assign_idempotency_test.go | 7 +- .../subscription_daily_midnight_reset_test.go | 162 ++++++++++++++++++ .../subscription_expiry_service_test.go | 4 +- .../subscription_monthly_window_test.go | 14 +- .../service/subscription_reset_quota_test.go | 21 ++- .../internal/service/subscription_service.go | 25 ++- backend/internal/service/user_subscription.go | 43 +++-- .../user_subscription_daily_quota_test.go | 3 +- .../service/user_subscription_port.go | 9 +- 14 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 backend/internal/service/subscription_daily_midnight_reset_test.go diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 8047d7d78b..0af3eda985 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -368,7 +368,7 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) } -func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { client := clientFromContext(ctx, r.client) n, err := client.UserSubscription.Update(). Where( @@ -377,24 +377,24 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int usersubscription.WeeklyWindowStartIsNil(), usersubscription.MonthlyWindowStartIsNil(), ). - SetDailyWindowStart(start). - SetWeeklyWindowStart(start). - SetMonthlyWindowStart(start). + SetDailyWindowStart(dailyStart). + SetWeeklyWindowStart(periodicStart). + SetMonthlyWindowStart(periodicStart). Save(ctx) return r.translateConditionalWindowReset(ctx, client, id, n, err) } -func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error { +func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { client := clientFromContext(ctx, r.client) update := client.UserSubscription.UpdateOneID(id) if resetDaily { - update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart) + update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart) } if resetWeekly { - update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart) + update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart) } if resetMonthly { - update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart) + update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart) } _, err := update.Save(ctx) return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 1d1964a20f..28f4c1b7d3 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { group := s.mustCreateGroup("g-activate") sub := s.mustCreateSubscription(user.ID, group.ID, nil) + dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt) + err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt) s.Require().NoError(err, "ActivateWindows") got, err := s.repo.GetByID(s.ctx, sub.ID) @@ -460,7 +461,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { s.Require().NotNil(got.DailyWindowStart) s.Require().NotNil(got.WeeklyWindowStart) s.Require().NotNil(got.MonthlyWindowStart) - s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond) + s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond) } func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() { @@ -469,15 +472,16 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreserves sub := s.mustCreateSubscription(user.ID, group.ID, nil) activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) manualResetAt := activatedAt.Add(2 * time.Hour) + manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt)) - s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt)) + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt)) + s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt)) // Simulate a concurrent request carrying the original unactivated snapshot. - s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour))) + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour))) got, err := s.repo.GetByID(s.ctx, sub.ID) s.Require().NoError(err) - s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond) s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond) s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond) } @@ -535,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma newWindowStart := oldWindowStart.Add(24 * time.Hour) s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart)) s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3)) - s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart)) + s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart)) got, err := s.repo.GetByID(s.ctx, sub.ID) s.Require().NoError(err) @@ -770,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba s.Require().Equal(active.ID, got.ID, "expected active subscription") activateAt := time.Now().Add(-25 * time.Hour) - s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows") + s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows") s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage") after, err := s.repo.GetByID(s.ctx, active.ID) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9a017f5620..cb7e41007f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -2247,10 +2247,10 @@ func (stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID func (stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error { return errors.New("not implemented") } -func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return errors.New("not implemented") } -func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error { +func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { return errors.New("not implemented") } func (stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error { diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 25adbb3121..6b5f10d48e 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -82,7 +82,7 @@ type fakeGoogleSubscriptionRepo struct { getByID func(ctx context.Context, id int64) (*service.UserSubscription, error) getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) updateStatus func(ctx context.Context, subscriptionID int64, status string) error - activateWindow func(ctx context.Context, id int64, start time.Time) error + activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error resetDaily func(ctx context.Context, id int64, start time.Time) error resetWeekly func(ctx context.Context, id int64, start time.Time) error resetMonthly func(ctx context.Context, id int64, start time.Time) error @@ -231,13 +231,13 @@ func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscripti func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error { return errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { if f.activateWindow != nil { - return f.activateWindow(ctx, id, start) + return f.activateWindow(ctx, id, dailyStart, periodicStart) } return errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return errors.New("not implemented") } func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error { @@ -886,7 +886,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil }, diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 1bfb3519b1..964fe8041b 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -119,7 +119,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { sub.DailyWindowStart = &start sub.DailyUsageUSD = 0 @@ -252,7 +252,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil }, @@ -1631,7 +1631,7 @@ type stubUserSubscriptionRepo struct { getByID func(ctx context.Context, id int64) (*service.UserSubscription, error) getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) updateStatus func(ctx context.Context, subscriptionID int64, status string) error - activateWindow func(ctx context.Context, id int64, start time.Time) error + activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error resetDaily func(ctx context.Context, id int64, start time.Time) error resetWeekly func(ctx context.Context, id int64, start time.Time) error resetMonthly func(ctx context.Context, id int64, start time.Time) error @@ -1753,14 +1753,14 @@ func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscription return errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { if r.activateWindow != nil { - return r.activateWindow(ctx, id, start) + return r.activateWindow(ctx, id, dailyStart, periodicStart) } return errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return errors.New("not implemented") } diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index d6dde5e944..ed6c4e0aae 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -9,6 +9,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/dgraph-io/ristretto" "github.com/stretchr/testify/require" ) @@ -157,10 +158,10 @@ func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error { func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error { panic("unexpected UpdateNotes call") } -func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time) error { +func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error { panic("unexpected ActivateWindows call") } -func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { panic("unexpected ResetUsageWindows call") } func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error { @@ -424,7 +425,7 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) { require.False(t, sub.StartsAt.Before(before)) require.False(t, sub.StartsAt.After(after)) require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt) - require.Equal(t, sub.StartsAt, *sub.DailyWindowStart) + require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点") require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart) require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart) require.Zero(t, sub.DailyUsageUSD) diff --git a/backend/internal/service/subscription_daily_midnight_reset_test.go b/backend/internal/service/subscription_daily_midnight_reset_test.go new file mode 100644 index 0000000000..5f16847511 --- /dev/null +++ b/backend/internal/service/subscription_daily_midnight_reset_test.go @@ -0,0 +1,162 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +// dailyMidnightResetRepo 记录 ResetDailyUsage 收到的新窗口起点。 +type dailyMidnightResetRepo struct { + userSubRepoNoop + + resetCalled bool + newWindowStart time.Time +} + +func (r *dailyMidnightResetRepo) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, newWindowStart time.Time) error { + r.resetCalled = true + r.newWindowStart = newWindowStart + return nil +} + +// midnightTestBase 返回一个固定日期在配置时区的 0 点,测试用时刻均从它推导, +// 保证断言在任意本地时区下都与生产逻辑同构。 +func midnightTestBase() time.Time { + return timezone.StartOfDay(time.Date(2026, 8, 6, 12, 0, 0, 0, timezone.Location())) +} + +func newMidnightTestSub(dailyWindowStart time.Time, base time.Time) *UserSubscription { + start := dailyWindowStart + return &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: base.AddDate(0, 0, -3), + ExpiresAt: base.AddDate(0, 0, 30), + DailyUsageUSD: 43.34, + DailyWindowStart: &start, + } +} + +// 手动重置(或任何原因)留下的非 0 点锚点,跨 0 点后必须重置, +// 且新窗口起点是当天 0 点,而不是锚点+24h 的滚动时刻。 +func TestCheckAndResetWindows_DailyResetsAtMidnightNotRollingAnchor(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) // 昨日 16:49 手动重置 + now := base.AddDate(0, 0, 1).Add(5 * time.Minute) // 次日 00:05 + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(manualResetAt, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.True(t, repo.resetCalled, "跨 0 点后日窗口必须重置") + require.Equal(t, base.AddDate(0, 0, 1), repo.newWindowStart, "新窗口起点应为当天 0 点") + require.Zero(t, sub.DailyUsageUSD) + require.Equal(t, base.AddDate(0, 0, 1), *sub.DailyWindowStart) +} + +// 同一日历日内(即使已过锚点+若干小时)不得重置:手动重置当天剩余时间继续累计用量。 +func TestCheckAndResetWindows_DailyNoResetWithinSameCalendarDay(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) + now := base.Add(23*time.Hour + 59*time.Minute) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(manualResetAt, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.False(t, repo.resetCalled, "同一日历日内不应重置日窗口") + require.Equal(t, 43.34, sub.DailyUsageUSD) +} + +// 0.1.170/171 期间产生的滚动锚点(如多日前的 17:18)自愈:下一次维护即拉回今天 0 点。 +func TestCheckAndResetWindows_LegacyRollingAnchorHealsToMidnight(t *testing.T) { + base := midnightTestBase() + staleAnchor := base.AddDate(0, 0, -3).Add(17*time.Hour + 18*time.Minute) + now := base.Add(10 * time.Hour) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(staleAnchor, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.True(t, repo.resetCalled) + require.Equal(t, base, repo.newWindowStart, "滚动锚点应被拉回今天 0 点,而非按 17:18 步进") +} + +// 手动重置写入的 0 点锚点不会改变刷新节奏:次日 0 点照常需要重置。 +func TestNeedsDailyReset_MidnightScheduleSurvivesManualReset(t *testing.T) { + base := midnightTestBase() + sub := newMidnightTestSub(base, base) // 手动重置后锚点=当天 0 点 + + require.False(t, sub.NeedsDailyResetAt(base.Add(23*time.Hour+54*time.Minute)), "当天 23:54 不应重置") + require.True(t, sub.NeedsDailyResetAt(base.AddDate(0, 0, 1).Add(time.Minute)), "次日 00:01 应重置") +} + +// 多日订阅的日窗口展示的下次刷新时间固定为次日 0 点(截图中「X 小时后重置」的数据源)。 +func TestDailyResetTime_NextMidnightForMultiDaySubscription(t *testing.T) { + base := midnightTestBase() + + // 0 点锚点 → 次日 0 点 + sub := newMidnightTestSub(base, base) + resetAt := sub.DailyResetTime() + require.NotNil(t, resetAt) + require.Equal(t, base.AddDate(0, 0, 1), *resetAt) + + // 非 0 点滚动锚点 → 仍是其所在日的次日 0 点,而非锚点+24h + rolling := newMidnightTestSub(base.Add(16*time.Hour+49*time.Minute), base) + resetAt = rolling.DailyResetTime() + require.NotNil(t, resetAt) + require.Equal(t, base.AddDate(0, 0, 1), *resetAt) +} + +// 截图场景:跨 0 点后(后台尚未收到请求推进窗口)列表展示即应清零日用量。 +func TestNormalizeExpiredWindows_DailyUsageClearsAfterMidnight(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) + now := base.AddDate(0, 0, 1).Add(time.Minute) // 次日 00:01 + + subs := []UserSubscription{*newMidnightTestSub(manualResetAt, base)} + normalizeExpiredWindowsAt(subs, now) + + require.Zero(t, subs[0].DailyUsageUSD, "跨 0 点后展示的日用量应清零") + require.Nil(t, subs[0].DailyWindowStart) +} + +// 日卡(一次性日额度)不受 0 点语义影响:跨 0 点不重置。 +func TestCheckAndResetWindows_OneTimeDailyCardStillExemptFromMidnightReset(t *testing.T) { + base := midnightTestBase() + startsAt := base.Add(17 * time.Hour) + anchor := base + now := base.AddDate(0, 0, 1).Add(2 * time.Hour) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 1), + DailyUsageUSD: 10, + DailyWindowStart: &anchor, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.False(t, repo.resetCalled, "日卡为一次性配额,跨 0 点不应重置") + require.Equal(t, 10.0, sub.DailyUsageUSD) +} diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index bb4372fa46..74b82e56c3 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -87,11 +87,11 @@ func (r *subscriptionExpiryRepoStub) UpdateNotes(context.Context, int64, string) return nil } -func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time) error { +func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time, time.Time) error { return nil } -func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return nil } diff --git a/backend/internal/service/subscription_monthly_window_test.go b/backend/internal/service/subscription_monthly_window_test.go index 6cd4410c7b..3e71ca17ef 100644 --- a/backend/internal/service/subscription_monthly_window_test.go +++ b/backend/internal/service/subscription_monthly_window_test.go @@ -8,12 +8,14 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) type activateWindowUserSubRepo struct { userSubRepoNoop - windowStart time.Time + dailyStart time.Time + periodicStart time.Time } type monthlyResetUserSubRepo struct { @@ -28,8 +30,9 @@ func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, return nil } -func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error { - r.windowStart = start +func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, dailyStart, periodicStart time.Time) error { + r.dailyStart = dailyStart + r.periodicStart = periodicStart return nil } @@ -47,8 +50,9 @@ func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) { require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub)) - require.Equal(t, activatedAt, repo.windowStart) - monthlyWindowStart := repo.windowStart + require.Equal(t, activatedAt, repo.periodicStart) + require.Equal(t, timezone.StartOfDay(activatedAt), repo.dailyStart) + monthlyWindowStart := repo.periodicStart resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour)) require.True(t, ok) require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt) diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go index df16db1d56..43ea5b0d10 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) @@ -24,7 +25,8 @@ type resetQuotaUserSubRepoStub struct { resetDailyErr error resetWeeklyErr error resetMonthlyErr error - windowStart time.Time + dailyStart time.Time + periodicStart time.Time } func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) { @@ -35,11 +37,12 @@ func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserS return &cp, nil } -func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, windowStart time.Time) error { +func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { r.resetDailyCalled = resetDaily r.resetWeeklyCalled = resetWeekly r.resetMonthlyCalled = resetMonthly - r.windowStart = windowStart + r.dailyStart = dailyStart + r.periodicStart = periodicStart if resetDaily && r.resetDailyErr != nil { return r.resetDailyErr } @@ -54,15 +57,15 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64 } if resetDaily { r.sub.DailyUsageUSD = 0 - r.sub.DailyWindowStart = &windowStart + r.sub.DailyWindowStart = &dailyStart } if resetWeekly { r.sub.WeeklyUsageUSD = 0 - r.sub.WeeklyWindowStart = &windowStart + r.sub.WeeklyWindowStart = &periodicStart } if resetMonthly { r.sub.MonthlyUsageUSD = 0 - r.sub.MonthlyWindowStart = &windowStart + r.sub.MonthlyWindowStart = &periodicStart } return nil } @@ -105,8 +108,10 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage") require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage") require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") - require.Equal(t, resetAt, stub.windowStart) - require.Equal(t, resetAt, *result.DailyWindowStart) + // 手动重置后日窗口锚定当天 0 点(保持 0 点刷新节奏),周窗口锚定重置时刻。 + require.Equal(t, timezone.StartOfDay(resetAt), stub.dailyStart) + require.Equal(t, resetAt, stub.periodicStart) + require.Equal(t, timezone.StartOfDay(resetAt), *result.DailyWindowStart) require.Equal(t, resetAt, *result.WeeklyWindowStart) } diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 47b18c582e..7d716b7a8e 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/dgraph-io/ristretto" "golang.org/x/sync/singleflight" ) @@ -380,13 +381,15 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription { renewed := *existingSub - windowStart := startsAt + // 日窗口按日历日对齐(0 点刷新);周/月窗口按订阅期限对齐(锚点为新周期起点)。 + dailyWindowStart := timezone.StartOfDay(startsAt) + periodicWindowStart := startsAt renewed.StartsAt = startsAt renewed.ExpiresAt = expiresAt renewed.Status = SubscriptionStatusActive - renewed.DailyWindowStart = &windowStart - renewed.WeeklyWindowStart = &windowStart - renewed.MonthlyWindowStart = &windowStart + renewed.DailyWindowStart = &dailyWindowStart + renewed.WeeklyWindowStart = &periodicWindowStart + renewed.MonthlyWindowStart = &periodicWindowStart renewed.DailyUsageUSD = 0 renewed.WeeklyUsageUSD = 0 renewed.MonthlyUsageUSD = 0 @@ -858,7 +861,9 @@ func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub return nil } - return s.userSubRepo.ActivateWindows(ctx, sub.ID, now) + // 日窗口锚定当天 0 点(日历日语义);周/月窗口锚定首次使用时刻(期限对齐语义, + // 锚点不得早于 StartsAt,否则最后一个不完整周期会重复发放额度,见 issue #5051)。 + return s.userSubRepo.ActivateWindows(ctx, sub.ID, timezone.StartOfDay(now), now) } // AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows. @@ -870,8 +875,10 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI if err != nil { return nil, err } - windowStart := s.now() - if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil { + now := s.now() + // 日窗口锚点取当天 0 点:手动重置只清空用量,不改变“每天 0 点刷新”的节奏。 + // 周/月窗口保持锚定重置时刻(期限对齐滚动窗口语义)。 + if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, timezone.StartOfDay(now), now); err != nil { return nil, err } // Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design, @@ -890,8 +897,8 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use now := s.now() needsInvalidateCache := false - // 日窗口重置(24小时) - if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok { + // 日窗口重置(每天 0 点刷新,按日历日对齐) + if windowStart, ok := sub.automaticDailyWindowStartAt(now); ok { expectedWindowStart := sub.DailyWindowStart if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 3a73554a4b..fb4d77f0f1 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -1,6 +1,10 @@ package service -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) const subscriptionDayDuration = 24 * time.Hour @@ -75,13 +79,8 @@ func (s *UserSubscription) NeedsDailyReset() bool { } func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool { - if s.DailyWindowStart == nil { - return false - } - if s.HasOneTimeDailyQuota() { - return false - } - return !now.Before(s.DailyWindowStart.Add(24 * time.Hour)) + _, ok := s.automaticDailyWindowStartAt(now) + return ok } func (s *UserSubscription) NeedsWeeklyReset() bool { @@ -107,8 +106,26 @@ func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool { } func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool { - _, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now) - return !s.HasOneTimeDailyQuota() && ok + _, ok := s.automaticDailyWindowStartAt(now) + return ok +} + +// automaticDailyWindowStartAt 计算日窗口按“配置时区日历日”对齐后的当前窗口起点。 +// 日额度固定在每天 0 点刷新(与周/月的期限对齐滚动窗口语义不同),因此只要持久化 +// 的窗口起点落在更早的日历日,就允许推进到今天 0 点。手动重置、激活等写入的任何 +// 非 0 点锚点都会在下一个 0 点被拉回日历日边界,不会永久漂移刷新时刻。 +func (s *UserSubscription) automaticDailyWindowStartAt(now time.Time) (time.Time, bool) { + if s.DailyWindowStart == nil { + return time.Time{}, false + } + if s.HasOneTimeDailyQuota() { + return time.Time{}, false + } + today := timezone.StartOfDay(now) + if !today.After(timezone.StartOfDay(*s.DailyWindowStart)) { + return time.Time{}, false + } + return today, true } func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool { @@ -121,6 +138,9 @@ func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool { return ok } +// automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。 +// 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整 +// 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。 func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) { if previous == nil { return time.Time{}, false @@ -155,7 +175,8 @@ func (s *UserSubscription) DailyResetTime() *time.Time { t := s.ExpiresAt return &t } - t := s.DailyWindowStart.Add(24 * time.Hour) + // 日窗口按日历日对齐:下次刷新固定在窗口起点所在日的次日 0 点。 + t := timezone.StartOfDay(*s.DailyWindowStart).AddDate(0, 0, 1) return &t } diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index 6e62eb3539..43e9799333 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) @@ -58,7 +59,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt") require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1))) require.NotNil(t, renewed.DailyWindowStart) - require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart) + require.Equal(t, timezone.StartOfDay(renewed.StartsAt), *renewed.DailyWindowStart, "续期后日窗口应锚定当天 0 点") require.Equal(t, 0.0, renewed.DailyUsageUSD) require.Equal(t, 0.0, renewed.WeeklyUsageUSD) require.Equal(t, 0.0, renewed.MonthlyUsageUSD) diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index 7ce79f4b27..a20e4a4f26 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -29,8 +29,13 @@ type UserSubscriptionRepository interface { UpdateStatus(ctx context.Context, subscriptionID int64, status string) error UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error - ActivateWindows(ctx context.Context, id int64, start time.Time) error - ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error + // ActivateWindows 首次使用时激活用量窗口。日窗口按日历日对齐,锚点为当天 0 点 + // (dailyStart);周/月窗口为期限对齐滚动窗口,锚点为激活时刻(periodicStart)。 + // 仅当三个窗口均未激活时生效。 + ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error + // ResetUsageWindows 手动重置所选窗口的用量。日窗口锚点写入 dailyStart(当天 0 点, + // 保持 0 点刷新节奏不漂移);周/月窗口锚点写入 periodicStart(重置时刻)。 + ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error ResetWeeklyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error ResetMonthlyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error From e687ca3e9d9c943bb7b1edb0f0622b783c671963 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 18:45:05 +0800 Subject: [PATCH 10/25] =?UTF-8?q?fix(ops):=20=E7=B3=BB=E7=BB=9F=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=90=BD=E5=BA=93=E5=A4=B1=E8=B4=A5=E5=90=8E=E9=80=80?= =?UTF-8?q?=E9=81=BF=E9=87=8D=E8=AF=95=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8B=96?= =?UTF-8?q?=E5=9E=AE=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=9E=E6=8E=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpsSystemLogSink 的 flush 没有任何失败退避:ticker 每 1 秒触发一次,每次都 带 5 秒超时打一批 COPY,失败后立刻在下一个 tick 再来一次。远程 PostgreSQL 上 COPY 被 context 取消会导致协议失步、连接被服务端 FATAL 终止,于是这条循环 变成持续的「占用连接 → 超时取消 → 销毁重建」;在 max_open 较小的连接池里, 日志通道会长期占满连接,业务侧最终报 Billing 503。 改为连续失败后指数退避(2s 起,翻倍,封顶 60s): - 退避窗口内直接丢弃当前批次并计入 dropped_count,不再触达数据库; - 任意一次成功立即清空失败计数与抑制窗口; - 失败日志随退避天然收敛为每个窗口至多一条,并附带 failures/backoff。 Fixes #5265 --- .../internal/service/ops_system_log_sink.go | 71 +++++- .../ops_system_log_sink_backoff_test.go | 225 ++++++++++++++++++ 2 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 backend/internal/service/ops_system_log_sink_backoff_test.go diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index 1cbac237c4..7a247a290d 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -34,6 +34,10 @@ type OpsSystemLogSink struct { batchSize int flushInterval time.Duration + // 连续写入失败后的退避参数。构造后只读,测试可在 Start 前覆盖。 + flushBackoff time.Duration + flushBackoffMax time.Duration + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -48,22 +52,54 @@ type OpsSystemLogSink struct { const maxSystemLogHostLength = 255 +const ( + // 首次写入失败后暂停落库的时长,之后逐次翻倍到上限。 + defaultOpsSystemLogFlushBackoff = 2 * time.Second + // 退避上限。日志是尽力而为的观测数据,不值得为它无限期占用连接池。 + defaultOpsSystemLogFlushBackoffMax = 60 * time.Second +) + func NewOpsSystemLogSink(opsRepo OpsRepository) *OpsSystemLogSink { ctx, cancel := context.WithCancel(context.Background()) rawHost, err := os.Hostname() s := &OpsSystemLogSink{ - opsRepo: opsRepo, - host: normalizeSystemLogHost(rawHost, err), - queue: make(chan *logger.LogEvent, 5000), - batchSize: 200, - flushInterval: time.Second, - ctx: ctx, - cancel: cancel, + opsRepo: opsRepo, + host: normalizeSystemLogHost(rawHost, err), + queue: make(chan *logger.LogEvent, 5000), + batchSize: 200, + flushInterval: time.Second, + flushBackoff: defaultOpsSystemLogFlushBackoff, + flushBackoffMax: defaultOpsSystemLogFlushBackoffMax, + ctx: ctx, + cancel: cancel, } s.lastError.Store("") return s } +// flushBackoffFor 返回第 failures 次连续失败后的退避时长(指数退避,封顶)。 +func (s *OpsSystemLogSink) flushBackoffFor(failures int) time.Duration { + base := s.flushBackoff + if base <= 0 { + base = defaultOpsSystemLogFlushBackoff + } + maxBackoff := s.flushBackoffMax + if maxBackoff <= 0 { + maxBackoff = defaultOpsSystemLogFlushBackoffMax + } + if maxBackoff < base { + maxBackoff = base + } + backoff := base + for i := 1; i < failures && backoff < maxBackoff; i++ { + backoff *= 2 + } + if backoff > maxBackoff { + backoff = maxBackoff + } + return backoff +} + func normalizeSystemLogHost(host string, err error) string { host = strings.TrimSpace(host) if err != nil || host == "" { @@ -146,20 +182,37 @@ func (s *OpsSystemLogSink) run() { defer ticker.Stop() batch := make([]*logger.LogEvent, 0, s.batchSize) + // 仅在本 goroutine 内读写,无需加锁。 + failures := 0 + var suppressedUntil time.Time flush := func(baseCtx context.Context) { if len(batch) == 0 { return } + now := time.Now() + if now.Before(suppressedUntil) { + // 退避窗口内直接丢弃本批:日志是尽力而为的观测数据,继续攒批只会把 + // 压力转移到内存,而每次重试都会再占用并取消一条池内连接。 + atomic.AddUint64(&s.droppedCount, uint64(len(batch))) + batch = batch[:0] + return + } started := time.Now() inserted, err := s.flushBatch(baseCtx, batch) delay := time.Since(started) if err != nil { + failures++ + backoff := s.flushBackoffFor(failures) + suppressedUntil = time.Now().Add(backoff) atomic.AddUint64(&s.writeFailed, uint64(len(batch))) s.lastError.Store(err.Error()) - _, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"ops system log sink flush failed\" err=%v batch=%d\n", - time.Now().Format(time.RFC3339Nano), err, len(batch), + // 每个退避窗口至多一条,避免数据库故障期间刷屏。 + _, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"ops system log sink flush failed\" err=%v batch=%d failures=%d backoff=%s\n", + time.Now().Format(time.RFC3339Nano), err, len(batch), failures, backoff, ) } else { + failures = 0 + suppressedUntil = time.Time{} atomic.AddUint64(&s.writtenCount, uint64(inserted)) atomic.AddUint64(&s.totalDelayNs, uint64(delay.Nanoseconds())) s.lastError.Store("") diff --git a/backend/internal/service/ops_system_log_sink_backoff_test.go b/backend/internal/service/ops_system_log_sink_backoff_test.go new file mode 100644 index 0000000000..3a29cb6d0d --- /dev/null +++ b/backend/internal/service/ops_system_log_sink_backoff_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +func opsSystemLogBackoffEvent() *logger.LogEvent { + return &logger.LogEvent{ + Time: time.Now().UTC(), + Level: "warn", + Component: "app", + Message: "boom", + Fields: map[string]any{}, + } +} + +// pumpOpsSystemLogEvents 持续投递日志事件,模拟故障期间业务侧不断产生 WARN/ERROR。 +func pumpOpsSystemLogEvents(t *testing.T, sink *OpsSystemLogSink) { + t.Helper() + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-stop: + return + default: + } + sink.WriteLogEvent(opsSystemLogBackoffEvent()) + time.Sleep(2 * time.Millisecond) + } + }() + t.Cleanup(func() { + close(stop) + <-done + }) +} + +func waitForOpsSystemLogCondition(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(5 * time.Millisecond) + } + return cond() +} + +func TestOpsSystemLogSinkFlushBackoffFor(t *testing.T) { + sink := &OpsSystemLogSink{flushBackoff: time.Second, flushBackoffMax: 8 * time.Second} + + cases := []struct { + name string + failures int + want time.Duration + }{ + {"first_failure", 1, time.Second}, + {"second_failure", 2, 2 * time.Second}, + {"third_failure", 3, 4 * time.Second}, + {"fourth_failure", 4, 8 * time.Second}, + {"capped", 9, 8 * time.Second}, + {"large_streak_does_not_overflow", 1000, 8 * time.Second}, + {"zero_streak_uses_base", 0, time.Second}, + {"negative_streak_uses_base", -1, time.Second}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sink.flushBackoffFor(tc.failures); got != tc.want { + t.Fatalf("flushBackoffFor(%d) = %s, want %s", tc.failures, got, tc.want) + } + }) + } +} + +// 未显式配置时回落到默认值;max 小于 base 时以 base 为准,不产生比基准还短的退避。 +func TestOpsSystemLogSinkFlushBackoffForFallbacks(t *testing.T) { + zero := &OpsSystemLogSink{} + if got := zero.flushBackoffFor(1); got != defaultOpsSystemLogFlushBackoff { + t.Fatalf("zero-value base = %s, want %s", got, defaultOpsSystemLogFlushBackoff) + } + if got := zero.flushBackoffFor(100); got != defaultOpsSystemLogFlushBackoffMax { + t.Fatalf("zero-value cap = %s, want %s", got, defaultOpsSystemLogFlushBackoffMax) + } + + inverted := &OpsSystemLogSink{flushBackoff: 5 * time.Second, flushBackoffMax: time.Second} + if got := inverted.flushBackoffFor(3); got != 5*time.Second { + t.Fatalf("inverted bounds = %s, want 5s", got) + } +} + +// issue #5265:写入失败后如果按 flushInterval 继续每秒重试,每一轮都会占用并取消 +// 一条池内连接(远程 PG 上 COPY 取消会让连接协议失步而被销毁),小连接池会被日志 +// 通道长期占满,业务侧最终报 Billing 503。失败后必须退避。 +func TestOpsSystemLogSinkSuppressesRetriesDuringBackoff(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, _ []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return 0, errors.New("db unavailable") + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 800 * time.Millisecond + sink.flushBackoffMax = 800 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 2*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 1 }) { + t.Fatalf("first flush never happened") + } + + // 退避窗口内不得再打上游:修复前 5ms 的 flushInterval 会在这 300ms 里打出上百次。 + time.Sleep(300 * time.Millisecond) + if got := atomic.LoadInt64(&calls); got != 1 { + t.Fatalf("upstream calls during backoff = %d, want 1", got) + } + + // 被抑制的批次记为 dropped,而不是 write_failed —— 没有尝试过就不算写失败。 + health := sink.Health() + if health.DroppedCount == 0 { + t.Fatalf("dropped_count should grow while flushing is suppressed") + } + if health.WriteFailed == 0 || health.LastError == "" { + t.Fatalf("failed flush should still surface in health: %+v", health) + } +} + +// 退避到期后必须自动恢复,不能变成永久停写。 +func TestOpsSystemLogSinkResumesAfterBackoffWindow(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, _ []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return 0, errors.New("db unavailable") + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 150 * time.Millisecond + sink.flushBackoffMax = 150 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 3 }) { + t.Fatalf("sink did not resume flushing after backoff, calls=%d", atomic.LoadInt64(&calls)) + } +} + +// 一次成功必须清空失败计数与抑制窗口,否则短暂抖动后写入速率无法恢复。 +func TestOpsSystemLogSinkSuccessClearsSuppression(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) { + if atomic.AddInt64(&calls, 1) == 1 { + return 0, errors.New("db unavailable") + } + return int64(len(inputs)), nil + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 150 * time.Millisecond + sink.flushBackoffMax = 150 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + // 第 2 次调用(恢复后的首次成功)之后不应再有抑制:调用次数需要快速爬升。 + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 2 }) { + t.Fatalf("sink never retried after the first failure") + } + recovered := atomic.LoadInt64(&calls) + time.Sleep(200 * time.Millisecond) + if got := atomic.LoadInt64(&calls) - recovered; got < 3 { + t.Fatalf("calls after recovery = %d in 200ms, want >=3 (suppression not cleared)", got) + } + if sink.Health().WrittenCount == 0 { + t.Fatalf("written_count should grow after recovery") + } +} + +// 健康路径不受影响:一直成功就永远不进入退避。 +func TestOpsSystemLogSinkHealthyPathNeverSuppressed(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return int64(len(inputs)), nil + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = time.Hour + sink.flushBackoffMax = time.Hour + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 10 }) { + t.Fatalf("healthy sink should keep flushing, calls=%d", atomic.LoadInt64(&calls)) + } + if got := sink.Health().DroppedCount; got != 0 { + t.Fatalf("healthy sink dropped_count = %d, want 0", got) + } +} From f3c94d2099e92efaf0e20742fa338b6dbfb483e8 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 18:31:56 +0800 Subject: [PATCH 11/25] =?UTF-8?q?fix(openai):=20=E4=BF=AE=E6=AD=A3=20Respo?= =?UTF-8?q?nses=20=E5=B7=A5=E5=85=B7=20Schema=20=E4=B8=AD=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E4=B8=BA=20null=20=E7=9A=84=20parameters.type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Desktop 内置的 automation_update 工具会带 parameters.type = null, OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的 502 upstream_error。该工具定义还会沉进多轮会话历史,之后每一轮都继续失败, 同一份坏 Schema 在账号池里被反复重放。 在 OpenAIGatewayService.Forward 分流到 passthrough / Codex transform / 原生 ChatCompletions 之前,统一把显式为 null 的 parameters.type 补成 "object",覆盖顶层 tools[] 与多轮历史里 input[].tools[] 的嵌套工具定义。 只修正显式 null:缺失 type 的 Schema 本身合法,补写会收窄客户端语义, 因此保持原样。 Fixes #5364 --- .../service/openai_gateway_forward.go | 10 + .../service/openai_responses_tool_schema.go | 92 +++++++++ .../openai_responses_tool_schema_test.go | 188 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 backend/internal/service/openai_responses_tool_schema.go create mode 100644 backend/internal/service/openai_responses_tool_schema_test.go diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 0484ea7d36..bb13dd5989 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -46,6 +46,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } + // 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正 + // 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502, + // 同一份坏定义在账号池里反复重放。 + sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body) + if toolSchemaErr != nil { + return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr) + } + if toolSchemaSanitized { + body = sanitizedToolBody + } if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) if liteErr != nil { diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go new file mode 100644 index 0000000000..af9811e2cb --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -0,0 +1,92 @@ +package service + +import ( + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + // 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体 + // 造成无界递归。 + openAIResponsesToolSchemaMaxDepth = 4 + // JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法, + // 补成 object 与 upstream 对该工具的实际期望一致。 + openAIResponsesToolSchemaFallbackType = "object" +) + +// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的 +// tools[].parameters.type。 +// +// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null, +// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的 +// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在 +// 账号池里反复重放同一份坏 Schema。 +// +// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄 +// 客户端语义,因此保持原样。 +func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + paths := make([]string, 0, 2) + collectOpenAIResponsesToolSchemaNullTypePaths(gjson.GetBytes(body, "tools"), "tools", 0, &paths) + if input := gjson.GetBytes(body, "input"); input.IsArray() { + index := 0 + input.ForEach(func(_, item gjson.Result) bool { + itemPath := fmt.Sprintf("input.%d.tools", index) + index++ + if item.IsObject() { + collectOpenAIResponsesToolSchemaNullTypePaths(item.Get("tools"), itemPath, 0, &paths) + } + return true + }) + } + if len(paths) == 0 { + return body, false, nil + } + + sanitized := body + for _, path := range paths { + next, err := sjson.SetBytes(sanitized, path, openAIResponsesToolSchemaFallbackType) + if err != nil { + return body, false, fmt.Errorf("normalize %s: %w", path, err) + } + sanitized = next + } + return sanitized, true, nil +} + +// collectOpenAIResponsesToolSchemaNullTypePaths 收集一个 tools 数组里所有需要修正的 +// parameters.type 路径。不按 tool type 过滤:null 的 schema type 在 function、 +// custom 以及任何 hosted 工具上都同样非法。 +func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath string, depth int, paths *[]string) { + if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() { + return + } + index := 0 + tools.ForEach(func(_, tool gjson.Result) bool { + toolPath := fmt.Sprintf("%s.%d", basePath, index) + index++ + if !tool.IsObject() { + return true + } + // Responses 形态用顶层 parameters,ChatCompletions 形态用 function.parameters, + // 两种都可能出现在 Responses 请求里(见 normalizeCodexTools)。 + for _, suffix := range []string{"parameters", "function.parameters"} { + params := tool.Get(suffix) + if !params.IsObject() { + continue + } + if typ := params.Get("type"); typ.Exists() && typ.Type == gjson.Null { + *paths = append(*paths, toolPath+"."+suffix+".type") + } + } + // 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如 + // input[234].tools[0].tools[3].parameters)。 + collectOpenAIResponsesToolSchemaNullTypePaths(tool.Get("tools"), toolPath+".tools", depth+1, paths) + return true + }) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go new file mode 100644 index 0000000000..59b22801c5 --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -0,0 +1,188 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// issue #5364 的最小复现体:Codex Desktop 内置 automation_update 带 +// parameters.type = null,upstream 回 400 invalid_function_parameters。 +func TestSanitizeOpenAIResponsesToolParameterTypes_TopLevelFunctionTool(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.6-sol", + "input": "Reply with OK.", + "stream": false, + "tools": [ + { + "type": "function", + "name": "automation_update", + "description": "Update an automation.", + "parameters": {"type": null, "properties": {}} + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String()) + // 只改 type,工具其余定义原样保留。 + require.Equal(t, "automation_update", gjson.GetBytes(sanitized, "tools.0.name").String()) + require.Equal(t, "Update an automation.", gjson.GetBytes(sanitized, "tools.0.description").String()) + require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties").IsObject()) + // 请求体其余字段不受影响。 + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(sanitized, "model").String()) + require.Equal(t, "Reply with OK.", gjson.GetBytes(sanitized, "input").String()) +} + +// 合法 Schema 必须原样返回:changed=false 且字节不变,避免无谓重写打散 +// prompt cache 前缀。 +func TestSanitizeOpenAIResponsesToolParameterTypes_ValidSchemaUntouched(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"type":"object","properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) +} + +// 缺失 type 的 Schema 本身合法(等价于不约束),不得补写——补写会收窄客户端语义。 +func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists()) +} + +// 多轮历史:工具定义沉进 input 后,upstream 报错路径形如 +// input[N].tools[i].tools[j].parameters,两层都要修。 +func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) { + body := []byte(`{ + "input": [ + {"type": "message", "role": "user", "content": "hi"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "codex_app", + "tools": [ + {"type": "function", "name": "noop", "parameters": {"type": "object"}}, + {"type": "function", "name": "automation_update", "parameters": {"type": null}} + ] + }, + {"type": "function", "name": "outer", "parameters": {"type": null}} + ] + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String()) + require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String()) + // 原本合法的兄弟条目保持不变。 + require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String()) + require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String()) +} + +// ChatCompletions 形态的工具({type:"function", function:{...}})同样可能出现在 +// Responses 请求里,见 normalizeCodexTools。 +func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","function":{"name":"legacy","parameters":{"type":null}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.function.parameters.type").String()) + require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String()) +} + +// 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。 +func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) { + body := []byte(`{"tools":[ + {"type":"function","name":"a","parameters":{"type":"object"}}, + {"type":"function","name":"b","parameters":{"type":null}}, + {"type":"function","name":"c","parameters":{"type":"object"}} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "a", gjson.GetBytes(sanitized, "tools.0.name").String()) + require.Equal(t, "b", gjson.GetBytes(sanitized, "tools.1.name").String()) + require.Equal(t, "c", gjson.GetBytes(sanitized, "tools.2.name").String()) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.1.parameters.type").String()) + require.Equal(t, 3, int(gjson.GetBytes(sanitized, "tools.#").Int())) +} + +// 畸形/非常规形态不得 panic,且一律按不变处理。 +func TestSanitizeOpenAIResponsesToolParameterTypes_MalformedShapesAreNoOps(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"empty_body", ``}, + {"no_tools", `{"model":"gpt-5.6-sol","input":"hi"}`}, + {"tools_null", `{"tools":null}`}, + {"tools_object", `{"tools":{"type":"function"}}`}, + {"tool_is_string", `{"tools":["freeform"]}`}, + {"parameters_is_string", `{"tools":[{"type":"function","parameters":"nope"}]}`}, + {"parameters_null", `{"tools":[{"type":"function","parameters":null}]}`}, + {"input_string", `{"input":"hi","tools":[]}`}, + {"input_item_not_object", `{"input":["hi"]}`}, + {"type_already_array", `{"tools":[{"type":"function","parameters":{"type":["object","null"]}}]}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tc.body)) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, tc.body, string(sanitized)) + }) + } +} + +// 递归深度守卫:超深嵌套只做截断,不递归到栈溢出,也不报错。 +func TestSanitizeOpenAIResponsesToolParameterTypes_DepthGuard(t *testing.T) { + tool := map[string]any{"type": "function", "name": "deep", "parameters": map[string]any{"type": nil}} + for i := 0; i < 12; i++ { + tool = map[string]any{"type": "namespace", "tools": []any{tool}} + } + body, err := json.Marshal(map[string]any{"tools": []any{tool}}) + require.NoError(t, err) + + require.NotPanics(t, func() { + _, _, sanitizeErr := sanitizeOpenAIResponsesToolParameterTypes(body) + require.NoError(t, sanitizeErr) + }) +} + +// 输出必须是合法 JSON,且除目标字段外与输入等价。 +func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","tool_choice":"none","store":false,"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + require.NoError(t, err) + require.True(t, changed) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(sanitized, &decoded)) + require.Equal(t, "gpt-5.5", decoded["model"]) + require.Equal(t, "none", decoded["tool_choice"]) + require.Equal(t, false, decoded["store"]) +} From 379db1914158ec4329b2755c32c7c250998025b7 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 20:37:44 +0800 Subject: [PATCH 12/25] =?UTF-8?q?perf(openai):=20=E5=B7=A5=E5=85=B7=20Sche?= =?UTF-8?q?ma=20=E5=87=80=E5=8C=96=E6=94=B9=E4=B8=BA=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E6=8B=BC=E6=8E=A5=EF=BC=8C=E5=8E=BB=E6=8E=89=E9=80=90=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=85=A8=E9=87=8F=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sjson.SetBytes 在 opts 为 nil 时 optimistic=false、inplace=false,每次调用都要 重扫整个文档并新建缓冲区拷贝,命中 N 处就是 N 次全量拷贝。/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中,会被放大 成 TB 级 memcpy。 改为收集 gjson 给出的绝对字节偏移后一次性拼接:全程一次分配、一次拷贝。 偏移不可用(Index 为 0)或与 body 对不上时跳过该处,而不是猜位置拼出损坏的 JSON。 实测(2000 处命中):旧写法 17740 次分配,新写法 18 次。 --- .../service/openai_responses_tool_schema.go | 85 +++++++++++++------ .../openai_responses_tool_schema_test.go | 61 +++++++++++++ 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go index af9811e2cb..8c7e7b8d18 100644 --- a/backend/internal/service/openai_responses_tool_schema.go +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -1,10 +1,10 @@ package service import ( - "fmt" + "bytes" + "sort" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -13,9 +13,18 @@ const ( openAIResponsesToolSchemaMaxDepth = 4 // JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法, // 补成 object 与 upstream 对该工具的实际期望一致。 - openAIResponsesToolSchemaFallbackType = "object" + openAIResponsesToolSchemaFallbackType = `"object"` + // 显式 null 在 JSON 里只有这一种字面量形态。 + openAIResponsesToolSchemaNullLiteral = "null" ) +// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的 +// 绝对字节偏移表示,便于最后一次性拼接。 +type openAIResponsesToolSchemaNullType struct { + offset int + length int +} + // sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的 // tools[].parameters.type。 // @@ -26,50 +35,57 @@ const ( // // 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄 // 客户端语义,因此保持原样。 +// +// 实现上先收集全部命中的绝对偏移,再一次性拼出新 body:逐个 sjson.SetBytes 每次 +// 都会重扫并全量拷贝整个文档,命中 N 处就是 N 次全量拷贝,而 /v1/responses 的 +// body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中。 func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - paths := make([]string, 0, 2) - collectOpenAIResponsesToolSchemaNullTypePaths(gjson.GetBytes(body, "tools"), "tools", 0, &paths) + hits := make([]openAIResponsesToolSchemaNullType, 0, 2) + collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits) if input := gjson.GetBytes(body, "input"); input.IsArray() { - index := 0 input.ForEach(func(_, item gjson.Result) bool { - itemPath := fmt.Sprintf("input.%d.tools", index) - index++ if item.IsObject() { - collectOpenAIResponsesToolSchemaNullTypePaths(item.Get("tools"), itemPath, 0, &paths) + collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits) } return true }) } - if len(paths) == 0 { + if len(hits) == 0 { return body, false, nil } - sanitized := body - for _, path := range paths { - next, err := sjson.SetBytes(sanitized, path, openAIResponsesToolSchemaFallbackType) - if err != nil { - return body, false, fmt.Errorf("normalize %s: %w", path, err) + // tools 与 input 在 body 里的先后顺序由客户端决定,收集顺序不保证单调。 + sort.Slice(hits, func(i, j int) bool { return hits[i].offset < hits[j].offset }) + + sanitized := make([]byte, 0, len(body)+len(hits)*len(openAIResponsesToolSchemaFallbackType)) + cursor := 0 + for _, hit := range hits { + // 收集阶段已逐个校验过区间,这里再挡一次重叠,保证拼接严格单调向前。 + if hit.offset < cursor { + continue } - sanitized = next + sanitized = append(sanitized, body[cursor:hit.offset]...) + sanitized = append(sanitized, openAIResponsesToolSchemaFallbackType...) + cursor = hit.offset + hit.length } + sanitized = append(sanitized, body[cursor:]...) return sanitized, true, nil } -// collectOpenAIResponsesToolSchemaNullTypePaths 收集一个 tools 数组里所有需要修正的 -// parameters.type 路径。不按 tool type 过滤:null 的 schema type 在 function、 +// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的 +// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、 // custom 以及任何 hosted 工具上都同样非法。 -func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath string, depth int, paths *[]string) { +func collectOpenAIResponsesToolSchemaNullTypes( + body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType, +) { if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() { return } - index := 0 tools.ForEach(func(_, tool gjson.Result) bool { - toolPath := fmt.Sprintf("%s.%d", basePath, index) - index++ if !tool.IsObject() { return true } @@ -80,13 +96,32 @@ func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath if !params.IsObject() { continue } - if typ := params.Get("type"); typ.Exists() && typ.Type == gjson.Null { - *paths = append(*paths, toolPath+"."+suffix+".type") + // gjson 用 Type==Null 同时表示「显式 null」和「路径不存在」,靠 Raw + // 区分:不存在时 Raw 为空串。 + if typ := params.Get("type"); typ.Type == gjson.Null && typ.Raw == openAIResponsesToolSchemaNullLiteral { + appendOpenAIResponsesToolSchemaNullType(body, typ, hits) } } // 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如 // input[234].tools[0].tools[3].parameters)。 - collectOpenAIResponsesToolSchemaNullTypePaths(tool.Get("tools"), toolPath+".tools", depth+1, paths) + collectOpenAIResponsesToolSchemaNullTypes(body, tool.Get("tools"), depth+1, hits) return true }) } + +// appendOpenAIResponsesToolSchemaNullType 先校验 gjson 给出的偏移确实指向原始 +// body 上那段 null,再记录。gjson 对嵌套取值同样返回相对原始文档的绝对偏移,但 +// Index 为 0 表示未知;偏移不可用时跳过该处而不是猜位置——少修一个工具只是维持 +// 现状,拼错位置会损坏整个请求体。 +func appendOpenAIResponsesToolSchemaNullType( + body []byte, typ gjson.Result, hits *[]openAIResponsesToolSchemaNullType, +) { + end := typ.Index + len(typ.Raw) + if typ.Index <= 0 || end > len(body) { + return + } + if !bytes.Equal(body[typ.Index:end], []byte(typ.Raw)) { + return + } + *hits = append(*hits, openAIResponsesToolSchemaNullType{offset: typ.Index, length: len(typ.Raw)}) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index 59b22801c5..2dbc7fb600 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -186,3 +186,64 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testi require.Equal(t, "none", decoded["tool_choice"]) require.Equal(t, false, decoded["store"]) } + +// 输入 body 是调用方持有的缓冲区(Forward 里 canonicalImageIntentBody 与它同源), +// 净化必须返回新切片,绝不能就地改写。 +func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","tools":[{"type":"function","name":"a","parameters":{"type":null}}]}`) + original := append([]byte(nil), body...) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, string(original), string(body), "调用方的 body 不得被就地改写") + require.NotEqual(t, string(original), string(sanitized)) +} + +func buildToolSchemaNullTypeBody(t *testing.T, hits int) []byte { + t.Helper() + tools := make([]any, 0, hits) + for i := 0; i < hits; i++ { + tools = append(tools, map[string]any{ + "type": "function", + "name": "automation_update", + "parameters": map[string]any{"type": nil, "properties": map[string]any{}}, + }) + } + body, err := json.Marshal(map[string]any{"model": "gpt-5.6-sol", "tools": tools}) + require.NoError(t, err) + return body +} + +// 复杂度守卫:重写次数必须与命中数无关。 +// +// 逐个 sjson.SetBytes 的写法每命中一处就重扫并全量拷贝一次文档,命中 N 处即 N 次 +// 全量拷贝;/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB), +// 构造请求可以塞进百万级命中,会被放大成 TB 级 memcpy。这里用分配次数锁死该行为: +// 命中数放大 500 倍,分配次数不得随之增长。 +func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits(t *testing.T) { + small := buildToolSchemaNullTypeBody(t, 4) + large := buildToolSchemaNullTypeBody(t, 2000) + + smallAllocs := testing.AllocsPerRun(2, func() { + _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(small) + }) + largeAllocs := testing.AllocsPerRun(2, func() { + _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(large) + }) + + // 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。 + require.Less(t, largeAllocs, smallAllocs+40, + "分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs) + + // 同时确认大 body 的结果确实全部修好了。 + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(large) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, 2000, int(gjson.GetBytes(sanitized, "tools.#").Int())) + gjson.GetBytes(sanitized, "tools").ForEach(func(_, tool gjson.Result) bool { + require.Equal(t, "object", tool.Get("parameters.type").String()) + return true + }) +} From a071b27b417ab2f20630ffb438b9480c271d5cc4 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 19:44:43 +0800 Subject: [PATCH 13/25] =?UTF-8?q?fix(grok):=20405=20=E7=BA=B3=E5=85=A5=20f?= =?UTF-8?q?ailover=20=E4=B8=8E=E4=B8=B4=E6=97=B6=E8=B8=A2=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Grok=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=B2=98=E6=80=A7=E9=94=81=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok 分组里若有账号的自定义 base_url 只实现 /v1/chat/completions,对 /v1/responses 固定返回 405,会话 sticky 到该账号后就永久锁死: 1. shouldFailoverUpstreamError 不含 405,请求不进换号循环 2. handleGrokAccountUpstreamError 的 switch 不含 405,账号不进临时不可调度 3. 账号仍可调度,sticky 不清理,客户端自动重连继续绑同一个坏号 改动两处: - shouldFailoverUpstreamError 增加 405,让请求可换号,sticky 随之更新 - handleGrokAccountUpstreamError 增加 405 → 30 分钟冷却,与 403 同级 Fixes #4668 --- .../internal/service/openai_gateway_grok.go | 2 ++ .../service/openai_gateway_grok_405_test.go | 29 +++++++++++++++++++ .../service/openai_gateway_grok_test.go | 7 +++++ .../service/openai_gateway_upstream_errors.go | 2 +- 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_gateway_grok_405_test.go diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 4d367c9aeb..87899c1350 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1370,6 +1370,8 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required") case http.StatusForbidden: s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") + case http.StatusMethodNotAllowed: + s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok endpoint not supported (405)") case http.StatusTooManyRequests: // updateGrokUsageSnapshot installs rate-limit state for non-pool accounts. default: diff --git a/backend/internal/service/openai_gateway_grok_405_test.go b/backend/internal/service/openai_gateway_grok_405_test.go new file mode 100644 index 0000000000..10ed8a599e --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_405_test.go @@ -0,0 +1,29 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldFailoverUpstreamError_405IsFailoverEligible(t *testing.T) { + svc := &OpenAIGatewayService{} + + assert.True(t, svc.shouldFailoverUpstreamError(http.StatusMethodNotAllowed), + "405 should trigger failover so sticky sessions can escape to healthy accounts") +} + +func TestShouldFailoverUpstreamError_ExistingCodesStillWork(t *testing.T) { + svc := &OpenAIGatewayService{} + + failoverCodes := []int{401, 402, 403, 405, 429, 529, 500, 502, 503, 504} + for _, code := range failoverCodes { + assert.True(t, svc.shouldFailoverUpstreamError(code), "status %d should trigger failover", code) + } + + nonFailoverCodes := []int{200, 201, 400, 404, 408, 422} + for _, code := range nonFailoverCodes { + assert.False(t, svc.shouldFailoverUpstreamError(code), "status %d should NOT trigger failover", code) + } +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 19942eae7d..30c25ba586 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -2583,6 +2583,13 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test wantMinCooldown: 2*time.Minute - time.Second, wantMaxCooldown: 2*time.Minute + time.Second, }, + { + name: "method not allowed", + status: http.StatusMethodNotAllowed, + wantReason: "grok endpoint not supported (405)", + wantMinCooldown: 30*time.Minute - time.Second, + wantMaxCooldown: 30*time.Minute + time.Second, + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index f56246e120..e0d3b8cf83 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -211,7 +211,7 @@ func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { switch statusCode { - case 401, 402, 403, 429, 529: + case 401, 402, 403, 405, 429, 529: return true default: return statusCode >= 500 From 146b8b668494df0a95fb421cbbfb851a79f3b840 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 20:41:23 +0800 Subject: [PATCH 14/25] =?UTF-8?q?revert(grok):=20=E6=92=A4=E5=9B=9E=20405?= =?UTF-8?q?=20=E8=B4=A6=E5=8F=B7=E7=BA=A7=E8=B8=A2=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E5=8F=AA=E4=BF=9D=E7=95=99=20failover=20=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tempUnscheduleGrok 是账号级、全端点、全模型的下线,而 handleGrokAccountUpstreamError 有 8 个调用点(raw chat / Grok 媒体 / CC 管线 / WS HTTP 桥接 / responses), /v1/responses 的 405 只证明该端点缺失,账号在其他端点上仍然健康。 仓库里已有相反的既定不变式:shouldApplyOpenAIAlphaSearchAccountErrorSideEffects 把 404/405 显式排除在账号状态写入之外,注释写明「端点不存在只说明该上游不支持 独立搜索,账号本身健康」。 failover 分类器加 405 单独就能解开 sticky 锁死(调度器选号时会即时改绑 sticky), 账号级冷却不是必需的。「405 不踢号但记住端点能力」按端点级负缓存另行处理。 --- backend/internal/service/openai_gateway_grok.go | 2 -- backend/internal/service/openai_gateway_grok_test.go | 7 ------- 2 files changed, 9 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 87899c1350..4d367c9aeb 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1370,8 +1370,6 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required") case http.StatusForbidden: s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") - case http.StatusMethodNotAllowed: - s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok endpoint not supported (405)") case http.StatusTooManyRequests: // updateGrokUsageSnapshot installs rate-limit state for non-pool accounts. default: diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 30c25ba586..19942eae7d 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -2583,13 +2583,6 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test wantMinCooldown: 2*time.Minute - time.Second, wantMaxCooldown: 2*time.Minute + time.Second, }, - { - name: "method not allowed", - status: http.StatusMethodNotAllowed, - wantReason: "grok endpoint not supported (405)", - wantMinCooldown: 30*time.Minute - time.Second, - wantMaxCooldown: 30*time.Minute + time.Second, - }, } for _, tt := range tests { From b5d9fd21b0e3542ce2a4c5e19959edffa525ad59 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 19:37:28 +0800 Subject: [PATCH 15/25] =?UTF-8?q?fix(ratelimit):=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=A2=AB=20Codex=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E6=8B=92=E7=BB=9D=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=86=99=E6=A8=A1=E5=9E=8B=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt-image-* 被误发到 /v1/chat/completions 或 /v1/responses 时,Codex 会稳定 返回 400 codex_plan_gated_model。HandleUpstreamModelNotFound 把它当成账号能力 缺失,给每个账号写 30 分钟 model_rate_limits 冷却。failover 走完整个号池后, 池内每个账号都冷却了该图片模型,于是后续走对端点的 /v1/images/generations 请求也一并 503 no available accounts。 在 codex plan-gated 分支里对图片模型提前返回:仍然 failover 掉本次尝试, 但跳过 SetModelRateLimit 写入,不把号池对正确的生图请求整体下线。 Fixes #4828 --- backend/internal/service/ratelimit_service.go | 8 ++++ .../ratelimit_service_model_not_found_test.go | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 266f806e83..ee75c28e48 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -2047,6 +2047,14 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco case isUpstreamModelNotFoundError(statusCode, responseBody): cooldown, reason = upstreamModelNotFoundCooldown, upstreamModelNotFoundReason case isOpenAIOAuthAccount(account) && isOpenAICodexPlanGatedModelError(statusCode, responseBody): + // Image models rejected by the Codex text endpoints are a deterministic + // endpoint mismatch, not an account capability gap: the same account still + // serves them over /v1/images/*. Cooling down the model here would take the + // whole pool offline for correct image requests, so fail the attempt over + // without recording a per-model cooldown. + if IsGPTImageGenerationModel(requestedModel) { + return true + } cooldown, reason = upstreamCodexPlanGatedModelCooldown, upstreamCodexPlanGatedModelReason default: return false diff --git a/backend/internal/service/ratelimit_service_model_not_found_test.go b/backend/internal/service/ratelimit_service_model_not_found_test.go index 55becb28a5..3e22d32b60 100644 --- a/backend/internal/service/ratelimit_service_model_not_found_test.go +++ b/backend/internal/service/ratelimit_service_model_not_found_test.go @@ -382,6 +382,49 @@ func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelIgnoresAPIKeyAc require.Empty(t, repo.modelRateLimitCalls) } +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldown(t *testing.T) { + for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} { + t.Run(model, func(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The '`+model+`' model is not supported when using Codex with a ChatGPT account."}`), + model, + ) + + require.True(t, handled, "attempt should still fail over") + require.Empty(t, repo.modelRateLimitCalls, + "image models must not be cooled down: the account still serves them over /v1/images/*") + require.Zero(t, repo.tempCalls) + }) + } +} + +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedTextModelStillCoolsDown(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-5.6-sol", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, "non-image plan-gated models keep the existing cooldown") + require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) +} + func openAICodexPlanGatedOAuthAccount() *Account { return &Account{ ID: 202, From 02fbcbe3ad1d3860d43d84438e2f256bf471f286 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 20:49:39 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix(ratelimit):=20=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E6=8C=89=E7=AB=AF=E7=82=B9=E6=9D=A5=E6=BA=90=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=B8=8E=E5=86=B7=E5=8D=B4=E9=94=AE=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=A8=A1=E5=9E=8B=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版守卫只看模型类型,不区分请求从哪个端点进来。OAuth 账号的 /v1/images/* 上游同样是 Codex Responses(openai_images_responses.go → handleOpenAIImagesErrorResponse → handleOpenAIAccountUpstreamError → HandleUpstreamModelNotFound),所以专用生图 端点也会命中 plan-gated 分支。账号确实不具备生图能力时跳过冷却,会让调度层失去 唯一的刹车:每个请求都完整走一遍号池,对上游形成无上界的 400 放大。 改动: - 新增 ctxkey.OpenAIImagesEndpoint 与 WithOpenAIImagesEndpoint / OpenAIImagesEndpointFromContext,在 handler/openai_images.go 入口置位; 与 OpenAIImageGenerationIntent 区分——后者在 /v1/responses 带图片模型时也会置位。 - 守卫下移到 modelKey 计算之后,抽成 shouldSkipCodexPlanGatedImageModelCooldown, 仅在 plan-gated 分支、且非 /v1/images/* 入站时生效。 - 同时判断 requestedModel 与最终 modelKey:冷却键走 account.GetMappedModel, 账号可以把文本别名映射到 gpt-image-*,只判请求模型会漏掉这种形态。 --- backend/internal/handler/openai_images.go | 2 +- backend/internal/pkg/ctxkey/ctxkey.go | 6 ++ backend/internal/service/model_rate_limit.go | 17 ++++ backend/internal/service/ratelimit_service.go | 34 +++++-- .../ratelimit_service_model_not_found_test.go | 90 +++++++++++++++++++ 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index d5d16d1e71..da41bf6aa3 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -144,7 +144,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body) - requestCtx := service.WithOpenAIImageGenerationIntent(c.Request.Context()) + requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context())) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index 2ec3e0fdbe..c34ef589a0 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -50,6 +50,12 @@ const ( // OpenAIImageGenerationIntent 标识 OpenAI 请求会触发生图能力(用于图片能力维度限流) OpenAIImageGenerationIntent Key = "ctx_openai_image_generation_intent" + // OpenAIImagesEndpoint 标识请求是从 /v1/images/* 入站的。 + // 与 OpenAIImageGenerationIntent 的区别:后者只表示"这次请求会生图", + // /v1/responses 带图片模型时也会置位;本 key 只在专用生图端点置位, + // 用于区分"用错端点"与"端点用对了但账号没能力"。 + OpenAIImagesEndpoint Key = "ctx_openai_images_endpoint" + // Group 认证后的分组信息,由 API Key 认证中间件设置 Group Key = "ctx_group" diff --git a/backend/internal/service/model_rate_limit.go b/backend/internal/service/model_rate_limit.go index 195f5a1dfd..fe962b17c0 100644 --- a/backend/internal/service/model_rate_limit.go +++ b/backend/internal/service/model_rate_limit.go @@ -120,6 +120,23 @@ func OpenAIImageGenerationIntentFromContext(ctx context.Context) bool { return ok && enabled } +// WithOpenAIImagesEndpoint 标记请求从 /v1/images/* 专用生图端点入站。 +func WithOpenAIImagesEndpoint(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, ctxkey.OpenAIImagesEndpoint, true) +} + +// OpenAIImagesEndpointFromContext 报告请求是否来自 /v1/images/*。 +func OpenAIImagesEndpointFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + enabled, ok := ctx.Value(ctxkey.OpenAIImagesEndpoint).(bool) + return ok && enabled +} + func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string { modelKey := mapAntigravityModel(account, requestedModel) if modelKey == "" { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index ee75c28e48..335181dffa 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -2047,14 +2047,6 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco case isUpstreamModelNotFoundError(statusCode, responseBody): cooldown, reason = upstreamModelNotFoundCooldown, upstreamModelNotFoundReason case isOpenAIOAuthAccount(account) && isOpenAICodexPlanGatedModelError(statusCode, responseBody): - // Image models rejected by the Codex text endpoints are a deterministic - // endpoint mismatch, not an account capability gap: the same account still - // serves them over /v1/images/*. Cooling down the model here would take the - // whole pool offline for correct image requests, so fail the attempt over - // without recording a per-model cooldown. - if IsGPTImageGenerationModel(requestedModel) { - return true - } cooldown, reason = upstreamCodexPlanGatedModelCooldown, upstreamCodexPlanGatedModelReason default: return false @@ -2063,6 +2055,9 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco if modelKey == "" { return false } + if shouldSkipCodexPlanGatedImageModelCooldown(ctx, reason, requestedModel, modelKey) { + return true + } resetAt := time.Now().Add(cooldown) if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt, reason); err != nil { slog.Warn("upstream_model_not_found_set_model_rate_limit_failed", "account_id", account.ID, "model", modelKey, "reason", reason, "error", err) @@ -2072,6 +2067,29 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco return true } +// shouldSkipCodexPlanGatedImageModelCooldown 判断这次 Codex plan-gated 400 是否 +// 属于"图片模型被文本端点拒绝"。 +// +// 这类错误是确定性的端点错配,不是账号能力缺失:同一账号通过 /v1/images/* 依然 +// 能出图。在这里写 per-model 冷却,会让一次用错端点的请求把整个号池对正确的生图 +// 端点下线(#4828)。 +// +// 但请求本身就从 /v1/images/* 入站时不适用——那种情况下被拒说明账号确实不具备 +// 该模型能力,冷却是必要的刹车:没有它,每个生图请求都会完整走一遍号池,对上游 +// 形成无上界的 400 放大。 +// +// 请求模型与最终冷却键都要判:冷却键走的是 account.GetMappedModel,账号可能把 +// 文本别名映射到 gpt-image-*,只判请求模型会漏掉这种形态。 +func shouldSkipCodexPlanGatedImageModelCooldown(ctx context.Context, reason, requestedModel, modelKey string) bool { + if reason != upstreamCodexPlanGatedModelReason { + return false + } + if OpenAIImagesEndpointFromContext(ctx) { + return false + } + return IsGPTImageGenerationModel(requestedModel) || IsGPTImageGenerationModel(modelKey) +} + func modelRateLimitKeyForUpstreamModelNotFound(ctx context.Context, account *Account, requestedModel string) string { modelKey := strings.TrimSpace(requestedModel) if account == nil || modelKey == "" { diff --git a/backend/internal/service/ratelimit_service_model_not_found_test.go b/backend/internal/service/ratelimit_service_model_not_found_test.go index 3e22d32b60..5e2fa0f71d 100644 --- a/backend/internal/service/ratelimit_service_model_not_found_test.go +++ b/backend/internal/service/ratelimit_service_model_not_found_test.go @@ -435,3 +435,93 @@ func openAICodexPlanGatedOAuthAccount() *Account { Credentials: map[string]any{}, } } + +// 请求本身就走 /v1/images/* 时必须保留冷却。 +// +// OAuth 账号的 /v1/images/* 上游同样是 Codex Responses(openai_images_responses.go +// → handleOpenAIImagesErrorResponse → handleOpenAIAccountUpstreamError → +// HandleUpstreamModelNotFound),所以这条路径也会命中 plan-gated 分支。账号确实 +// 不具备生图能力时,冷却是唯一的刹车:调度层靠 model_rate_limits 跳过该账号后 +// 快速 503;一旦跳过冷却,每个请求都会完整走一遍号池,对上游形成无上界的 400 放大。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelKeepsCooldownOnImagesEndpoint(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + WithOpenAIImagesEndpoint(context.Background()), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, + "/v1/images/* 上的 plan-gated 拒绝是真实的能力缺失,必须保留冷却刹车") + require.Equal(t, "gpt-image-2", repo.modelRateLimitCalls[0].scope) + require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) +} + +// 仅 WithOpenAIImageGenerationIntent(/v1/responses 因模型名自动置位)不算专用生图 +// 端点,仍按"用错端点"处理。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownOnIntentOnly(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + WithOpenAIImageGenerationIntent(context.Background()), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Empty(t, repo.modelRateLimitCalls) +} + +// 守卫口径必须与冷却键一致:冷却键走 account.GetMappedModel,账号可以把文本别名 +// 映射到 gpt-image-*,只判请求模型会漏掉这种形态,原 bug 原样复现。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownViaModelMapping(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + account.Credentials["model_mapping"] = map[string]any{"my-draw-alias": "gpt-image-2"} + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "my-draw-alias", + ) + + require.True(t, handled) + require.Empty(t, repo.modelRateLimitCalls, + "映射后的上游模型是图片模型,冷却键会写到 gpt-image-2 上,守卫必须一并识别") +} + +// 404 model-not-found 分支不受守卫影响:即使是图片模型也照常冷却。 +func TestRateLimitService_HandleUpstreamError_ModelNotFoundImageModelStillCoolsDown(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusNotFound, + http.Header{}, + []byte(`{"error":{"message":"The model 'gpt-image-2' does not exist","code":"model_not_found"}}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, "守卫只作用于 codex plan-gated 分支") + require.Equal(t, upstreamModelNotFoundReason, repo.modelRateLimitCalls[0].reason) +} From db0bff82c7f954607f4b66421a79200e150ac836 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Tue, 4 Aug 2026 09:44:29 -0400 Subject: [PATCH 17/25] feat(usage): audit upstream response models (cherry picked from commit 839036224f795c8ee5dc6718a2a14372a45eea44) --- backend/ent/migrate/schema.go | 30 +-- backend/ent/mutation.go | 148 ++++++++++++++- backend/ent/runtime/runtime.go | 64 ++++--- backend/ent/schema/usage_log.go | 11 ++ backend/ent/usagelog.go | 32 +++- backend/ent/usagelog/usagelog.go | 18 ++ backend/ent/usagelog/where.go | 105 ++++++++++ backend/ent/usagelog_create.go | 161 ++++++++++++++++ backend/ent/usagelog_update.go | 114 +++++++++++ .../handler/admin/dashboard_handler.go | 36 +++- .../dashboard_handler_request_type_test.go | 77 ++++++++ .../handler/admin/dashboard_query_cache.go | 128 +++++++------ .../admin/dashboard_snapshot_v2_handler.go | 98 ++++++---- .../internal/handler/admin/usage_handler.go | 74 +++++--- .../handler/admin/usage_query_cache.go | 46 ++--- backend/internal/handler/dto/mappers.go | 2 + .../handler/dto/mappers_usage_test.go | 16 +- backend/internal/handler/dto/types.go | 4 + .../usage_handler_request_type_test.go | 2 + .../pkg/usagestats/usage_log_types.go | 15 +- .../internal/repository/migrations_runner.go | 4 + .../repository/migrations_runner_notx_test.go | 36 ++++ .../migrations_schema_integration_test.go | 18 ++ .../repository/usage_log_repo_insert.go | 52 +++-- .../repository/usage_log_repo_query.go | 30 ++- .../usage_log_repo_request_type_test.go | 32 +++- .../repository/usage_log_repo_stats.go | 3 + .../usage_log_repo_stats_integration_test.go | 61 ++++++ .../repository/usage_log_repo_trend.go | 38 ++-- .../usage_log_session_id_unit_test.go | 2 +- .../service/antigravity_gateway_claude.go | 19 +- .../service/antigravity_gateway_compat.go | 25 +-- .../antigravity_gateway_compat_stream.go | 1 + .../service/antigravity_gateway_gemini.go | 26 +-- .../service/antigravity_gateway_streaming.go | 37 +++- .../service/antigravity_gateway_upstream.go | 17 +- backend/internal/service/dashboard_service.go | 43 +++++ .../service/gateway_anthropic_passthrough.go | 30 ++- backend/internal/service/gateway_forward.go | 21 +- backend/internal/service/gateway_service.go | 16 +- .../service/gateway_upstream_response.go | 30 ++- .../internal/service/gateway_usage_billing.go | 12 ++ .../service/gemini_messages_compat_service.go | 68 +++++-- .../openai_gateway_chat_completions.go | 46 +++-- .../openai_gateway_chat_completions_raw.go | 52 +++-- .../service/openai_gateway_forward.go | 27 +-- .../service/openai_gateway_messages.go | 52 +++-- .../service/openai_gateway_passthrough.go | 39 ++-- .../openai_gateway_response_handling.go | 14 ++ .../service/openai_gateway_service.go | 4 + .../service/openai_gateway_service_test.go | 26 +++ .../internal/service/openai_gateway_usage.go | 58 +++--- .../service/openai_ws_forwarder_ingress.go | 28 +-- .../service/openai_ws_forwarder_v2.go | 32 ++-- .../internal/service/openai_ws_http_bridge.go | 28 +-- .../service/openai_ws_v2/passthrough_relay.go | 112 +++++++++-- .../passthrough_relay_internal_test.go | 54 ++++++ .../openai_ws_v2_passthrough_adapter.go | 44 +++-- .../service/upstream_response_model.go | 179 ++++++++++++++++++ .../service/upstream_response_model_test.go | 75 ++++++++ backend/internal/service/usage_log.go | 6 + ..._add_usage_log_upstream_response_model.sql | 3 + ...log_upstream_model_mismatch_index_notx.sql | 3 + frontend/src/api/admin/dashboard.ts | 3 + frontend/src/api/admin/usage.ts | 2 + .../components/admin/usage/UsageFilters.vue | 11 ++ .../src/components/admin/usage/UsageTable.vue | 69 +++++-- .../usage/__tests__/UsageFilters.spec.ts | 5 + .../admin/usage/__tests__/UsageTable.spec.ts | 47 +++++ .../src/i18n/locales/en/admin/resources.ts | 4 + frontend/src/i18n/locales/en/dashboard.ts | 5 + .../src/i18n/locales/zh/admin/resources.ts | 4 + frontend/src/i18n/locales/zh/dashboard.ts | 5 + frontend/src/types/index.ts | 2 + frontend/src/views/admin/UsageView.vue | 6 +- .../views/admin/__tests__/UsageView.spec.ts | 86 ++++++++- 76 files changed, 2401 insertions(+), 532 deletions(-) create mode 100644 backend/internal/service/upstream_response_model.go create mode 100644 backend/internal/service/upstream_response_model_test.go create mode 100644 backend/migrations/194_add_usage_log_upstream_response_model.sql create mode 100644 backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 0475bca3bf..92cdb7218d 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1621,6 +1621,8 @@ var ( {Name: "model", Type: field.TypeString, Size: 100}, {Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100}, {Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100}, + {Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200}, + {Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true}, {Name: "channel_id", Type: field.TypeInt64, Nullable: true}, {Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500}, {Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50}, @@ -1671,31 +1673,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[42]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[43]}, + Columns: []*schema.Column{UsageLogsColumns[45]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[44]}, + Columns: []*schema.Column{UsageLogsColumns[46]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[45]}, + Columns: []*schema.Column{UsageLogsColumns[47]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -1704,32 +1706,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[44]}, + Columns: []*schema.Column{UsageLogsColumns[46]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[42]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[43]}, + Columns: []*schema.Column{UsageLogsColumns[45]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[45]}, + Columns: []*schema.Column{UsageLogsColumns[47]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, }, { Name: "usagelog_model", @@ -1749,17 +1751,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 61ff4f9e6d..98fce86905 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -43356,6 +43356,8 @@ type UsageLogMutation struct { model *string requested_model *string upstream_model *string + upstream_response_model *string + upstream_model_mismatch *bool channel_id *int64 addchannel_id *int64 model_mapping_chain *string @@ -43805,6 +43807,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() { delete(m.clearedFields, usagelog.FieldUpstreamModel) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (m *UsageLogMutation) SetUpstreamResponseModel(s string) { + m.upstream_response_model = &s +} + +// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation. +func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) { + v := m.upstream_response_model + if v == nil { + return + } + return *v, true +} + +// OldUpstreamResponseModel returns the old "upstream_response_model" 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) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err) + } + return oldValue.UpstreamResponseModel, nil +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (m *UsageLogMutation) ClearUpstreamResponseModel() { + m.upstream_response_model = nil + m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{} +} + +// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation. +func (m *UsageLogMutation) UpstreamResponseModelCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel] + return ok +} + +// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field. +func (m *UsageLogMutation) ResetUpstreamResponseModel() { + m.upstream_response_model = nil + delete(m.clearedFields, usagelog.FieldUpstreamResponseModel) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) { + m.upstream_model_mismatch = &b +} + +// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation. +func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) { + v := m.upstream_model_mismatch + if v == nil { + return + } + return *v, true +} + +// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" 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) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err) + } + return oldValue.UpstreamModelMismatch, nil +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (m *UsageLogMutation) ClearUpstreamModelMismatch() { + m.upstream_model_mismatch = nil + m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{} +} + +// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation. +func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch] + return ok +} + +// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field. +func (m *UsageLogMutation) ResetUpstreamModelMismatch() { + m.upstream_model_mismatch = nil + delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch) +} + // SetChannelID sets the "channel_id" field. func (m *UsageLogMutation) SetChannelID(i int64) { m.channel_id = &i @@ -46001,7 +46101,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, 45) + fields := make([]string, 0, 47) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -46023,6 +46123,12 @@ func (m *UsageLogMutation) Fields() []string { if m.upstream_model != nil { fields = append(fields, usagelog.FieldUpstreamModel) } + if m.upstream_response_model != nil { + fields = append(fields, usagelog.FieldUpstreamResponseModel) + } + if m.upstream_model_mismatch != nil { + fields = append(fields, usagelog.FieldUpstreamModelMismatch) + } if m.channel_id != nil { fields = append(fields, usagelog.FieldChannelID) } @@ -46159,6 +46265,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.RequestedModel() case usagelog.FieldUpstreamModel: return m.UpstreamModel() + case usagelog.FieldUpstreamResponseModel: + return m.UpstreamResponseModel() + case usagelog.FieldUpstreamModelMismatch: + return m.UpstreamModelMismatch() case usagelog.FieldChannelID: return m.ChannelID() case usagelog.FieldModelMappingChain: @@ -46258,6 +46368,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldRequestedModel(ctx) case usagelog.FieldUpstreamModel: return m.OldUpstreamModel(ctx) + case usagelog.FieldUpstreamResponseModel: + return m.OldUpstreamResponseModel(ctx) + case usagelog.FieldUpstreamModelMismatch: + return m.OldUpstreamModelMismatch(ctx) case usagelog.FieldChannelID: return m.OldChannelID(ctx) case usagelog.FieldModelMappingChain: @@ -46392,6 +46506,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetUpstreamModel(v) return nil + case usagelog.FieldUpstreamResponseModel: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpstreamResponseModel(v) + return nil + case usagelog.FieldUpstreamModelMismatch: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpstreamModelMismatch(v) + return nil case usagelog.FieldChannelID: v, ok := value.(int64) if !ok { @@ -46949,6 +47077,12 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldUpstreamModel) { fields = append(fields, usagelog.FieldUpstreamModel) } + if m.FieldCleared(usagelog.FieldUpstreamResponseModel) { + fields = append(fields, usagelog.FieldUpstreamResponseModel) + } + if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) { + fields = append(fields, usagelog.FieldUpstreamModelMismatch) + } if m.FieldCleared(usagelog.FieldChannelID) { fields = append(fields, usagelog.FieldChannelID) } @@ -47023,6 +47157,12 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldUpstreamModel: m.ClearUpstreamModel() return nil + case usagelog.FieldUpstreamResponseModel: + m.ClearUpstreamResponseModel() + return nil + case usagelog.FieldUpstreamModelMismatch: + m.ClearUpstreamModelMismatch() + return nil case usagelog.FieldChannelID: m.ClearChannelID() return nil @@ -47106,6 +47246,12 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldUpstreamModel: m.ResetUpstreamModel() return nil + case usagelog.FieldUpstreamResponseModel: + m.ResetUpstreamResponseModel() + return nil + case usagelog.FieldUpstreamModelMismatch: + m.ResetUpstreamModelMismatch() + return nil case usagelog.FieldChannelID: m.ResetChannelID() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e85660dcc1..b50b50cf58 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1982,124 +1982,128 @@ func init() { usagelogDescUpstreamModel := usagelogFields[6].Descriptor() // usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error) + // usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field. + usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor() + // usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save. + usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error) // usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field. - usagelogDescModelMappingChain := usagelogFields[8].Descriptor() + usagelogDescModelMappingChain := usagelogFields[10].Descriptor() // usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save. usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error) // usagelogDescBillingTier is the schema descriptor for billing_tier field. - usagelogDescBillingTier := usagelogFields[9].Descriptor() + usagelogDescBillingTier := usagelogFields[11].Descriptor() // usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save. usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error) // usagelogDescBillingMode is the schema descriptor for billing_mode field. - usagelogDescBillingMode := usagelogFields[10].Descriptor() + usagelogDescBillingMode := usagelogFields[12].Descriptor() // usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save. usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error) // usagelogDescInputTokens is the schema descriptor for input_tokens field. - usagelogDescInputTokens := usagelogFields[13].Descriptor() + usagelogDescInputTokens := usagelogFields[15].Descriptor() // usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field. usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int) // usagelogDescOutputTokens is the schema descriptor for output_tokens field. - usagelogDescOutputTokens := usagelogFields[14].Descriptor() + usagelogDescOutputTokens := usagelogFields[16].Descriptor() // usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field. usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int) // usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field. - usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor() + usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor() // usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field. usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int) // usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field. - usagelogDescCacheReadTokens := usagelogFields[16].Descriptor() + usagelogDescCacheReadTokens := usagelogFields[18].Descriptor() // usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field. usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int) // usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field. - usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor() + usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor() // usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field. usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int) // usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field. - usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor() + usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor() // usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field. usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int) // usagelogDescInputCost is the schema descriptor for input_cost field. - usagelogDescInputCost := usagelogFields[19].Descriptor() + usagelogDescInputCost := usagelogFields[21].Descriptor() // usagelog.DefaultInputCost holds the default value on creation for the input_cost field. usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64) // usagelogDescOutputCost is the schema descriptor for output_cost field. - usagelogDescOutputCost := usagelogFields[20].Descriptor() + usagelogDescOutputCost := usagelogFields[22].Descriptor() // usagelog.DefaultOutputCost holds the default value on creation for the output_cost field. usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64) // usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field. - usagelogDescCacheCreationCost := usagelogFields[21].Descriptor() + usagelogDescCacheCreationCost := usagelogFields[23].Descriptor() // usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field. usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64) // usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field. - usagelogDescCacheReadCost := usagelogFields[22].Descriptor() + usagelogDescCacheReadCost := usagelogFields[24].Descriptor() // usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field. usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64) // usagelogDescTotalCost is the schema descriptor for total_cost field. - usagelogDescTotalCost := usagelogFields[23].Descriptor() + usagelogDescTotalCost := usagelogFields[25].Descriptor() // usagelog.DefaultTotalCost holds the default value on creation for the total_cost field. usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64) // usagelogDescActualCost is the schema descriptor for actual_cost field. - usagelogDescActualCost := usagelogFields[24].Descriptor() + usagelogDescActualCost := usagelogFields[26].Descriptor() // usagelog.DefaultActualCost holds the default value on creation for the actual_cost field. usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64) // usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field. - usagelogDescRateMultiplier := usagelogFields[25].Descriptor() + usagelogDescRateMultiplier := usagelogFields[27].Descriptor() // usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field. usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64) // usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field. - usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor() + usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor() // usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field. usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool) // usagelogDescBillingType is the schema descriptor for billing_type field. - usagelogDescBillingType := usagelogFields[28].Descriptor() + usagelogDescBillingType := usagelogFields[30].Descriptor() // usagelog.DefaultBillingType holds the default value on creation for the billing_type field. usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8) // usagelogDescStream is the schema descriptor for stream field. - usagelogDescStream := usagelogFields[29].Descriptor() + usagelogDescStream := usagelogFields[31].Descriptor() // usagelog.DefaultStream holds the default value on creation for the stream field. usagelog.DefaultStream = usagelogDescStream.Default.(bool) // usagelogDescUserAgent is the schema descriptor for user_agent field. - usagelogDescUserAgent := usagelogFields[32].Descriptor() + usagelogDescUserAgent := usagelogFields[34].Descriptor() // usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error) // usagelogDescIPAddress is the schema descriptor for ip_address field. - usagelogDescIPAddress := usagelogFields[33].Descriptor() + usagelogDescIPAddress := usagelogFields[35].Descriptor() // usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save. usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error) // usagelogDescImageCount is the schema descriptor for image_count field. - usagelogDescImageCount := usagelogFields[34].Descriptor() + usagelogDescImageCount := usagelogFields[36].Descriptor() // usagelog.DefaultImageCount holds the default value on creation for the image_count field. usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int) // usagelogDescImageSize is the schema descriptor for image_size field. - usagelogDescImageSize := usagelogFields[35].Descriptor() + usagelogDescImageSize := usagelogFields[37].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[36].Descriptor() + usagelogDescImageInputSize := usagelogFields[38].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[37].Descriptor() + usagelogDescImageOutputSize := usagelogFields[39].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[38].Descriptor() + usagelogDescImageSizeSource := usagelogFields[40].Descriptor() // usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error) // usagelogDescVideoCount is the schema descriptor for video_count field. - usagelogDescVideoCount := usagelogFields[40].Descriptor() + usagelogDescVideoCount := usagelogFields[42].Descriptor() // usagelog.DefaultVideoCount holds the default value on creation for the video_count field. usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int) // usagelogDescVideoResolution is the schema descriptor for video_resolution field. - usagelogDescVideoResolution := usagelogFields[41].Descriptor() + usagelogDescVideoResolution := usagelogFields[43].Descriptor() // usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[45].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[44].Descriptor() + usagelogDescCreatedAt := usagelogFields[46].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 6d8c2d4191..f13d07170c 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field { MaxLen(100). Optional(). Nillable(), + // UpstreamResponseModel stores the model name declared by the upstream + // response before any protocol conversion or client-facing rewrite. + field.String("upstream_response_model"). + MaxLen(200). + Optional(). + Nillable(), + // UpstreamModelMismatch is tri-state: NULL means the upstream response did + // not declare a model (or predates this field); false/true means observed. + field.Bool("upstream_model_mismatch"). + Optional(). + Nillable(), field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"), field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"), field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index b13e29b2f7..1bd424f8d5 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -37,6 +37,10 @@ type UsageLog struct { RequestedModel *string `json:"requested_model,omitempty"` // UpstreamModel holds the value of the "upstream_model" field. UpstreamModel *string `json:"upstream_model,omitempty"` + // UpstreamResponseModel holds the value of the "upstream_response_model" field. + UpstreamResponseModel *string `json:"upstream_response_model,omitempty"` + // UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field. + UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"` // 渠道 ID ChannelID *int64 `json:"channel_id,omitempty"` // 模型映射链 @@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { switch columns[i] { case usagelog.FieldImageSizeBreakdown: values[i] = new([]byte) - case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden: + case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, 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, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.UpstreamModel = new(string) *_m.UpstreamModel = value.String } + case usagelog.FieldUpstreamResponseModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i]) + } else if value.Valid { + _m.UpstreamResponseModel = new(string) + *_m.UpstreamResponseModel = value.String + } + case usagelog.FieldUpstreamModelMismatch: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i]) + } else if value.Valid { + _m.UpstreamModelMismatch = new(bool) + *_m.UpstreamModelMismatch = value.Bool + } case usagelog.FieldChannelID: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field channel_id", values[i]) @@ -606,6 +624,16 @@ func (_m *UsageLog) String() string { builder.WriteString(*v) } builder.WriteString(", ") + if v := _m.UpstreamResponseModel; v != nil { + builder.WriteString("upstream_response_model=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.UpstreamModelMismatch; v != nil { + builder.WriteString("upstream_model_mismatch=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") if v := _m.ChannelID; v != nil { builder.WriteString("channel_id=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index a87d937195..d3f8dfa1b3 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -28,6 +28,10 @@ const ( FieldRequestedModel = "requested_model" // FieldUpstreamModel holds the string denoting the upstream_model field in the database. FieldUpstreamModel = "upstream_model" + // FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database. + FieldUpstreamResponseModel = "upstream_response_model" + // FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database. + FieldUpstreamModelMismatch = "upstream_model_mismatch" // FieldChannelID holds the string denoting the channel_id field in the database. FieldChannelID = "channel_id" // FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database. @@ -163,6 +167,8 @@ var Columns = []string{ FieldModel, FieldRequestedModel, FieldUpstreamModel, + FieldUpstreamResponseModel, + FieldUpstreamModelMismatch, FieldChannelID, FieldModelMappingChain, FieldBillingTier, @@ -222,6 +228,8 @@ var ( RequestedModelValidator func(string) error // UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. UpstreamModelValidator func(string) error + // UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save. + UpstreamResponseModelValidator func(string) error // ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save. ModelMappingChainValidator func(string) error // BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save. @@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc() } +// ByUpstreamResponseModel orders the results by the upstream_response_model field. +func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc() +} + +// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field. +func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc() +} + // ByChannelID orders the results by the channel_id field. func ByChannelID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldChannelID, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index a9462e0d0e..7982d74c5f 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v)) } +// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ. +func UpstreamResponseModel(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ. +func UpstreamModelMismatch(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v)) +} + // ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ. func ChannelID(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v)) @@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v)) } +// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field. +func UpstreamResponseModelEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field. +func UpstreamResponseModelNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field. +func UpstreamResponseModelIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...)) +} + +// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field. +func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...)) +} + +// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field. +func UpstreamResponseModelGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field. +func UpstreamResponseModelGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field. +func UpstreamResponseModelLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field. +func UpstreamResponseModelLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field. +func UpstreamResponseModelContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field. +func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field. +func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field. +func UpstreamResponseModelIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel)) +} + +// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field. +func UpstreamResponseModelNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel)) +} + +// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field. +func UpstreamResponseModelEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field. +func UpstreamResponseModelContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v)) +} + +// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchEQ(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v)) +} + +// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v)) +} + +// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch)) +} + +// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch)) +} + // ChannelIDEQ applies the EQ predicate on the "channel_id" field. func ChannelIDEQ(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 31cf45328e..8178fa0710 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate { return _c } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate { + _c.mutation.SetUpstreamResponseModel(v) + return _c +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate { + if v != nil { + _c.SetUpstreamResponseModel(*v) + } + return _c +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate { + _c.mutation.SetUpstreamModelMismatch(v) + return _c +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate { + if v != nil { + _c.SetUpstreamModelMismatch(*v) + } + return _c +} + // SetChannelID sets the "channel_id" field. func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate { _c.mutation.SetChannelID(v) @@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _c.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _c.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value) _node.UpstreamModel = &value } + if value, ok := _c.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + _node.UpstreamResponseModel = &value + } + if value, ok := _c.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + _node.UpstreamModelMismatch = &value + } if value, ok := _c.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) _node.ChannelID = &value @@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert { return u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert { + u.Set(usagelog.FieldUpstreamResponseModel, v) + return u +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUpstreamResponseModel) + return u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert { + u.SetNull(usagelog.FieldUpstreamResponseModel) + return u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert { + u.Set(usagelog.FieldUpstreamModelMismatch, v) + return u +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUpstreamModelMismatch) + return u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert { + u.SetNull(usagelog.FieldUpstreamModelMismatch) + return u +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert { u.Set(usagelog.FieldChannelID, v) @@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne { }) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamResponseModel(v) + }) +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamResponseModel() + }) +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamResponseModel() + }) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModelMismatch(v) + }) +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModelMismatch() + }) +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModelMismatch() + }) +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk { }) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamResponseModel(v) + }) +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamResponseModel() + }) +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamResponseModel() + }) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModelMismatch(v) + }) +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModelMismatch() + }) +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModelMismatch() + }) +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 2a60d6f44d..6c23ebd355 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate { return _u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate { + _u.mutation.SetUpstreamResponseModel(v) + return _u +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate { + if v != nil { + _u.SetUpstreamResponseModel(*v) + } + return _u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate { + _u.mutation.ClearUpstreamResponseModel() + return _u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate { + _u.mutation.SetUpstreamModelMismatch(v) + return _u +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate { + if v != nil { + _u.SetUpstreamModelMismatch(*v) + } + return _u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate { + _u.mutation.ClearUpstreamModelMismatch() + return _u +} + // SetChannelID sets the "channel_id" field. func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate { _u.mutation.ResetChannelID() @@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _u.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _u.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.UpstreamModelCleared() { _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) } + if value, ok := _u.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + } + if _u.mutation.UpstreamResponseModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString) + } + if value, ok := _u.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + } + if _u.mutation.UpstreamModelMismatchCleared() { + _spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool) + } if value, ok := _u.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) } @@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne { return _u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne { + _u.mutation.SetUpstreamResponseModel(v) + return _u +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetUpstreamResponseModel(*v) + } + return _u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne { + _u.mutation.ClearUpstreamResponseModel() + return _u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne { + _u.mutation.SetUpstreamModelMismatch(v) + return _u +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne { + if v != nil { + _u.SetUpstreamModelMismatch(*v) + } + return _u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne { + _u.mutation.ClearUpstreamModelMismatch() + return _u +} + // SetChannelID sets the "channel_id" field. func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne { _u.mutation.ResetChannelID() @@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _u.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _u.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.UpstreamModelCleared() { _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) } + if value, ok := _u.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + } + if _u.mutation.UpstreamResponseModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString) + } + if value, ok := _u.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + } + if _u.mutation.UpstreamModelMismatchCleared() { + _spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool) + } if value, ok := _u.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) } diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 8f55fb4165..c94ed033fa 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) { return startTime, endTime } +func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) { + raw := strings.TrimSpace(c.Query(name)) + if raw == "" { + return nil, nil + } + value, err := strconv.ParseBool(raw) + if err != nil { + return nil, err + } + return &value, nil +} + // GetStats handles getting dashboard statistics // GET /api/v1/admin/dashboard/stats func (h *DashboardHandler) GetStats(c *gin.Context) { @@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType) + trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get usage trend") return @@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType) + stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get model statistics") return @@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get group statistics") return diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 6056f725b5..3316557dba 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct { trendStream *bool modelRequestType *int16 modelStream *bool + trendMismatch *bool + modelMismatch *bool + groupMismatch *bool rankingLimit int ranking []usagestats.UserSpendingRankingItem rankingTotal float64 } +func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters( + ctx context.Context, + startTime, endTime time.Time, + granularity string, + filters usagestats.UsageLogFilters, +) ([]usagestats.TrendDataPoint, error) { + s.trendRequestType = filters.RequestType + s.trendStream = filters.Stream + s.trendMismatch = filters.UpstreamModelMismatch + return []usagestats.TrendDataPoint{}, nil +} + func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters( ctx context.Context, startTime, endTime time.Time, @@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters( return []usagestats.TrendDataPoint{}, nil } +func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource( + ctx context.Context, + startTime, endTime time.Time, + filters usagestats.UsageLogFilters, + source string, +) ([]usagestats.ModelStat, error) { + s.modelRequestType = filters.RequestType + s.modelStream = filters.Stream + s.modelMismatch = filters.UpstreamModelMismatch + return []usagestats.ModelStat{}, nil +} + +func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters( + ctx context.Context, + startTime, endTime time.Time, + filters usagestats.UsageLogFilters, +) ([]usagestats.GroupStat, error) { + s.groupMismatch = filters.UpstreamModelMismatch + return []usagestats.GroupStat{}, nil +} + func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters( ctx context.Context, startTime, endTime time.Time, @@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng router := gin.New() router.GET("/admin/dashboard/trend", handler.GetUsageTrend) router.GET("/admin/dashboard/models", handler.GetModelStats) + router.GET("/admin/dashboard/groups", handler.GetGroupStats) router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking) return router } @@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) } +func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) { + resetDashboardReadCachesForTest() + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?upstream_model_mismatch=true", + "/admin/dashboard/models?upstream_model_mismatch=true", + "/admin/dashboard/groups?upstream_model_mismatch=true", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, path) + } + + require.NotNil(t, repo.trendMismatch) + require.True(t, *repo.trendMismatch) + require.NotNil(t, repo.modelMismatch) + require.True(t, *repo.modelMismatch) + require.NotNil(t, repo.groupMismatch) + require.True(t, *repo.groupMismatch) +} + +func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?upstream_model_mismatch=invalid", + "/admin/dashboard/models?upstream_model_mismatch=invalid", + "/admin/dashboard/groups?upstream_model_mismatch=invalid", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, path) + } +} + func TestDashboardUsersRankingLimitAndCache(t *testing.T) { dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute) repo := &dashboardUsageRepoCapture{ diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 815c516154..4f79fb4500 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -18,30 +18,32 @@ var ( ) type dashboardTrendCacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Granularity string `json:"granularity"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Granularity string `json:"granularity"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } type dashboardModelGroupCacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - ModelSource string `json:"model_source,omitempty"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + ModelSource string `json:"model_source,omitempty"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } type dashboardEntityTrendCacheKey struct { @@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.TrendDataPoint, bool, error) { key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - Granularity: granularity, - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - Model: model, - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + Granularity: granularity, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType) + return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + Model: model, RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }) }) if err != nil { return nil, hit, err @@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.ModelStat, bool, error) { key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - ModelSource: usagestats.NormalizeModelSource(modelSource), - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + ModelSource: usagestats.NormalizeModelSource(modelSource), + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource) + return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }, modelSource) }) if err != nil { return nil, hit, err @@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.GroupStat, bool, error) { key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 517ae7bd14..b19c9360aa 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct { } type dashboardSnapshotV2Filters struct { - UserID int64 - APIKeyID int64 - AccountID int64 - GroupID int64 - Model string - RequestType *int16 - Stream *bool - BillingType *int8 + UserID int64 + APIKeyID int64 + AccountID int64 + GroupID int64 + Model string + RequestType *int16 + Stream *bool + BillingType *int8 + UpstreamModelMismatch *bool } type dashboardSnapshotV2CacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Granularity string `json:"granularity"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` - IncludeStats bool `json:"include_stats"` - IncludeTrend bool `json:"include_trend"` - IncludeModels bool `json:"include_models"` - IncludeGroups bool `json:"include_groups"` - IncludeUsersTrend bool `json:"include_users_trend"` - UsersTrendLimit int `json:"users_trend_limit"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Granularity string `json:"granularity"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` + IncludeStats bool `json:"include_stats"` + IncludeTrend bool `json:"include_trend"` + IncludeModels bool `json:"include_models"` + IncludeGroups bool `json:"include_groups"` + IncludeUsersTrend bool `json:"include_users_trend"` + UsersTrendLimit int `json:"users_trend_limit"` } func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) { @@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) { } keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - Granularity: granularity, - UserID: filters.UserID, - APIKeyID: filters.APIKeyID, - AccountID: filters.AccountID, - GroupID: filters.GroupID, - Model: filters.Model, - RequestType: filters.RequestType, - Stream: filters.Stream, - BillingType: filters.BillingType, - IncludeStats: includeStats, - IncludeTrend: includeTrend, - IncludeModels: includeModels, - IncludeGroups: includeGroups, - IncludeUsersTrend: includeUsersTrend, - UsersTrendLimit: usersTrendLimit, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + Granularity: granularity, + UserID: filters.UserID, + APIKeyID: filters.APIKeyID, + AccountID: filters.AccountID, + GroupID: filters.GroupID, + Model: filters.Model, + RequestType: filters.RequestType, + Stream: filters.Stream, + BillingType: filters.BillingType, + UpstreamModelMismatch: filters.UpstreamModelMismatch, + IncludeStats: includeStats, + IncludeTrend: includeTrend, + IncludeModels: includeModels, + IncludeGroups: includeGroups, + IncludeUsersTrend: includeUsersTrend, + UsersTrendLimit: usersTrendLimit, }) cacheKey := string(keyRaw) @@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get usage trend") @@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get model statistics") @@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get group statistics") @@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter filters.BillingType = &bt } + if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" { + value, err := strconv.ParseBool(mismatchStr) + if err != nil { + return nil, err + } + filters.UpstreamModelMismatch = &value + } + return filters, nil } diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index ad2bbecd22..829730c647 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) { billingType = &bt } + var upstreamModelMismatch *bool + if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } + upstreamModelMismatch = &value + } + // Parse date range var startTime, endTime *time.Time userTZ := c.Query("timezone") // Get user's timezone from request @@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) { SortOrder: c.DefaultQuery("sort_order", "desc"), } filters := usagestats.UsageLogFilters{ - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - RequestID: requestID, - Model: model, - ModelFilterSource: usagestats.ModelSourceRequested, - RequestType: requestType, - Stream: stream, - BillingType: billingType, - BillingMode: billingMode, - StartTime: startTime, - EndTime: endTime, - ExactTotal: exactTotal, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + RequestID: requestID, + Model: model, + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + BillingMode: billingMode, + UpstreamModelMismatch: upstreamModelMismatch, + StartTime: startTime, + EndTime: endTime, + ExactTotal: exactTotal, } records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters) @@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) { billingType = &bt } + var upstreamModelMismatch *bool + if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } + upstreamModelMismatch = &value + } + // Parse date range userTZ := c.Query("timezone") now := timezone.NowInUserLocation(userTZ) @@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) { // Build filters and call GetStatsWithFilters filters := usagestats.UsageLogFilters{ - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - Model: model, - ModelFilterSource: usagestats.ModelSourceRequested, - RequestType: requestType, - Stream: stream, - BillingType: billingType, - BillingMode: billingMode, - StartTime: &startTime, - EndTime: &endTime, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + BillingMode: billingMode, + UpstreamModelMismatch: upstreamModelMismatch, + StartTime: &startTime, + EndTime: &endTime, } var stats *usagestats.UsageStats diff --git a/backend/internal/handler/admin/usage_query_cache.go b/backend/internal/handler/admin/usage_query_cache.go index b288a95ba4..f6b7746e1b 100644 --- a/backend/internal/handler/admin/usage_query_cache.go +++ b/backend/internal/handler/admin/usage_query_cache.go @@ -11,17 +11,18 @@ import ( var usageStatsCache = newSnapshotCache(30 * time.Second) type usageStatsCacheKeyData struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - BillingMode string `json:"billing_mode"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + BillingMode string `json:"billing_mode"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } func usageStatsCacheKey(filters usagestats.UsageLogFilters) string { @@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string { end = filters.EndTime.UTC().Format(time.RFC3339) } return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{ - StartTime: start, - EndTime: end, - UserID: filters.UserID, - APIKeyID: filters.APIKeyID, - AccountID: filters.AccountID, - GroupID: filters.GroupID, - Model: filters.Model, - BillingMode: filters.BillingMode, - RequestType: filters.RequestType, - Stream: filters.Stream, - BillingType: filters.BillingType, + StartTime: start, + EndTime: end, + UserID: filters.UserID, + APIKeyID: filters.APIKeyID, + AccountID: filters.AccountID, + GroupID: filters.GroupID, + Model: filters.Model, + BillingMode: filters.BillingMode, + RequestType: filters.RequestType, + Stream: filters.Stream, + BillingType: filters.BillingType, + UpstreamModelMismatch: filters.UpstreamModelMismatch, }) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index c6f6cf25e2..8687e79741 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -705,6 +705,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { return &AdminUsageLog{ UsageLog: usageLog, UpstreamModel: l.UpstreamModel, + UpstreamResponseModel: l.UpstreamResponseModel, + UpstreamModelMismatch: l.UpstreamModelMismatch, ChannelID: l.ChannelID, ModelMappingChain: l.ModelMappingChain, BillingTier: l.BillingTier, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index 6cbcbada56..d396bbfa2f 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test t.Parallel() upstreamModel := "claude-sonnet-4-20250514" + upstreamResponseModel := "claude-sonnet-4-20250513" + upstreamModelMismatch := true log := &service.UsageLog{ - RequestID: "req_4", - Model: upstreamModel, - RequestedModel: "claude-sonnet-4", - UpstreamModel: &upstreamModel, + RequestID: "req_4", + Model: upstreamModel, + RequestedModel: "claude-sonnet-4", + UpstreamModel: &upstreamModel, + UpstreamResponseModel: &upstreamResponseModel, + UpstreamModelMismatch: &upstreamModelMismatch, } userDTO := UsageLogFromService(log) @@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test userJSON, err := json.Marshal(userDTO) require.NoError(t, err) require.NotContains(t, string(userJSON), "upstream_model") + require.NotContains(t, string(userJSON), "upstream_response_model") + require.NotContains(t, string(userJSON), "upstream_model_mismatch") adminJSON, err := json.Marshal(adminDTO) require.NoError(t, err) require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`) + require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`) + require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`) } func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 37927c2b48..1761c4aaae 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -555,6 +555,10 @@ type AdminUsageLog struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Omitted when no mapping was applied (requested model was used as-is). UpstreamModel *string `json:"upstream_model,omitempty"` + // UpstreamResponseModel is the raw model declared by the upstream response. + UpstreamResponseModel *string `json:"upstream_response_model,omitempty"` + // UpstreamModelMismatch is nil when the upstream did not declare a model. + UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"` // ChannelID 渠道 ID ChannelID *int64 `json:"channel_id,omitempty"` diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index 8dc9a8b442..1e480d2e34 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -224,6 +224,8 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) require.NotContains(t, body, "account_rate_multiplier") require.NotContains(t, body, "account_stats_cost") require.NotContains(t, body, "upstream_model") + require.NotContains(t, body, "upstream_response_model") + require.NotContains(t, body, "upstream_model_mismatch") require.NotContains(t, body, "billing_tier") require.NotContains(t, body, "channel_id") require.NotContains(t, body, `"account":`) diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 959907ca77..a100b2ccaa 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -274,13 +274,14 @@ type UsageLogFilters struct { RequestID string Model string // ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics. - ModelFilterSource string - RequestType *int16 - Stream *bool - BillingType *int8 - BillingMode string - StartTime *time.Time - EndTime *time.Time + ModelFilterSource string + RequestType *int16 + Stream *bool + BillingType *int8 + BillingMode string + UpstreamModelMismatch *bool + StartTime *time.Time + EndTime *time.Time // ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging. ExactTotal bool } diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index c5e13b25b1..b28cef1ca3 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -57,6 +57,8 @@ const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_de const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key" const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index_notx.sql" const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip" +const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql" +const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -283,6 +285,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio return dropInvalidIndexIfPresent(ctx, db, schedulerOutboxPendingDedupKeyIndex) case latestAPIKeyIPIndexMigration: return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex) + case usageLogsUpstreamModelMismatchIndexMigration: + return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex) default: return nil } diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go index 4ab6274b92..55588bb043 100644 --- a/backend/internal/repository/migrations_runner_notx_test.go +++ b/backend/internal/repository/migrations_runner_notx_test.go @@ -155,6 +155,42 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_api_key_latest_ip require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyMigrationsFS_NonTransactionalMigration_UsageModelMismatchIndexDropsInvalidIndexBeforeRetry(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + prepareMigrationsBootstrapExpectations(mock) + mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1"). + WithArgs(usageLogsUpstreamModelMismatchIndexMigration). + WillReturnError(sql.ErrNoRows) + mock.ExpectQuery("SELECT EXISTS \\("). + WithArgs(usageLogsUpstreamModelMismatchIndex). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_usage_logs_upstream_model_mismatch_created_at"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)"). + WithArgs(usageLogsUpstreamModelMismatchIndexMigration, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)"). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + fsys := fstest.MapFS{ + usageLogsUpstreamModelMismatchIndexMigration: &fstest.MapFile{Data: []byte(` +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at + ON usage_logs (created_at DESC, id DESC) + WHERE upstream_model_mismatch IS TRUE; +`)}, + } + + err = applyMigrationsFS(context.Background(), db, fsys) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index b393bc15b6..e4af36ae25 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -76,6 +76,24 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false) requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true) requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true) + requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true) + requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true) + requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex) + + var mismatchIndexDef string + require.NoError(t, tx.QueryRowContext(context.Background(), ` +SELECT pg_get_indexdef(i.indexrelid) +FROM pg_class idx +JOIN pg_index i ON i.indexrelid = idx.oid +JOIN pg_class tbl ON tbl.oid = i.indrelid +JOIN pg_namespace ns ON ns.oid = tbl.relnamespace +WHERE ns.nspname = 'public' + AND tbl.relname = 'usage_logs' + AND idx.relname = $1 +`, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef)) + require.Contains(t, mismatchIndexDef, "created_at DESC") + require.Contains(t, mismatchIndexDef, "id DESC") + require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)") requireConstraintDefinitionContains( t, tx, diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 809b0652b5..6d932c0d43 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -31,6 +31,8 @@ var usageLogInsertArgTypes = [...]string{ "text", // model "text", // requested_model "text", // upstream_model + "text", // upstream_response_model + "boolean", // upstream_model_mismatch "bigint", // group_id "bigint", // subscription_id "integer", // input_tokens @@ -227,6 +229,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -278,12 +282,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, session_id, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, + $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -682,6 +686,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -734,9 +740,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - // Each batch row prepends the synthetic input_index before the 57 + // Each batch row prepends the synthetic input_index before the 59 // usage-log column values. - args := make([]any, 0, len(keys)*58) + args := make([]any, 0, len(keys)*60) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -772,6 +778,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -831,6 +839,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -930,6 +940,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -982,7 +994,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*57) + args := make([]any, 0, len(preparedList)*59) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1015,6 +1027,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1074,6 +1088,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1141,6 +1157,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1192,12 +1210,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared session_id, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, + $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1244,6 +1262,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { requestedModel = strings.TrimSpace(log.Model) } upstreamModel := nullString(log.UpstreamModel) + upstreamResponseModel := nullString(log.UpstreamResponseModel) + upstreamModelMismatch := nullBool(log.UpstreamModelMismatch) var requestIDArg any if requestID != "" { @@ -1263,6 +1283,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.Model, nullString(&requestedModel), upstreamModel, + upstreamResponseModel, + upstreamModelMismatch, groupID, subscriptionID, log.InputTokens, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 084b79ad97..f2fa6e30b0 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, 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, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -127,6 +127,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat args = append(args, int16(*filters.BillingType)) } conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.UpstreamModelMismatch != nil { + conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch)) + } if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) @@ -157,6 +160,13 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat return logs, page, nil } +func upstreamModelMismatchCondition(column string, mismatch bool) string { + if mismatch { + return column + " IS TRUE" + } + return column + " IS FALSE" +} + func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool { if filters.ExactTotal { return false @@ -437,6 +447,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e model string requestedModel sql.NullString upstreamModel sql.NullString + upstreamResponseModel sql.NullString + upstreamModelMismatch sql.NullBool groupID sql.NullInt64 subscriptionID sql.NullInt64 inputTokens int @@ -498,6 +510,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &model, &requestedModel, &upstreamModel, + &upstreamResponseModel, + &upstreamModelMismatch, &groupID, &subscriptionID, &inputTokens, @@ -651,6 +665,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if upstreamModel.Valid { log.UpstreamModel = &upstreamModel.String } + if upstreamResponseModel.Valid { + log.UpstreamResponseModel = &upstreamResponseModel.String + } + if upstreamModelMismatch.Valid { + value := upstreamModelMismatch.Bool + log.UpstreamModelMismatch = &value + } if channelID.Valid { value := channelID.Int64 log.ChannelID = &value @@ -703,6 +724,13 @@ func nullString(v *string) sql.NullString { return sql.NullString{String: *v, Valid: true} } +func nullBool(v *bool) sql.NullBool { + if v == nil { + return sql.NullBool{} + } + return sql.NullBool{Bool: *v, Valid: true} +} + func nullStringIntMapJSON(v map[string]int) any { if len(v) == 0 { return nil 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 047d0bf65f..a2b82dfa60 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -48,6 +48,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { log.Model, log.RequestedModel, sqlmock.AnyArg(), // upstream_model + sqlmock.AnyArg(), // upstream_response_model + sqlmock.AnyArg(), // upstream_model_mismatch sqlmock.AnyArg(), // group_id sqlmock.AnyArg(), // subscription_id log.InputTokens, @@ -137,9 +139,11 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { log.RequestID, log.Model, log.RequestedModel, - sqlmock.AnyArg(), - sqlmock.AnyArg(), - sqlmock.AnyArg(), + sqlmock.AnyArg(), // upstream_model + sqlmock.AnyArg(), // upstream_response_model + sqlmock.AnyArg(), // upstream_model_mismatch + sqlmock.AnyArg(), // group_id + sqlmock.AnyArg(), // subscription_id log.InputTokens, log.OutputTokens, log.CacheCreationTokens, @@ -211,8 +215,8 @@ func TestBuildUsageLogBestEffortInsertQuery_IncludesRequestedModelColumn(t *test query, args := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared}) require.Contains(t, query, "INSERT INTO usage_logs (") - require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,") - require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,") + require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,") + require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,") require.Len(t, args, len(prepared.args)) require.Equal(t, prepared.args[5], args[5]) } @@ -273,11 +277,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) { CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC), }) - require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36]) - require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37]) - require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38]) - require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39]) - breakdownJSON, ok := prepared.args[40].(string) + require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[38]) + require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[39]) + require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[40]) + require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[41]) + breakdownJSON, ok := prepared.args[42].(string) require.True(t, ok) require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON) } @@ -809,6 +813,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-image-2", sql.NullString{Valid: true, String: "gpt-image-2"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 0, 0, 0, 0, 0, 0, @@ -872,6 +878,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5", // model sql.NullString{Valid: true, String: "gpt-5"}, // requested_model sql.NullString{}, // upstream_model + sql.NullString{}, // upstream_response_model + sql.NullBool{}, // upstream_model_mismatch sql.NullInt64{}, // group_id sql.NullInt64{}, // subscription_id 1, // input_tokens @@ -942,6 +950,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5", sql.NullString{Valid: true, String: "gpt-5"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, @@ -1000,6 +1010,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5.4", sql.NullString{Valid: true, String: "gpt-5.4"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index aeccd49b30..53d60b7f4a 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -683,6 +683,9 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us args = append(args, int16(*filters.BillingType)) } conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.UpstreamModelMismatch != nil { + conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch)) + } if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index 44052d07cc..e9025b1bd1 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -4,6 +4,7 @@ package repository import ( "context" + "strings" "testing" "time" @@ -12,6 +13,66 @@ import ( "github.com/stretchr/testify/require" ) +func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + client := tx.Client() + repo := newUsageLogRepositoryWithSQL(client, tx) + + user := mustCreateUser(t, client, &service.User{Email: "model-audit@test.com"}) + apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-model-audit", Name: "model-audit"}) + account := mustCreateAccount(t, client, &service.Account{Name: "model-audit-account"}) + now := time.Now().UTC() + responseModel := "gpt-5.4" + for _, mismatch := range []bool{true, false} { + mismatchValue := mismatch + _, err := repo.Create(ctx, &service.UsageLog{ + UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID, + Model: "gpt-5.5", InputTokens: 1, OutputTokens: 1, + UpstreamResponseModel: &responseModel, UpstreamModelMismatch: &mismatchValue, + CreatedAt: now, + }) + require.NoError(t, err) + } + + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + trueValue := true + stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{ + UserID: user.ID, StartTime: &start, EndTime: &end, UpstreamModelMismatch: &trueValue, + }) + require.NoError(t, err) + require.Equal(t, int64(1), stats.TotalRequests) + + trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{ + UserID: user.ID, UpstreamModelMismatch: &trueValue, + }) + require.NoError(t, err) + require.Len(t, trend, 1) + require.Equal(t, int64(1), trend[0].Requests) + + _, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off") + require.NoError(t, err) + rows, err := tx.QueryContext(ctx, ` +EXPLAIN (COSTS OFF) +SELECT id +FROM usage_logs +WHERE upstream_model_mismatch IS TRUE +ORDER BY created_at DESC, id DESC +LIMIT 100 +`) + require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() + var planLines []string + for rows.Next() { + var line string + require.NoError(t, rows.Scan(&line)) + planLines = append(planLines, line) + } + require.NoError(t, rows.Err()) + require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex) +} + func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { ctx := context.Background() tx := testEntTx(t) diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 76863911c6..6d8bd79eaa 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -265,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user // GetUserModelStats 获取指定用户的模型统计 func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil) } // GetUsageTrendWithFilters returns usage trend data with optional filters func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil) } func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { - if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { +func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) { + if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) { aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) if aggregatedErr == nil && len(aggregated) > 0 { return aggregated, nil @@ -326,6 +326,9 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch) + } query += " GROUP BY date ORDER BY date ASC" rows, err := r.sql.QueryContext(ctx, query, args...) @@ -348,7 +351,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start return results, nil } -func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { +func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool { if granularity != "day" && granularity != "hour" { return false } @@ -360,7 +363,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID requestType == nil && stream == nil && billingType == nil && - billingMode == "" + billingMode == "" && + upstreamModelMismatch == nil } func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { @@ -425,20 +429,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil) } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. // source: requested | upstream | mapping. func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil) } func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 if accountID > 0 && userID == 0 && apiKeyID == 0 { @@ -490,6 +494,9 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch) + } query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr) rows, err := r.sql.QueryContext(ctx, query, args...) @@ -514,14 +521,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex // GetGroupStatsWithFilters returns group usage statistics with optional filters func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") + return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil) } func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { +func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) { query := ` SELECT COALESCE(ul.group_id, 0) as group_id, @@ -564,6 +571,9 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch) + } query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC" rows, err := r.sql.QueryContext(ctx, query, args...) diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go index 7804dce306..64c1c1bab2 100644 --- a/backend/internal/repository/usage_log_session_id_unit_test.go +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog { // arg slice / arg-type table so the five INSERT column lists stay in sync. session_id // is the penultimate arg (created_at is always last). func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) { - require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id") + require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id") sessionID := "sess-persisted-123" prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID)) diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 2011625984..3511f60288 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -28,6 +28,7 @@ import ( // ├─ 成功 → 正常返回 // └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) // 上游透传账号直接转发,不走 OAuth token 刷新 if account.Type == AccountTypeUpstream { return s.ForwardUpstream(ctx, c, account, body) @@ -448,14 +449,16 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: claudeReq.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: billingModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: claudeReq.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index af1b8ebbe4..ab8f0d17a2 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -168,6 +168,7 @@ func (s *AntigravityGatewayService) forwardAntigravityCompat( account *Account, request antigravityCompatRequest, ) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) call, err := s.prepareAntigravityCompatCall(ctx, c, account, request) if err != nil { return nil, err @@ -324,15 +325,17 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse( } return &ForwardResult{ - RequestID: requestID, - Usage: *streamResult.usage, - Model: call.request.originalModel, - UpstreamModel: call.billingModel, - Stream: call.request.clientStream, - Duration: time.Since(call.request.startTime), - FirstTokenMs: streamResult.firstTokenMs, - ReasoningEffort: call.request.reasoningEffort, - ClientDisconnect: streamResult.clientDisconnect, + RequestID: requestID, + Usage: *streamResult.usage, + Model: call.request.originalModel, + UpstreamModel: call.billingModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: call.request.clientStream, + Duration: time.Since(call.request.startTime), + FirstTokenMs: streamResult.firstTokenMs, + ReasoningEffort: call.request.reasoningEffort, + ClientDisconnect: streamResult.clientDisconnect, }, nil } @@ -477,7 +480,7 @@ func (s *AntigravityGatewayService) handleChatCompletionsNonStreamingFromAntigra startTime time.Time, originalModel string, ) (*antigravityStreamResult, error) { - claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { return nil, s.mapAntigravityCompatCollectionError(c, err) } @@ -496,7 +499,7 @@ func (s *AntigravityGatewayService) handleResponsesNonStreamingFromAntigravity( startTime time.Time, originalModel string, ) (*antigravityStreamResult, error) { - claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { return nil, s.mapAntigravityCompatCollectionError(c, err) } diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index 3243193ceb..26d30ddb46 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -301,6 +301,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix) } resetAntigravityCompatTimer(timeoutTimer, timeout) + s.observeAntigravityGeminiSSELine(c, event.line) session.consume(event.line) case <-timeoutCh: diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index f0d20244a8..2b622afd37 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -42,6 +42,7 @@ func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOp } func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() forwardOpts := forwardGeminiOptions{} for _, apply := range options { @@ -203,6 +204,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if err == nil && fallbackResp.StatusCode < 400 { _ = resp.Body.Close() resp = fallbackResp + billingModel = fallbackModel } else if fallbackResp != nil { _ = fallbackResp.Body.Close() } @@ -431,17 +433,19 @@ handleSuccess: } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: billingModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 2810162c45..0b3a672853 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -22,6 +22,26 @@ type antigravityStreamResult struct { clientDisconnect bool // 客户端是否在流式传输过程中断开 } +func (s *AntigravityGatewayService) observeAntigravityGeminiSSELine(c *gin.Context, line string) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "data:") { + return + } + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + return + } + raw := []byte(payload) + if inner, err := s.unwrapV1InternalResponse(raw); err == nil && len(inner) > 0 { + raw = inner + } + observer.ObserveGemini(raw) +} + // antigravityClientWriter 封装流式响应的客户端写入,自动检测断开并标记。 // 断开后所有写入操作变为 no-op,调用方通过 Disconnected() 判断是否继续 drain 上游。 type antigravityClientWriter struct { @@ -95,6 +115,9 @@ func handleStreamReadError(err error, clientDisconnected bool, prefix string) (d } func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } c.Status(resp.StatusCode) c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") @@ -220,6 +243,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context lastDataAt = time.Now() line := ev.line + s.observeAntigravityGeminiSSELine(c, line) trimmed := strings.TrimRight(line, "\r\n") if strings.HasPrefix(trimmed, "data:") { payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) @@ -298,6 +322,9 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context // handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端 // Gemini 流式响应是增量的,需要累积所有 chunk 的内容 func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { @@ -377,6 +404,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont } line := ev.line + s.observeAntigravityGeminiSSELine(c, line) trimmed := strings.TrimRight(line, "\r\n") if !strings.HasPrefix(trimmed, "data:") { @@ -765,7 +793,10 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, // collectClaudeStreamResponse 收集上游流式响应,转换为 Claude 非流式格式返回 // 用于处理客户端非流式请求但上游只支持流式的情况 -func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) { +func (s *AntigravityGatewayService) collectClaudeStreamResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { @@ -860,6 +891,7 @@ func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Respo if parseErr != nil { continue } + upstreamResponseModelObserverFromContext(c).ObserveGemini(inner) var parsed map[string]any if err := json.Unmarshal(inner, &parsed); err != nil { @@ -941,7 +973,7 @@ returnResponse: // handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回 // 用于处理客户端非流式请求但上游只支持流式的情况 func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { - claudeResp, streamRes, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResp, streamRes, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { var failoverErr *UpstreamFailoverError if errors.As(err, &failoverErr) { @@ -1120,6 +1152,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context } lastDataAt = time.Now() + s.observeAntigravityGeminiSSELine(c, ev.line) // 处理 SSE 行,转换为 Claude 格式 claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index 2914863131..7254745698 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -19,6 +19,7 @@ import ( // ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求 func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() sessionID := getSessionID(c) prefix := logPrefix(sessionID, account.Name) @@ -129,6 +130,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. } // 提取 usage + upstreamResponseModelObserverFromContext(c).ObserveAnthropic(respBody) usage = s.extractClaudeUsage(respBody) c.Header("Content-Type", resp.Header.Get("Content-Type")) @@ -141,11 +143,13 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds()) return &ForwardResult{ - Model: originalModel, - Stream: claudeReq.Stream, - Duration: duration, - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + Model: originalModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: claudeReq.Stream, + Duration: duration, + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, Usage: ClaudeUsage{ InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, @@ -247,6 +251,9 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp lastDataAt = time.Now() line := ev.line + if data, ok := extractAnthropicSSEDataLine(line); ok { + upstreamResponseModelObserverFromContext(c).ObserveAnthropic([]byte(strings.TrimSpace(data))) + } // 记录首 token 时间 if firstTokenMs == nil && len(line) > 0 { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3e059e3069..d815ed6ef5 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -132,6 +132,20 @@ func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTi return trend, nil } +func (s *DashboardService) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) { + type usageTrendWithFiltersRepo interface { + GetUsageTrendWithUsageFilters(context.Context, time.Time, time.Time, string, usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) + } + if repo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok { + trend, err := repo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters) + if err != nil { + return nil, fmt.Errorf("get usage trend with usage filters: %w", err) + } + return trend, nil + } + return s.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType) +} + func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) { stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) if err != nil { @@ -161,6 +175,21 @@ func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) } +func (s *DashboardService) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) { + normalizedSource := usagestats.NormalizeModelSource(modelSource) + type modelStatsWithFiltersRepo interface { + GetModelStatsWithUsageFiltersBySource(context.Context, time.Time, time.Time, usagestats.UsageLogFilters, string) ([]usagestats.ModelStat, error) + } + if repo, ok := s.usageRepo.(modelStatsWithFiltersRepo); ok { + stats, err := repo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource) + if err != nil { + return nil, fmt.Errorf("get model stats with usage filters by source: %w", err) + } + return stats, nil + } + return s.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource) +} + func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) { stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) if err != nil { @@ -169,6 +198,20 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi return stats, nil } +func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) { + type groupStatsWithFiltersRepo interface { + GetGroupStatsWithUsageFilters(context.Context, time.Time, time.Time, usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) + } + if repo, ok := s.usageRepo.(groupStatsWithFiltersRepo); ok { + stats, err := repo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters) + if err != nil { + return nil, fmt.Errorf("get group stats with usage filters: %w", err) + } + return stats, nil + } + return s.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType) +} + // GetGroupUsageSummary returns today's and cumulative cost for all groups. func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index e46977f37d..c18d5bf047 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -271,7 +271,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( if err != nil { // 流中断时保留已观测到的 usage 与错误一起返回,避免上游已计量的请求 // 完全漏记漏计费(issue #5148)。 - if partial := partialStreamUsageResult(resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil { + if partial := partialStreamUsageResult(c, resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil { return partial, err } return nil, err @@ -290,14 +290,16 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: input.OriginalModel, - UpstreamModel: input.RequestModel, - Stream: input.RequestStream, - Duration: time.Since(input.StartTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: input.OriginalModel, + UpstreamModel: input.RequestModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: input.RequestStream, + Duration: time.Since(input.StartTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } @@ -379,6 +381,10 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( startTime time.Time, model string, ) (*streamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } if s.rateLimitService != nil { s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) } @@ -532,6 +538,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( line := ev.line if data, ok := extractAnthropicSSEDataLine(line); ok { trimmed := strings.TrimSpace(data) + observer.ObserveAnthropic([]byte(trimmed)) if anthropicStreamEventIsTerminal("", trimmed) { sawTerminalEvent = true } @@ -782,6 +789,11 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { var raw json.RawMessage diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index b862dfb7c0..1c1d41a711 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -92,6 +92,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + beginUpstreamResponseModelObservation(c) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) { @@ -845,7 +846,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } // 流中断(缺失 terminal 事件、读错误、数据间隔超时等)时保留已观测到的 // usage 与错误一起返回,handler 在错误处理完成后照常提交 usage 记录。 - if partial := partialStreamUsageResult(resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil { + if partial := partialStreamUsageResult(c, resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil { return partial, err } return nil, err @@ -861,14 +862,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, // 使用原始模型用于计费和日志 - UpstreamModel: mappedModel, - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, // 使用原始模型用于计费和日志 + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index bb4ce85476..da246af030 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -576,12 +576,16 @@ type ForwardResult struct { Model string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. - UpstreamModel string - Stream bool - Duration time.Duration - FirstTokenMs *int // 首字时间(流式请求) - ClientDisconnect bool // 客户端是否在流式传输过程中断开 - ReasoningEffort *string + UpstreamModel string + // UpstreamResponseModel is captured from the raw successful upstream + // response before any client-facing rewrite or protocol conversion. + UpstreamResponseModel string + UpstreamResponseModelConflict bool + Stream bool + Duration time.Duration + FirstTokenMs *int // 首字时间(流式请求) + ClientDisconnect bool // 客户端是否在流式传输过程中断开 + ReasoningEffort *string // 图片生成计费字段(图片生成模型使用) ImageCount int // 生成的图片数量 diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 2ddc831992..5a9fedf964 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -665,7 +665,7 @@ func (u *ClaudeUsage) hasObservedTokens() bool { // // 不变式:UpstreamFailoverError 必须保持 result=nil——failover 重试成功后按成功请求 // 计费,若同时返回部分 usage 会造成双重计费,此处显式拦截兜底。 -func partialStreamUsageResult(resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult { +func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult { if streamResult == nil || !streamResult.usage.hasObservedTokens() { return nil } @@ -674,18 +674,24 @@ func partialStreamUsageResult(resp *http.Response, streamResult *streamingResult return nil } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *streamResult.usage, - Model: model, - UpstreamModel: upstreamModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: streamResult.firstTokenMs, - ClientDisconnect: streamResult.clientDisconnect, + RequestID: resp.Header.Get("x-request-id"), + Usage: *streamResult.usage, + Model: model, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: streamResult.firstTokenMs, + ClientDisconnect: streamResult.clientDisconnect, } } func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } // 更新5h窗口状态 s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) @@ -881,6 +887,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http } eventType, _ := event["type"].(string) + observer.ObserveAnthropic([]byte(dataLine)) if eventName == "" { eventName = eventType } @@ -1376,6 +1383,11 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) // 解析usage var response struct { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0d07b355c6..8deefe3d6e 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -990,6 +990,16 @@ func (s *GatewayService) buildRecordUsageLog( ) *UsageLog { durationMs := int(result.Duration.Milliseconds()) requestID := resolveUsageBillingRequestID(ctx, result.RequestID) + sentModel := upstreamSentModel(result.Model, result.UpstreamModel) + if result.UpstreamResponseModelConflict { + slog.Warn("upstream_response_model_conflict", + "platform", account.Platform, + "account_id", account.ID, + "request_id", requestID, + "sent_model", sentModel, + "selected_response_model", strings.TrimSpace(result.UpstreamResponseModel), + ) + } usageLog := &UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, @@ -998,6 +1008,8 @@ func (s *GatewayService) buildRecordUsageLog( Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), + UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), + UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index e4df8982d3..fcedf9a21f 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -581,6 +581,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont } func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() var req struct { @@ -1072,6 +1073,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream") } collectedBytes, _ := json.Marshal(collected) + upstreamResponseModelObserverFromContext(c).ObserveGemini(collectedBytes) claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes, false) c.JSON(http.StatusOK, claudeResp) usage = usageObj2 @@ -1095,16 +1097,18 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: req.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: req.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -1117,6 +1121,7 @@ func isGeminiSignatureRelatedError(respBody []byte) bool { } func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() if strings.TrimSpace(originalModel) == "" { @@ -1603,6 +1608,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to read upstream stream") } b, _ := json.Marshal(collected) + upstreamResponseModelObserverFromContext(c).ObserveGemini(b) c.Data(http.StatusOK, "application/json", b) usage = usageObj } else { @@ -1627,16 +1633,18 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -1999,6 +2007,11 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context, if err != nil { return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(unwrappedBody) var geminiResp map[string]any if err := json.Unmarshal(unwrappedBody, &geminiResp); err != nil { @@ -2082,6 +2095,11 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re if err != nil { continue } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(unwrappedBytes) var geminiResp map[string]any if err := json.Unmarshal(unwrappedBytes, &geminiResp); err != nil { @@ -2583,6 +2601,11 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co respBody = unwrappedBody } } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(respBody) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -2631,6 +2654,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte reader := bufio.NewReader(resp.Body) usage := &ClaudeUsage{} + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } var firstTokenMs *int for { @@ -2661,6 +2688,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte if u := extractGeminiUsage(rawBytes); u != nil { usage = u } + observer.ObserveGemini(rawBytes) if firstTokenMs == nil { ms := int(time.Since(startTime).Milliseconds()) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 171a6c8805..0b135bbb06 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -58,6 +58,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + beginUpstreamResponseModelObservation(c) + restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) if restrictionResult.Enabled && !restrictionResult.Matched { @@ -419,6 +421,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event") return nil, fmt.Errorf("upstream stream ended without terminal event") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.Observe(finalResponse.Model, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) // cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4), @@ -477,13 +484,15 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( c.JSON(http.StatusOK, chatResp) return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: false, + Duration: time.Since(startTime), }, nil } @@ -517,6 +526,10 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen) var streamFailoverErr *UpstreamFailoverError var streamNonFailoverErr error + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } scanner := s.newUpstreamSSEScanner(resp.Body) @@ -536,14 +549,16 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } } @@ -562,6 +577,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( ) return false } + observer.ObserveOpenAI([]byte(payload), event.Type) refusalDetector.ObservePayload([]byte(payload)) isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 49cd591829..0a7cd98ec7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -253,6 +253,10 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( startTime time.Time, requestBodyLen int, ) (*OpenAIForwardResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } requestID := resp.Header.Get("x-request-id") writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header) scanner := s.newUpstreamSSEScanner(resp.Body) @@ -302,6 +306,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( if payload, ok := extractOpenAISSEDataLine(line); ok { trimmedPayload := strings.TrimSpace(payload) if trimmedPayload != "[DONE]" { + observer.ObserveOpenAI([]byte(payload), strings.TrimSpace(gjson.Get(payload, "type").String())) usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload) if u := extractCCStreamUsage(payload); u != nil { usage = *u @@ -356,16 +361,18 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ReasoningEffort: reasoningEffort, + ServiceTier: serviceTier, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } @@ -425,6 +432,11 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( } return nil, fmt.Errorf("read upstream body: %w", err) } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveOpenAI(respBody, strings.TrimSpace(gjson.GetBytes(respBody, "type").String())) var usage OpenAIUsage if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { @@ -443,15 +455,17 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( _, _ = c.Writer.Write(respBody) return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ReasoningEffort: reasoningEffort, + ServiceTier: serviceTier, + Stream: false, + Duration: time.Since(startTime), }, nil } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index bb13dd5989..f32cef7474 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -19,6 +19,7 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { + beginUpstreamResponseModelObservation(c) clearGrokResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() @@ -974,18 +975,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } forwardResult := &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - ResponseID: responseID, - Usage: *usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ServiceTier: serviceTier, - ReasoningEffort: reasoningEffort, - Stream: reqStream, - OpenAIWSMode: false, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + ResponseID: responseID, + Usage: *usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ServiceTier: serviceTier, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + OpenAIWSMode: false, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } if imageCount > 0 { forwardResult.ImageCount = imageCount diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 066fc82cec..08958f87b6 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -34,6 +34,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + beginUpstreamResponseModelObservation(c) + // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 @@ -549,6 +551,11 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event") return nil, fmt.Errorf("upstream stream ended without terminal event") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.Observe(finalResponse.Model, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) @@ -602,14 +609,16 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( c.JSON(http.StatusOK, anthropicResp) return &OpenAIForwardResult{ - RequestID: requestID, - ResponseID: finalResponse.ID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + ResponseID: finalResponse.ID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: false, + Duration: time.Since(startTime), }, nil } @@ -846,20 +855,26 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( if intervalTicker != nil { intervalCh = intervalTicker.C } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } // resultWithUsage builds the final result snapshot. resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ - RequestID: requestID, - ResponseID: responseID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnected, + RequestID: requestID, + ResponseID: responseID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, } } @@ -879,6 +894,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( ) return false } + observer.ObserveOpenAI([]byte(payload), event.Type) eventType := strings.TrimSpace(event.Type) isBareErrorEvent := eventType == "error" diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index b1a3d189cf..4aaeaf6ade 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -278,17 +278,19 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } forwardResult := &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - ResponseID: responseID, - Usage: *usage, - Model: reqModel, - UpstreamModel: upstreamPassthroughModel, - ServiceTier: serviceTier, - ReasoningEffort: reasoningEffort, - Stream: reqStream, - OpenAIWSMode: false, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + ResponseID: responseID, + Usage: *usage, + Model: reqModel, + UpstreamModel: upstreamPassthroughModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ServiceTier: serviceTier, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + OpenAIWSMode: false, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } if imageCount > 0 { forwardResult.ImageCount = imageCount @@ -1051,6 +1053,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( originalModel string, mappedModel string, ) (*openaiStreamingResultPassthrough, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) // SSE headers @@ -1131,6 +1137,8 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if data, ok := extractOpenAISSEDataLine(line); ok { dataBytes := []byte(data) trimmedData := strings.TrimSpace(data) + rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + observer.ObserveOpenAI(dataBytes, rawEventType) if needModelReplace && strings.Contains(data, mappedModel) { line = s.replaceModelInSSELine(line, mappedModel, originalModel) if replacedData, replaced := extractOpenAISSEDataLine(line); replaced { @@ -1317,6 +1325,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if bodyHasSSEFraming(body) { + observeOpenAISSEBody(observer, string(body)) + } else { + observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String())) + } // Detect SSE responses from upstream and convert to JSON. // Some upstreams (e.g. other sub2api instances) may return SSE even when diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 2a1993c463..9b833b750b 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -43,6 +43,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp } func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel, reasoningEffort string) (*openaiStreamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } firstOutputTimeout := time.Duration(0) if account != nil && account.Platform == PlatformOpenAI { firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort) @@ -406,6 +410,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. dataBytes := []byte(data) eventTypeRaw := gjson.GetBytes(dataBytes, "type").String() eventType := strings.TrimSpace(eventTypeRaw) + observer.ObserveOpenAI(dataBytes, eventTypeRaw) // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) { sawTerminalEvent = true @@ -1110,6 +1115,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if bodyHasSSEFraming(body) { + observeOpenAISSEBody(observer, string(body)) + } else { + observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String())) + } // Detect SSE responses for ALL account types via Content-Type header. // Some OpenAI-compatible upstreams (including other sub2api instances) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 135f63aa81..b875d89237 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -243,6 +243,10 @@ type OpenAIForwardResult struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Empty when no mapping was applied (requested model was used as-is). UpstreamModel string + // UpstreamResponseModel is captured from the raw successful upstream + // response before any client-facing rewrite or protocol conversion. + UpstreamResponseModel string + UpstreamResponseModelConflict bool // UpstreamEndpoint is the actual upstream API path used for this request. // It avoids guessing when one downstream protocol can use multiple upstream endpoints. UpstreamEndpoint string diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 4ec35804c7..f9865501a9 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3456,6 +3456,32 @@ func TestHandleNonStreamingResponse_OAuthJSONBodyWithDataEventTextKeepsJSONUsage require.Contains(t, rec.Body.String(), "processing data: 1,2,3 then event: click finished") } +func TestHandleNonStreamingResponse_ObservesUpstreamModelBeforeClientRewrite(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_model_audit","object":"response","model":"gpt-5.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}`, + )), + } + account := &Account{ID: 1, Type: AccountTypeAPIKey} + + result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "gpt-5.6-sol", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + + // 客户端仍看到自己请求的模型名,审计观察器则保留改写前的上游声明。 + require.Equal(t, "gpt-5.6-sol", gjson.Get(rec.Body.String(), "model").String()) + require.Equal(t, "gpt-5.5", observedUpstreamResponseModel(c)) + require.False(t, observedUpstreamResponseModelConflict(c)) +} + func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 0725c34cf7..642a021ffa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -263,31 +263,43 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if input.OriginalModel != "" { requestedModel = input.OriginalModel } + sentModel := upstreamSentModel(result.Model, result.UpstreamModel) + if result.UpstreamResponseModelConflict { + logger.L().Warn("upstream_response_model_conflict", + zap.String("platform", account.Platform), + zap.Int64("account_id", account.ID), + zap.String("request_id", requestID), + zap.String("sent_model", sentModel), + zap.String("selected_response_model", strings.TrimSpace(result.UpstreamResponseModel)), + ) + } usageLog := &UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, - RequestID: requestID, - Model: result.Model, - RequestedModel: requestedModel, - UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), - ServiceTier: result.ServiceTier, - ReasoningEffort: result.ReasoningEffort, - InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), - UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), - InputTokens: actualInputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - ImageInputTokens: result.Usage.ImageInputTokens, - 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, + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: requestID, + Model: result.Model, + RequestedModel: requestedModel, + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), + UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), + UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, + ReasoningEffort: result.ReasoningEffort, + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), + InputTokens: actualInputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageInputTokens: result.Usage.ImageInputTokens, + 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, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) if isVideoUsage { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 400ae714b8..cf579d5cee 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -785,6 +785,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { + responseModelObserver := &upstreamResponseModelObserver{} if lease == nil { return nil, errors.New("upstream websocket lease is nil") } @@ -852,6 +853,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) + responseModelObserver.ObserveOpenAI(upstreamMessage, eventType) if responseID == "" && eventResponseID != "" { responseID = eventResponseID } @@ -1026,18 +1028,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(payload), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: terminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ServiceTier: extractOpenAIServiceTierFromBody(payload), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: terminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 62aee29c20..cebca5993c 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -36,6 +36,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if s == nil || account == nil { return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) } + responseModelObserver := &upstreamResponseModelObserver{} wsURL, err := s.buildOpenAIResponsesWSURL(account) if err != nil { @@ -512,6 +513,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if eventType == "" { continue } + responseModelObserver.ObserveOpenAI(message, eventType) eventCount++ if firstEventType == "" { firstEventType = eventType @@ -748,20 +750,22 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ) return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ImageCount: imageCounter.Count(), + ImageOutputSizes: imageCounter.Sizes(), + ServiceTier: extractOpenAIServiceTier(reqBody), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index b2c7c67f64..ccef029188 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -180,6 +180,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if writeClientMessage == nil { return nil, errors.New("client websocket writer is nil") } + responseModelObserver := &upstreamResponseModelObserver{} body, err := prepareOpenAIWSHTTPBridgeBody(payload) if err != nil { @@ -299,18 +300,20 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( resultWithUsage := func() *OpenAIForwardResult { imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(body), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: cloneHeader(resp.Header), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ServiceTier: extractOpenAIServiceTierFromBody(body), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: cloneHeader(resp.Header), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput @@ -355,6 +358,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( upstreamMessage = normalized } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) + responseModelObserver.ObserveOpenAI(upstreamMessage, eventType) if responseID == "" && eventResponseID != "" { responseID = eventResponseID } diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index b6936fe994..b3fe6d3084 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -31,6 +31,8 @@ type Usage struct { type RelayResult struct { RequestModel string + ResponseModel string + ResponseModelConflict bool Usage Usage RequestID string TerminalEventType string @@ -42,12 +44,14 @@ type RelayResult struct { } type RelayTurnResult struct { - RequestModel string - Usage Usage - RequestID string - TerminalEventType string - Duration time.Duration - FirstTokenMs *int + RequestModel string + ResponseModel string + ResponseModelConflict bool + Usage Usage + RequestID string + TerminalEventType string + Duration time.Duration + FirstTokenMs *int } type RelayExit struct { @@ -90,6 +94,8 @@ type relayState struct { requestModelMu sync.RWMutex requestModel string lastResponseID string + lastResponseModel string + responseConflict bool terminalEventType string firstTokenMs *int turnTimingByID map[string]*relayTurnTiming @@ -104,17 +110,22 @@ type relayExitSignal struct { } type observedUpstreamEvent struct { - terminal bool - eventType string - responseID string - usage Usage - duration time.Duration - firstToken *int + terminal bool + eventType string + responseID string + usage Usage + responseModel string + responseConflict bool + duration time.Duration + firstToken *int } type relayTurnTiming struct { - startAt time.Time - firstTokenMs *int + startAt time.Time + firstTokenMs *int + firstResponseModel string + terminalResponseModel string + responseModelConflict bool } func Relay( @@ -684,15 +695,19 @@ func observeUpstreamMessage( responseID: responseID, usage: parsedUsage, } + var turnTiming *relayTurnTiming if responseID != "" { - turnTiming := openAIWSRelayGetOrInitTurnTiming(state, responseID, now) + turnTiming = openAIWSRelayGetOrInitTurnTiming(state, responseID, now) if turnTiming != nil && turnTiming.firstTokenMs == nil && isTokenEvent(eventType) { ms := int(now.Sub(turnTiming.startAt).Milliseconds()) if ms >= 0 { turnTiming.firstTokenMs = &ms } } + } else { + turnTiming = state.activeTurn } + observeRelayTurnResponseModel(turnTiming, firstRelayResponseModel(message), isTerminalEvent(eventType)) if !isTerminalEvent(eventType) { return observed } @@ -701,6 +716,10 @@ func observeUpstreamMessage( if responseID != "" { state.lastResponseID = responseID if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok { + observed.responseModel = relayTurnResponseModel(&turnTiming) + observed.responseConflict = turnTiming.responseModelConflict + state.lastResponseModel = observed.responseModel + state.responseConflict = observed.responseConflict duration := now.Sub(turnTiming.startAt) if duration < 0 { duration = 0 @@ -729,15 +748,64 @@ func emitTurnComplete( requestModel = state.currentRequestModel() } onTurnComplete(RelayTurnResult{ - RequestModel: requestModel, - Usage: observed.usage, - RequestID: responseID, - TerminalEventType: observed.eventType, - Duration: observed.duration, - FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), + RequestModel: requestModel, + ResponseModel: observed.responseModel, + ResponseModelConflict: observed.responseConflict, + Usage: observed.usage, + RequestID: responseID, + TerminalEventType: observed.eventType, + Duration: observed.duration, + FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), }) } +func firstRelayResponseModel(message []byte) string { + if len(message) == 0 { + return "" + } + values := gjson.GetManyBytes(message, "response.model", "model") + for _, value := range values { + if value.Type != gjson.String { + continue + } + if model := strings.TrimSpace(value.String()); model != "" { + return model + } + } + return "" +} + +func observeRelayTurnResponseModel(turn *relayTurnTiming, model string, terminal bool) { + if turn == nil { + return + } + model = strings.TrimSpace(model) + if model == "" { + return + } + current := relayTurnResponseModel(turn) + if current != "" && !strings.EqualFold(current, model) { + turn.responseModelConflict = true + } + if terminal { + turn.terminalResponseModel = model + return + } + if turn.firstResponseModel == "" { + turn.firstResponseModel = model + } +} + +func relayTurnResponseModel(turn *relayTurnTiming) string { + if turn == nil { + return "" + } + if turn.terminalResponseModel != "" { + return turn.terminalResponseModel + } + return turn.firstResponseModel +} + func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming { if state == nil { return nil @@ -888,6 +956,8 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration return } result.RequestModel = state.currentRequestModel() + result.ResponseModel = state.lastResponseModel + result.ResponseModelConflict = state.responseConflict result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index 6036be7451..ed403e3448 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -457,6 +457,60 @@ func TestRelayTurnTimingHelpersCoverage(t *testing.T) { require.False(t, ok) } +func TestObserveUpstreamMessage_ResponseModelIsTurnLocalAndTerminalWins(t *testing.T) { + t.Parallel() + + state := &relayState{requestModel: "gpt-5.6-sol"} + startAt := time.Unix(0, 0) + now := startAt + nowFn := func() time.Time { + now = now.Add(5 * time.Millisecond) + return now + } + + created := observeUpstreamMessage( + state, + []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.5"}}`), + startAt, + nowFn, + nil, + ) + require.False(t, created.terminal) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":2}}}`), + startAt, + nowFn, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, "gpt-5.4", completed.responseModel) + require.True(t, completed.responseConflict) + + var firstTurn RelayTurnResult + emitTurnComplete(func(turn RelayTurnResult) { firstTurn = turn }, state, completed) + require.Equal(t, "gpt-5.4", firstTurn.ResponseModel) + require.True(t, firstTurn.ResponseModelConflict) + + observeUpstreamMessage( + state, + []byte(`{"type":"response.created","response":{"id":"resp_2","model":"gpt-5.3"}}`), + startAt, + nowFn, + nil, + ) + second := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_2","model":"GPT-5.3","usage":{"input_tokens":3,"output_tokens":4}}}`), + startAt, + nowFn, + nil, + ) + require.Equal(t, "GPT-5.3", second.responseModel) + require.False(t, second.responseConflict, "the previous turn must not contaminate this turn") +} + func TestObserveUpstreamMessage_ResponseIDFallbackPolicy(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index ae74027bca..483ea8e94c 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1103,16 +1103,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: turn.Usage.CacheReadInputTokens, ImageOutputTokens: turn.Usage.ImageOutputTokens, }, - Model: turnRequestModel, - UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: turn.Duration, - FirstTokenMs: turn.FirstTokenMs, + Model: turnRequestModel, + UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), + UpstreamResponseModel: turn.ResponseModel, + UpstreamResponseModelConflict: turn.ResponseModelConflict, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: turn.Duration, + FirstTokenMs: turn.FirstTokenMs, } logOpenAIWSV2Passthrough( "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s turn_requested_model=%s turn_upstream_model=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", @@ -1222,16 +1224,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, ImageOutputTokens: relayResult.Usage.ImageOutputTokens, }, - Model: resultRequestModel, - UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: relayResult.Duration, - FirstTokenMs: relayResult.FirstTokenMs, + Model: resultRequestModel, + UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), + UpstreamResponseModel: relayResult.ResponseModel, + UpstreamResponseModelConflict: relayResult.ResponseModelConflict, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: relayResult.Duration, + FirstTokenMs: relayResult.FirstTokenMs, } turnCount := int(completedTurns.Load()) diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go new file mode 100644 index 0000000000..2452c382d0 --- /dev/null +++ b/backend/internal/service/upstream_response_model.go @@ -0,0 +1,179 @@ +package service + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +const ( + upstreamResponseModelObserverContextKey = "upstream_response_model_observer" + upstreamResponseModelMaxLength = 200 +) + +// upstreamResponseModelObserver tracks one forwarding attempt (or one WS turn). +// A terminal declaration wins over an earlier declaration; otherwise the first +// declaration is retained. Conflicts are diagnostic only and never affect the +// forwarding or billing path. +type upstreamResponseModelObserver struct { + first string + terminal string + conflict bool +} + +func (o *upstreamResponseModelObserver) Observe(model string, terminal bool) { + model = normalizeObservedUpstreamResponseModel(model) + if model == "" { + return + } + current := o.Model() + if current != "" && !strings.EqualFold(current, model) { + o.conflict = true + } + if terminal { + o.terminal = model + return + } + if o.first == "" { + o.first = model + } +} + +func normalizeObservedUpstreamResponseModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + runes := []rune(model) + if len(runes) > upstreamResponseModelMaxLength { + model = string(runes[:upstreamResponseModelMaxLength]) + } + return model +} + +func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return + } + model := firstTrimmedGJSONModel( + gjson.GetBytes(payload, "response.model"), + gjson.GetBytes(payload, "model"), + ) + o.Observe(model, isUpstreamResponseModelTerminalEvent(eventType)) +} + +func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return + } + model := firstTrimmedGJSONModel( + gjson.GetBytes(payload, "message.model"), + gjson.GetBytes(payload, "model"), + ) + o.Observe(model, false) +} + +func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return + } + model := firstTrimmedGJSONModel( + gjson.GetBytes(payload, "modelVersion"), + gjson.GetBytes(payload, "response.modelVersion"), + ) + // Gemini streaming has no universal terminal event carrying modelVersion; + // treating each declaration as terminal retains the latest chunk. + o.Observe(model, true) +} + +func (o *upstreamResponseModelObserver) Model() string { + if o == nil { + return "" + } + if o.terminal != "" { + return o.terminal + } + return o.first +} + +func (o *upstreamResponseModelObserver) Conflict() bool { + return o != nil && o.conflict +} + +func beginUpstreamResponseModelObservation(c *gin.Context) *upstreamResponseModelObserver { + observer := &upstreamResponseModelObserver{} + if c != nil { + c.Set(upstreamResponseModelObserverContextKey, observer) + } + return observer +} + +func upstreamResponseModelObserverFromContext(c *gin.Context) *upstreamResponseModelObserver { + if c == nil { + return nil + } + value, ok := c.Get(upstreamResponseModelObserverContextKey) + if !ok { + return nil + } + observer, _ := value.(*upstreamResponseModelObserver) + return observer +} + +func observedUpstreamResponseModel(c *gin.Context) string { + return upstreamResponseModelObserverFromContext(c).Model() +} + +func observedUpstreamResponseModelConflict(c *gin.Context) bool { + return upstreamResponseModelObserverFromContext(c).Conflict() +} + +func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) { + if observer == nil || strings.TrimSpace(body) == "" { + return + } + forEachOpenAISSEDataPayload(body, func(payload []byte) { + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + observer.ObserveOpenAI(payload, eventType) + }) +} + +func firstTrimmedGJSONModel(values ...gjson.Result) string { + for _, value := range values { + if !value.Exists() || value.Type != gjson.String { + continue + } + if model := strings.TrimSpace(value.String()); model != "" { + return model + } + } + return "" +} + +func isUpstreamResponseModelTerminalEvent(eventType string) bool { + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + return true + default: + return false + } +} + +func upstreamModelMismatch(sentModel, responseModel string) *bool { + responseModel = strings.TrimSpace(responseModel) + if responseModel == "" { + return nil + } + sentModel = strings.TrimSpace(sentModel) + mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel) + return &mismatch +} + +func upstreamSentModel(requestedModel, upstreamModel string) string { + sentModel := strings.TrimSpace(upstreamModel) + if sentModel == "" { + sentModel = strings.TrimSpace(requestedModel) + } + return sentModel +} diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go new file mode 100644 index 0000000000..37d382e851 --- /dev/null +++ b/backend/internal/service/upstream_response_model_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestUpstreamResponseModelObserverTerminalWinsAndRecordsConflict(t *testing.T) { + observer := &upstreamResponseModelObserver{} + + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5"}}`), "response.created") + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.4"}}`), "response.completed") + + require.Equal(t, "gpt-5.4", observer.Model()) + require.True(t, observer.Conflict()) +} + +func TestUpstreamResponseModelObserverSupportsAnthropicAndGeminiShapes(t *testing.T) { + t.Run("anthropic", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"model":"claude-sonnet-4-20250514"}}`)) + require.Equal(t, "claude-sonnet-4-20250514", observer.Model()) + }) + + t.Run("gemini outer and nested", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveGemini([]byte(`{"response":{"modelVersion":"gemini-2.5-pro"}}`)) + observer.ObserveGemini([]byte(`{"modelVersion":"gemini-2.5-pro-latest"}`)) + require.Equal(t, "gemini-2.5-pro-latest", observer.Model()) + require.True(t, observer.Conflict()) + }) +} + +func TestUpstreamResponseModelObservationAttemptReset(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + + first := beginUpstreamResponseModelObservation(c) + first.Observe("failed-attempt-model", false) + second := beginUpstreamResponseModelObservation(c) + second.Observe("successful-attempt-model", false) + + require.Equal(t, "successful-attempt-model", observedUpstreamResponseModel(c)) + require.False(t, observedUpstreamResponseModelConflict(c)) +} + +func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing.T) { + require.Nil(t, upstreamModelMismatch("gpt-5.5", "")) + + matched := upstreamModelMismatch("gpt-5.5", "GPT-5.5") + require.NotNil(t, matched) + require.False(t, *matched) + + mismatched := upstreamModelMismatch("gpt-5.5", "gpt-5.4") + require.NotNil(t, mismatched) + require.True(t, *mismatched) +} + +func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n") + + require.Equal(t, "gpt-5.4", observer.Model()) + require.False(t, observer.Conflict()) +} + +func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.Observe(" "+strings.Repeat("模", upstreamResponseModelMaxLength+1)+" ", false) + + require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index b35d149ea8..7a41555ece 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -114,6 +114,12 @@ type UsageLog struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Nil means no mapping was applied (requested model was used as-is). UpstreamModel *string + // UpstreamResponseModel is the model declared by the successful upstream + // response before client-facing model rewrites or protocol conversion. + UpstreamResponseModel *string + // UpstreamModelMismatch is nil when no upstream model was observed. Otherwise + // it compares UpstreamResponseModel with the actual model sent upstream. + UpstreamModelMismatch *bool // ChannelID 渠道 ID ChannelID *int64 // ModelMappingChain 模型映射链,如 "a→b→c" diff --git a/backend/migrations/194_add_usage_log_upstream_response_model.sql b/backend/migrations/194_add_usage_log_upstream_response_model.sql new file mode 100644 index 0000000000..a5865aca17 --- /dev/null +++ b/backend/migrations/194_add_usage_log_upstream_response_model.sql @@ -0,0 +1,3 @@ +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS upstream_response_model VARCHAR(200), + ADD COLUMN IF NOT EXISTS upstream_model_mismatch BOOLEAN; diff --git a/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql new file mode 100644 index 0000000000..811ca8786c --- /dev/null +++ b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql @@ -0,0 +1,3 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at + ON usage_logs (created_at DESC, id DESC) + WHERE upstream_model_mismatch IS TRUE; diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index ae20d33f8e..5117039f61 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -56,6 +56,7 @@ export interface TrendParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface TrendResponse { @@ -87,6 +88,7 @@ export interface ModelStatsParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface ModelStatsResponse { @@ -115,6 +117,7 @@ export interface GroupStatsParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface GroupStatsResponse { diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 83be033c11..1c740caabe 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams { user_id?: number exact_total?: boolean billing_mode?: string + upstream_model_mismatch?: boolean sort_by?: string sort_order?: 'asc' | 'desc' // 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象) @@ -123,6 +124,7 @@ export async function getStats(params: { model?: string request_type?: UsageRequestType stream?: boolean + upstream_model_mismatch?: boolean period?: string start_date?: string end_date?: string diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 0965567cec..1d52eb9477 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -139,6 +139,11 @@ +
+
@@ -307,6 +312,12 @@ const billingModeOptions = ref([ { value: 'video', label: t('admin.usage.billingModeVideo') } ]) +const upstreamModelMismatchOptions = ref([ + { value: null, label: t('admin.usage.allUpstreamModelAudit') }, + { value: true, label: t('admin.usage.upstreamModelMismatchOnly') }, + { value: false, label: t('admin.usage.upstreamModelMatchedOnly') } +]) + const emitChange = () => emit('change') const clearPendingUserSearch = () => { diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 032ecc0bd2..f0301d6db6 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -53,23 +53,41 @@