@@ -1036,9 +1038,9 @@ interface GrokQuotaBarInfo {
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
- const used = Math.max(0, quota.limit - quota.remaining)
+ const remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
return {
- utilization: (used / quota.limit) * 100,
+ utilization: (remaining / quota.limit) * 100,
resetsAt: quota.reset_at || null
}
}
diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue
index 6a69357318..2f8b9a88a7 100644
--- a/frontend/src/components/account/UsageProgressBar.vue
+++ b/frontend/src/components/account/UsageProgressBar.vue
@@ -69,6 +69,7 @@ const props = defineProps<{
color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null
showNowWhenIdle?: boolean
+ remainingCapacity?: boolean
}>()
const { t } = useI18n()
@@ -109,6 +110,14 @@ const labelClass = computed(() => {
// Progress bar color based on utilization
const barClass = computed(() => {
+ if (props.remainingCapacity) {
+ if (props.utilization <= 20) {
+ return 'bg-red-500'
+ } else if (props.utilization <= 50) {
+ return 'bg-amber-500'
+ }
+ return 'bg-green-500'
+ }
if (props.utilization >= 100) {
return 'bg-red-500'
} else if (props.utilization >= 80) {
@@ -120,6 +129,14 @@ const barClass = computed(() => {
// Text color based on utilization
const textClass = computed(() => {
+ if (props.remainingCapacity) {
+ if (props.utilization <= 20) {
+ return 'text-red-600 dark:text-red-400'
+ } else if (props.utilization <= 50) {
+ return 'text-amber-600 dark:text-amber-400'
+ }
+ return 'text-gray-600 dark:text-gray-400'
+ }
if (props.utilization >= 100) {
return 'text-red-600 dark:text-red-400'
} else if (props.utilization >= 80) {
@@ -131,12 +148,16 @@ const textClass = computed(() => {
// Bar width (capped at 100%)
const barWidth = computed(() => {
- return `${Math.min(props.utilization, 100)}%`
+ return `${Math.min(Math.max(props.utilization, 0), 100)}%`
})
// Display percentage (cap at 999% for readability)
const displayPercent = computed(() => {
- const percent = Math.round(props.utilization)
+ const percent = Math.round(
+ props.remainingCapacity
+ ? Math.min(Math.max(props.utilization, 0), 100)
+ : props.utilization
+ )
return percent > 999 ? '>999%' : `${percent}%`
})
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
index 2df3cc07e2..2abf6513ca 100644
--- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
@@ -566,7 +566,7 @@ describe('AccountUsageCell', () => {
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
- it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => {
+ it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
getUsage.mockResolvedValue({
grok_local_usage: {
requests: 4,
@@ -611,13 +611,55 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('1.2K')
expect(wrapper.text()).toContain('A $0.12')
expect(wrapper.text()).toContain('U $0.34')
- expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
+ it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
+ getUsage.mockResolvedValue({
+ grok_request_quota: {
+ limit: 100,
+ remaining: 100,
+ reset_at: '2026-07-09T16:00:00Z'
+ },
+ grok_token_quota: {
+ limit: 1000,
+ remaining: 250,
+ reset_at: '2026-07-09T16:00:00Z'
+ },
+ grok_quota_snapshot_state: 'observed'
+ })
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 4073,
+ platform: 'grok',
+ type: 'oauth',
+ extra: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: {
+ props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
+ template: '
{{ label }}|{{ utilization }}|{{ remainingCapacity }}
'
+ },
+ AccountQuotaInfo: true,
+ GrokQuotaProbeCell: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
+ })
+
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
index 6fa6575f54..af5fc5d66d 100644
--- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
+++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
@@ -96,4 +96,54 @@ describe('UsageProgressBar', () => {
expect(wrapper.text()).toContain('usage.resetNow')
expect(wrapper.text()).not.toContain('usage.resetPending')
})
+
+ it('剩余容量模式在 100% 时显示满格绿色', () => {
+ const wrapper = mount(UsageProgressBar, {
+ props: {
+ label: 'Req',
+ utilization: 100,
+ remainingCapacity: true,
+ color: 'indigo'
+ }
+ })
+
+ expect(wrapper.text()).toContain('100%')
+ expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
+ expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-green-500')
+ })
+
+ it('剩余容量模式在低量和耗尽时缩短并变红', async () => {
+ const wrapper = mount(UsageProgressBar, {
+ props: {
+ label: 'Req',
+ utilization: 15,
+ remainingCapacity: true,
+ color: 'indigo'
+ }
+ })
+
+ expect(wrapper.text()).toContain('15%')
+ expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 15%')
+ expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
+
+ await wrapper.setProps({ utilization: 0 })
+
+ expect(wrapper.text()).toContain('0%')
+ expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 0%')
+ expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
+ })
+
+ it('默认利用率模式仍把超限显示为满格红色', () => {
+ const wrapper = mount(UsageProgressBar, {
+ props: {
+ label: '5h',
+ utilization: 120,
+ color: 'indigo'
+ }
+ })
+
+ expect(wrapper.text()).toContain('120%')
+ expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
+ expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
+ })
})
From f187f08ae366a52b2f95d6317e542f9a87fd1559 Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Sun, 12 Jul 2026 11:39:54 +0800
Subject: [PATCH 19/27] fix(grok): harden OAuth routing and CLI version guard
---
backend/go.mod | 2 +-
backend/internal/repository/http_upstream.go | 14 ++--
.../internal/repository/http_upstream_test.go | 41 ++++++++++++
backend/internal/service/account.go | 26 +++++++-
.../internal/service/account_base_url_test.go | 66 +++++++++++++++++++
5 files changed, 142 insertions(+), 7 deletions(-)
diff --git a/backend/go.mod b/backend/go.mod
index a06f06437d..64b5c30d96 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -44,6 +44,7 @@ require (
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.51.0
golang.org/x/image v0.39.0
+ golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
@@ -176,7 +177,6 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go
index ace6fc1a8c..bb079b0789 100644
--- a/backend/internal/repository/http_upstream.go
+++ b/backend/internal/repository/http_upstream.go
@@ -14,7 +14,6 @@ import (
"net/http"
"net/url"
"os"
- "regexp"
"strings"
"sync"
"sync/atomic"
@@ -29,6 +28,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
+ "golang.org/x/mod/semver"
)
// 默认配置常量
@@ -68,8 +68,6 @@ const (
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
)
-var grokCLIVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$`)
-
const (
upstreamProtocolModeDefault = "default"
upstreamProtocolModeOpenAIH1 = "openai_h1"
@@ -275,7 +273,7 @@ func applyGrokCLIProxyHeaders(req *http.Request) {
req.Header = make(http.Header)
}
version := strings.TrimSpace(os.Getenv(grokCLIVersionOverride))
- if !grokCLIVersionPattern.MatchString(version) {
+ if !isSupportedGrokCLIVersion(version) {
version = grokCLIStableVersion
}
req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
@@ -283,6 +281,14 @@ func applyGrokCLIProxyHeaders(req *http.Request) {
req.Header.Set("User-Agent", "xai-grok-workspace/"+version)
}
+func isSupportedGrokCLIVersion(version string) bool {
+ canonical := "v" + version
+ minimum := "v" + grokCLIStableVersion
+ return semver.IsValid(canonical) &&
+ semver.Canonical(canonical) == canonical &&
+ semver.Compare(canonical, minimum) >= 0
+}
+
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, upstreamProfile service.HTTPUpstreamProfile) (*upstreamClientEntry, error) {
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, upstreamProfile, true, true)
diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go
index a632dd662f..90cbd15ae3 100644
--- a/backend/internal/repository/http_upstream_test.go
+++ b/backend/internal/repository/http_upstream_test.go
@@ -51,6 +51,47 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
require.Empty(t, req.Header.Get("X-Injected"))
})
+ t.Run("rejects an override below the supported minimum", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.92")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+
+ t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+
+ for _, version := range []string{
+ "0.2.093",
+ "0.2.94-alpha..1",
+ "0.3",
+ "1",
+ "0.2.95+build.1",
+ } {
+ t.Run("rejects invalid semver "+version, func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", version)
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+ }
+
t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95")
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 52411d4c6c..62cb5a2e66 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -6,6 +6,7 @@ import (
"errors"
"hash/fnv"
"log/slog"
+ "net/url"
"reflect"
"sort"
"strconv"
@@ -1256,8 +1257,7 @@ func (a *Account) GetGrokBaseURL() string {
}
baseURL := a.GetCredential("base_url")
if a.IsGrokOAuth() {
- normalizedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/")
- if normalizedBaseURL == "" || strings.EqualFold(normalizedBaseURL, xai.DefaultBaseURL) {
+ if strings.TrimSpace(baseURL) == "" || isOfficialGrokAPIBaseURL(baseURL) {
return xai.DefaultCLIBaseURL
}
}
@@ -1267,6 +1267,28 @@ func (a *Account) GetGrokBaseURL() string {
return xai.DefaultBaseURL
}
+func isOfficialGrokAPIBaseURL(raw string) bool {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return false
+ }
+ defaultURL, err := url.Parse(xai.DefaultBaseURL)
+ if err != nil {
+ return false
+ }
+ if !strings.EqualFold(parsed.Scheme, defaultURL.Scheme) || !strings.EqualFold(parsed.Hostname(), defaultURL.Hostname()) {
+ return false
+ }
+ if port := parsed.Port(); port != "" {
+ portNumber, err := strconv.Atoi(port)
+ if err != nil || portNumber != 443 {
+ return false
+ }
+ }
+ path := strings.TrimRight(parsed.Path, "/")
+ return path == "" || path == strings.TrimRight(defaultURL.Path, "/")
+}
+
func (a *Account) GetGrokAccessToken() string {
if !a.IsGrok() {
return ""
diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go
index cf3655dbb8..0ffaa21ae4 100644
--- a/backend/internal/service/account_base_url_test.go
+++ b/backend/internal/service/account_base_url_test.go
@@ -199,6 +199,72 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
},
expected: xai.DefaultCLIBaseURL,
},
+ {
+ name: "oauth legacy API root is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API root with canonical HTTPS port is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "HTTPS://API.X.AI:443/",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API canonical port with leading zeroes is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai:0443/v1",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API encoded version path is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai/%76%31",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API encoded trailing slash is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai/v1%2F",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth non-default API port remains an explicit override",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai:8443/v1",
+ },
+ },
+ expected: "https://api.x.ai:8443/v1",
+ },
{
name: "oauth explicit custom base_url remains supported",
account: Account{
From ce3f12bbffbf6d4423c6b3f419b2d52726e8c28b Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Sun, 12 Jul 2026 12:18:12 +0800
Subject: [PATCH 20/27] test(grok): cover CLI identity at transport boundary
---
.../internal/repository/http_upstream_test.go | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go
index 90cbd15ae3..d34e1df02c 100644
--- a/backend/internal/repository/http_upstream_test.go
+++ b/backend/internal/repository/http_upstream_test.go
@@ -15,6 +15,51 @@ import (
"github.com/stretchr/testify/suite"
)
+func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeRoundTrip(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "")
+
+ upstream := NewHTTPUpstream(nil)
+ svc, ok := upstream.(*httpUpstreamService)
+ require.True(t, ok)
+
+ const accountID int64 = 4079
+ isolation := svc.getIsolationMode()
+ profile := service.HTTPUpstreamProfileDefault
+ proxyKey := directProxyKey
+ protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
+ settings := svc.resolvePoolSettings(isolation, 1)
+ settings = svc.applyProfilePoolSettings(settings, profile)
+ cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
+
+ var capturedHeaders http.Header
+ svc.clients[cacheKey] = &upstreamClientEntry{
+ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ capturedHeaders = req.Header.Clone()
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: http.NoBody,
+ Request: req,
+ }, nil
+ })},
+ proxyKey: proxyKey,
+ poolKey: buildPoolKey(settings, protocolMode),
+ protocolMode: protocolMode,
+ }
+
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+ req.Header.Set("User-Agent", "sub2api-grok/1.0")
+
+ resp, err := svc.Do(req, "", accountID, 1)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent"))
+}
+
func TestApplyGrokCLIProxyHeaders(t *testing.T) {
t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "")
From c4ff604e9327c2a06c9b3a5c9549a2128cd06c0d Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Sun, 12 Jul 2026 13:14:06 +0800
Subject: [PATCH 21/27] test(grok): cover OAuth chat permission identity
---
.../internal/repository/http_upstream_test.go | 85 ++++++++++---------
1 file changed, 47 insertions(+), 38 deletions(-)
diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go
index d34e1df02c..cb8fe70d5b 100644
--- a/backend/internal/repository/http_upstream_test.go
+++ b/backend/internal/repository/http_upstream_test.go
@@ -15,49 +15,58 @@ import (
"github.com/stretchr/testify/suite"
)
-func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeRoundTrip(t *testing.T) {
+func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "")
- upstream := NewHTTPUpstream(nil)
- svc, ok := upstream.(*httpUpstreamService)
- require.True(t, ok)
+ for _, endpoint := range []string{"responses", "chat/completions"} {
+ t.Run(endpoint, func(t *testing.T) {
+ upstream := NewHTTPUpstream(nil)
+ svc, ok := upstream.(*httpUpstreamService)
+ require.True(t, ok)
- const accountID int64 = 4079
- isolation := svc.getIsolationMode()
- profile := service.HTTPUpstreamProfileDefault
- proxyKey := directProxyKey
- protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
- settings := svc.resolvePoolSettings(isolation, 1)
- settings = svc.applyProfilePoolSettings(settings, profile)
- cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
+ const accountID int64 = 4084
+ isolation := svc.getIsolationMode()
+ profile := service.HTTPUpstreamProfileDefault
+ proxyKey := directProxyKey
+ protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
+ settings := svc.resolvePoolSettings(isolation, 1)
+ settings = svc.applyProfilePoolSettings(settings, profile)
+ cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
- var capturedHeaders http.Header
- svc.clients[cacheKey] = &upstreamClientEntry{
- client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- capturedHeaders = req.Header.Clone()
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: http.NoBody,
- Request: req,
- }, nil
- })},
- proxyKey: proxyKey,
- poolKey: buildPoolKey(settings, protocolMode),
- protocolMode: protocolMode,
+ var capturedHeaders http.Header
+ svc.clients[cacheKey] = &upstreamClientEntry{
+ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ capturedHeaders = req.Header.Clone()
+ statusCode := http.StatusOK
+ if req.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" {
+ statusCode = http.StatusForbidden
+ }
+ return &http.Response{
+ StatusCode: statusCode,
+ Header: make(http.Header),
+ Body: http.NoBody,
+ Request: req,
+ }, nil
+ })},
+ proxyKey: proxyKey,
+ poolKey: buildPoolKey(settings, protocolMode),
+ protocolMode: protocolMode,
+ }
+
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil)
+ require.NoError(t, err)
+ req.Header.Set("User-Agent", "sub2api-grok/1.0")
+
+ resp, err := svc.Do(req, "", accountID, 1)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent"))
+ })
}
-
- req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
- require.NoError(t, err)
- req.Header.Set("User-Agent", "sub2api-grok/1.0")
-
- resp, err := svc.Do(req, "", accountID, 1)
- require.NoError(t, err)
- require.NoError(t, resp.Body.Close())
-
- require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version"))
- require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
- require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent"))
}
func TestApplyGrokCLIProxyHeaders(t *testing.T) {
From aeb34d2003e3db0ba7126a5878539fa0979786b9 Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Sun, 12 Jul 2026 13:45:00 +0800
Subject: [PATCH 22/27] fix(grok): sanitize composer reasoning parameters
---
.../internal/service/openai_gateway_grok.go | 36 +++++++++++++++
.../service/openai_gateway_grok_test.go | 44 +++++++++++++++++++
2 files changed, 80 insertions(+)
diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go
index 502f99f707..379b586136 100644
--- a/backend/internal/service/openai_gateway_grok.go
+++ b/backend/internal/service/openai_gateway_grok.go
@@ -154,6 +154,10 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
if err != nil {
return nil, err
}
+ out, err = sanitizeGrokResponsesModelCapabilities(out, upstreamModel)
+ if err != nil {
+ return nil, err
+ }
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
@@ -187,6 +191,38 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
return out, nil
}
+func sanitizeGrokResponsesModelCapabilities(body []byte, upstreamModel string) ([]byte, error) {
+ if !grokModelRejectsReasoningEffort(upstreamModel) {
+ return body, nil
+ }
+
+ out := body
+ for _, field := range []string{"reasoning", "reasoning_effort", "reasoningEffort"} {
+ if !gjson.GetBytes(out, field).Exists() {
+ continue
+ }
+ var err error
+ out, err = sjson.DeleteBytes(out, field)
+ if err != nil {
+ return nil, fmt.Errorf("remove unsupported Grok Composer %s: %w", field, err)
+ }
+ }
+ return out, nil
+}
+
+func grokModelRejectsReasoningEffort(model string) bool {
+ model = strings.TrimSpace(strings.ToLower(model))
+ if slash := strings.LastIndex(model, "/"); slash >= 0 {
+ model = strings.TrimSpace(model[slash+1:])
+ }
+ switch model {
+ case "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
+ return true
+ default:
+ return false
+ }
+}
+
var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{
"external_web_access": {},
}
diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go
index 6a0942eb72..13020b1c28 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -42,6 +42,50 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi
require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String())
}
+func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ upstreamModel string
+ wantReasoning bool
+ }{
+ {name: "composer fast", upstreamModel: "grok-composer-2.5-fast"},
+ {name: "composer shorthand", upstreamModel: "grok-composer"},
+ {name: "composer legacy alias", upstreamModel: "composer-2.5"},
+ {name: "provider-prefixed composer", upstreamModel: "xai/grok-composer-2.5-fast"},
+ {name: "grok 4.5", upstreamModel: "grok-4.5", wantReasoning: true},
+ }
+
+ body := []byte(`{
+ "model": "grok",
+ "input": "hello",
+ "reasoning": {"effort": "medium", "summary": "auto"},
+ "reasoning_effort": "medium",
+ "reasoningEffort": "medium"
+ }`)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ patched, err := patchGrokResponsesBody(body, tt.upstreamModel)
+ require.NoError(t, err)
+ require.True(t, json.Valid(patched))
+ require.Equal(t, tt.upstreamModel, gjson.GetBytes(patched, "model").String())
+
+ if tt.wantReasoning {
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning.effort").String())
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning_effort").String())
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoningEffort").String())
+ return
+ }
+
+ require.False(t, gjson.GetBytes(patched, "reasoning").Exists())
+ require.False(t, gjson.GetBytes(patched, "reasoning_effort").Exists())
+ require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists())
+ })
+ }
+}
+
func TestExtractGrokResponsesReasoningEffortSupportsOpenAICompatibleField(t *testing.T) {
t.Parallel()
From 8a22dc7347d383b0b8fe3e510dfa246ee721dac2 Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Sun, 12 Jul 2026 21:12:13 +0800
Subject: [PATCH 23/27] fix(grok): diagnose unavailable models by platform
---
backend/internal/handler/no_account_error.go | 28 +++++++++++++++++++
.../internal/handler/no_account_error_test.go | 28 +++++++++++++++++++
.../handler/openai_chat_completions.go | 6 ++--
.../handler/openai_gateway_count_tokens.go | 7 +++--
.../handler/openai_gateway_handler.go | 14 +++++-----
5 files changed, 70 insertions(+), 13 deletions(-)
diff --git a/backend/internal/handler/no_account_error.go b/backend/internal/handler/no_account_error.go
index a3bf3b049e..001cef611d 100644
--- a/backend/internal/handler/no_account_error.go
+++ b/backend/internal/handler/no_account_error.go
@@ -107,3 +107,31 @@ func classifyNoAccountErrorFromGin(
}
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
}
+
+func classifyOpenAICompatibleNoAccountErrorFromGin(
+ c *gin.Context,
+ diag service.ModelAvailabilityDiagnoser,
+ apiKey *service.APIKey,
+ routingModel string,
+ displayModel string,
+) noAccountErrorClassification {
+ return classifyNoAccountErrorFromGin(
+ c,
+ diag,
+ apiKey,
+ routingModel,
+ displayModel,
+ openAICompatibleRequestPlatform(apiKey),
+ )
+}
+
+func openAICompatibleSelectionErrorForLog(err error, platform string) error {
+ if err == nil || platform != service.PlatformGrok {
+ return err
+ }
+ message := strings.ReplaceAll(err.Error(), "OpenAI accounts", "Grok accounts")
+ if message == err.Error() {
+ return err
+ }
+ return fmt.Errorf("%s", message)
+}
diff --git a/backend/internal/handler/no_account_error_test.go b/backend/internal/handler/no_account_error_test.go
index cfe41bb34f..174da82cc7 100644
--- a/backend/internal/handler/no_account_error_test.go
+++ b/backend/internal/handler/no_account_error_test.go
@@ -4,6 +4,7 @@ package handler
import (
"context"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -114,6 +115,33 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
require.Equal(t, int64(42), *fd.calls[0].GroupID)
}
+func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
+ c := newTestGinContextWithRequest()
+ fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
+ groupID := int64(43)
+ apiKey := &service.APIKey{
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ Platform: service.PlatformGrok,
+ },
+ }
+
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5")
+
+ require.Equal(t, http.StatusNotFound, cls.Status)
+ require.Equal(t, "model_not_found", cls.ErrType)
+ require.True(t, cls.ModelNotFound)
+ require.Len(t, fd.calls, 1)
+ require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
+
+ logErr := openAICompatibleSelectionErrorForLog(
+ fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
+ service.PlatformGrok,
+ )
+ require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
+}
+
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 9da5296c0d..636e143740 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -151,11 +151,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai_chat_completions.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -171,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go
index 0461017067..0a010cc176 100644
--- a/backend/internal/handler/openai_gateway_count_tokens.go
+++ b/backend/internal/handler/openai_gateway_count_tokens.go
@@ -115,8 +115,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
if err != nil {
- reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(err))
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)))
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -124,7 +125,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 473200df9a..afa2a5073a 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -351,7 +351,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
@@ -360,7 +360,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted)
return
}
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -375,7 +375,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -855,12 +855,12 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai_messages.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
if err != nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -877,7 +877,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -1456,7 +1456,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai.websocket_account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if lastFailoverErr != nil {
From 64a2a31729537c76d628da854c3556b9c2311756 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 13 Jul 2026 10:14:53 +0800
Subject: [PATCH 24/27] =?UTF-8?q?fix(billing):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=A4=8D=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E4=B8=89=E5=A4=84?=
=?UTF-8?q?=E6=8C=89=E6=AC=A1=E8=AE=A1=E8=B4=B9=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- HIGH: GetByKeyForAuth 的 WithGroup 显式 Select 补 web_search_price_per_call 列,
否则 auth 快照里分组覆盖价恒为 nil,覆盖价/免费配置在实际计费中静默失效
- MEDIUM: 按次搜索倍率改用不含高峰因子的基础倍率(与 image/video 按次不变式
及分组表单价格预览承诺一致),calculateOpenAIRecordUsageCost 新增独立倍率参数
- LOW: 计费门槛从 <400 收紧为严格 2xx,1xx/3xx 透传不计费
---
backend/internal/repository/api_key_repo.go | 1 +
backend/internal/service/openai_alpha_search.go | 4 ++--
.../service/openai_alpha_search_billing_test.go | 9 +++++----
backend/internal/service/openai_gateway_usage.go | 11 +++++++----
4 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go
index a091c3c1d7..ee4ed4785f 100644
--- a/backend/internal/repository/api_key_repo.go
+++ b/backend/internal/repository/api_key_repo.go
@@ -190,6 +190,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldVideoPrice480p,
group.FieldVideoPrice720p,
group.FieldVideoPrice1080p,
+ group.FieldWebSearchPricePerCall,
group.FieldClaudeCodeOnly,
group.FieldFallbackGroupID,
group.FieldFallbackGroupIDOnInvalidRequest,
diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go
index 8275626d6e..50d37f95bb 100644
--- a/backend/internal/service/openai_alpha_search.go
+++ b/backend/internal/service/openai_alpha_search.go
@@ -89,8 +89,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
contentType = "application/json"
}
c.Data(resp.StatusCode, contentType, respBody)
- if resp.StatusCode >= http.StatusBadRequest {
- // 上游错误已原样透传给客户端:不是一次成功的搜索,不计费。
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ // 非 2xx(错误/重定向)已原样透传给客户端:不是一次成功的搜索,不计费。
return nil, nil
}
return &OpenAIForwardResult{
diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go
index 3c5fb6cc81..1251ee43f9 100644
--- a/backend/internal/service/openai_alpha_search_billing_test.go
+++ b/backend/internal/service/openai_alpha_search_billing_test.go
@@ -46,10 +46,11 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
svc := &OpenAIGatewayService{billingService: &BillingService{}}
groupID := int64(11)
- // 分组未配置单价:默认 0.01,倍率 2.0
+ // 分组未配置单价:默认 0.01。按次搜索使用不含高峰因子的基础倍率(第 4 个倍率参数 2.0),
+ // 即使 token 倍率(含高峰,3.0)更高也不采用。
apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}}
result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1}
- cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 2.0, 1.0, 1.0, UsageTokens{}, "")
+ cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "")
require.NoError(t, err)
require.Equal(t, string(BillingModePerRequest), cost.BillingMode)
require.InDelta(t, 0.01, cost.TotalCost, 1e-12)
@@ -57,7 +58,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
// 分组配置单价 0.005
apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005)
- cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, UsageTokens{}, "")
+ cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "")
require.NoError(t, err)
require.InDelta(t, 0.005, cost.TotalCost, 1e-12)
require.InDelta(t, 0.005, cost.ActualCost, 1e-12)
@@ -65,7 +66,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
// WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误,
// 证明回落到了 token 路径而不是被按次分支吞掉)。
result.WebSearchCalls = 0
- _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "")
+ _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "")
require.Error(t, err)
}
diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go
index 08eab1248a..9431b73ecb 100644
--- a/backend/internal/service/openai_gateway_usage.go
+++ b/backend/internal/service/openai_gateway_usage.go
@@ -178,7 +178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
}
- cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, tokens, serviceTier)
+ cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier)
if err != nil {
if !isUsagePricingUnavailableError(err) {
return err
@@ -363,14 +363,17 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
multiplier float64,
imageMultiplier float64,
videoMultiplier float64,
+ webSearchMultiplier float64,
tokens UsageTokens,
serviceTier string,
) (*CostBreakdown, error) {
billingModel := firstUsageBillingModel(billingModels)
if result != nil && result.WebSearchCalls > 0 {
- // Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,
- // 单价取分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),倍率与 token 口径一致。
- return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), multiplier), nil
+ // Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,单价只取
+ // 分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),不参与渠道级模型定价。
+ // 倍率与 image/video 按次口径一致:使用不含高峰因子的基础倍率
+ //(用户专属 > 分组 rate_multiplier > 系统默认),与分组表单的价格预览承诺一致。
+ return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), webSearchMultiplier), nil
}
if isGrokVideoUsageResult(result, billingModels) {
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
From f73031f4362e914058997ee5badf4a1f861aa019 Mon Sep 17 00:00:00 2001
From: Heatherm Huang
Date: Mon, 13 Jul 2026 10:21:34 +0800
Subject: [PATCH 25/27] test(grok): align scheduling reasons with upstream
---
backend/internal/service/openai_gateway_grok_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go
index 13020b1c28..71e427c790 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -1386,14 +1386,14 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test
{
name: "unauthorized reauth",
status: http.StatusUnauthorized,
- wantReason: "grok oauth token unauthorized",
+ wantReason: "grok credentials unauthorized",
wantMinCooldown: 10*time.Minute - time.Second,
wantMaxCooldown: 10*time.Minute + time.Second,
},
{
name: "forbidden entitlement",
status: http.StatusForbidden,
- wantReason: "grok entitlement or subscription tier denied",
+ wantReason: "grok access or entitlement denied",
wantMinCooldown: 30*time.Minute - time.Second,
wantMaxCooldown: 30*time.Minute + time.Second,
},
From e5af699d0f6926408e71f7f43164889e3aa0f919 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 13 Jul 2026 10:21:44 +0800
Subject: [PATCH 26/27] =?UTF-8?q?test(contract):=20groups/available=20?=
=?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=A4=B9=E5=85=B7=E8=A1=A5=20web=5Fsearch=5F?=
=?UTF-8?q?price=5Fper=5Fcall=20=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/server/api_contract_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index d260afe738..372cc46bbf 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -366,6 +366,7 @@ func TestAPIContracts(t *testing.T) {
"video_price_480p": null,
"video_price_720p": null,
"video_price_1080p": null,
+ "web_search_price_per_call": null,
"allow_image_generation": false,
"allow_batch_image_generation": false,
"batch_image_discount_multiplier": 0,
From a1930ea6f29fc5f17ae0020f4e2d38e789c49d73 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:53:05 +0000
Subject: [PATCH 27/27] chore: sync VERSION to 0.1.152 [skip ci]
---
backend/cmd/server/VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 6c015fd0ca..611234586a 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.151
+0.1.152