Compare commits

..
Author SHA1 Message Date
shaw 4795650d21 fix(openai): record actual upstream endpoint on errors 2026-08-26 09:42:28 +08:00
github-actions[bot] 7634e3c23b chore: sync VERSION to 0.1.183 [skip ci] 2026-08-25 13:53:14 +00:00
Wesley Liddick e8cb019fab Merge pull request #6078 from YogaSakti/fix/responses-custom-tool-call-id
fix(openai): keep restored tool-call item IDs typed
2026-08-25 21:32:34 +08:00
Wesley Liddick 7a7bd3729c Merge pull request #6166 from jyking/fix/email-bind-alias-dedup
fix(auth): 邮箱换绑增加别名与并发守卫
2026-08-25 21:31:39 +08:00
Wesley Liddick 49ad7021d7 Merge pull request #6164 from jimbirthday/fix/openai-sticky-capacity-spillover
fix(openai): preserve sticky binding on capacity spillover
2026-08-25 21:28:38 +08:00
Wesley Liddick 4952b919a4 Merge pull request #6204 from wucm667/fix/issue-6203-kimi-concurrency-403
fix(kimi): keep concurrency-limit 403 recoverable
2026-08-25 21:28:08 +08:00
Wesley Liddick fa0685a4a3 Merge pull request #6175 from wucm667/feat/issue-6172-antigravity-token-clamp
fix(antigravity): clamp compatible token limits
2026-08-25 21:27:57 +08:00
Wesley Liddick 8040428716 Merge pull request #6201 from xuhaihan/fix/codex-session-id-affinity
fix(openai): honor Codex session-id header
2026-08-25 21:27:24 +08:00
Wesley Liddick 4ff136cfd9 Merge pull request #6193 from antTing/codex/fix-channel-monitor-v2-nullif
fix(channel-monitor-v2): fix composite platform aggregation SQL
2026-08-25 21:26:32 +08:00
Wesley Liddick 832cf4df65 Merge pull request #6208 from Wei-Shaw/fix/openai-oauth-429-quota-scheduling
fix(openai): pause OAuth accounts on exhausted quota 429
2026-08-25 20:41:41 +08:00
shaw f1aadd48d5 fix(openai): pause OAuth accounts on exhausted quota 429 2026-08-25 20:25:22 +08:00
wucm667 3802268e2d fix(kimi): keep concurrency 403 recoverable 2026-08-25 19:40:39 +08:00
hank 8e60d57475 fix(openai): honor Codex session-id header 2026-08-25 17:08:27 +08:00
暴走的小笼包 b20f29d115 fix(channel-monitor-v2): fix composite platform aggregation SQL 2026-08-25 16:36:04 +08:00
wucm667 1a9898a6a4 fix(antigravity): clamp compatible token limits 2026-08-25 13:13:08 +08:00
github-actions[bot] aa2c4e8d13 chore: sync VERSION to 0.1.182 [skip ci] 2026-08-25 04:47:52 +00:00
Wesley Liddick 5a7d469622 Merge pull request #6157 from LiPu-jpg/fix/issue-6147-responses-lite-parallel
fix(openai): enforce Responses Lite parallel tool calls across account types
2026-08-25 12:31:32 +08:00
shaw 095b525360 fix(openai): pin Responses Lite parallel tool calls 2026-08-25 12:14:52 +08:00
Wesley Liddick 027d442f9b Merge pull request #6101 from jianjianai/fix/composite-group-channel-monitor-v2
fix(monitor-v2): 修复Composite分组错误事实未解析到真实账号平台导致渠道监控V2无效
2026-08-25 11:27:03 +08:00
Wesley Liddick 810d50a00f Merge pull request #6155 from HypoxanthineOvO/fix/composite-kimi-k3-routing
fix(composite): route Kimi Code K3 model IDs
2026-08-25 11:13:59 +08:00
Wesley Liddick 41d712be09 Merge pull request #5658 from william-drakemond/fix/opencode-go-usage-limit-reset
fix(openai): honor OpenCode Go usage reset durations
2026-08-25 11:05:15 +08:00
Wesley Liddick 3c714d8736 Merge pull request #6152 from ranxi2001/fix/payment-result-balance-refresh
fix(payment): refresh balance after fulfillment
2026-08-25 11:04:10 +08:00
Wesley Liddick 636d7debf9 Merge pull request #6132 from wucm667/fix/issue-6125-anthropic-cache-ttl
fix: prevent duplicate Anthropic cache TTL billing
2026-08-25 11:02:29 +08:00
Wesley Liddick 3dd717ab0d Merge pull request #5920 from wucm667/fix/issue-5884-antigravity-sonnet46
fix(antigravity): define Sonnet 4.5 to 4.6 compatibility routing
2026-08-25 11:01:34 +08:00
Wesley Liddick 1dc1b44268 Merge pull request #6149 from SipengXie2024/fix/oauth-image-verbatim-prompt
fix(openai): preserve OAuth image prompts verbatim
2026-08-25 11:01:20 +08:00
jyking 4ca86c52e3 fix(auth): 邮箱换绑增加别名与并发守卫 2026-08-25 10:40:02 +08:00
超级英雄吉姆 e55727d4c8 fix(openai): preserve sticky binding on capacity spillover 2026-08-25 10:27:48 +08:00
shaw 3b7753a8e4 chore: update sponsors 2026-08-25 08:51:58 +08:00
Jiao Ziang d5e43ef7d1 fix(openai): normalize Lite requests in WS HTTP bridge 2026-08-25 03:46:46 +08:00
Jiao Ziang d6012b0b35 fix(openai): preserve numeric precision in Lite payloads 2026-08-25 01:05:28 +08:00
Jiao Ziang 53d76ad800 fix(openai): enforce Responses Lite tool call mode 2026-08-25 00:21:29 +08:00
HypoxanthineOvO 4347e5555b fix(composite): route Kimi Code K3 model IDs 2026-08-24 23:09:30 +08:00
SipengXie2024 329b92ef04 fix(openai): preserve OAuth image prompts verbatim 2026-08-24 14:37:48 +00:00
github-actions[bot] e2d9b823f6 chore: sync VERSION to 0.1.181 [skip ci] 2026-08-24 14:35:57 +00:00
ranxi2001 eb594eefc9 fix(payment): refresh balance after fulfillment 2026-08-24 22:30:24 +08:00
Wesley Liddick 3af5443b22 Merge pull request #6116 from wucm667/fix/issue-6110-gemini-tool-schema
fix(gemini): sanitize unsupported tool schema fields
2026-08-24 22:19:07 +08:00
Wesley Liddick 7ba3e1ac52 Merge pull request #6150 from Wei-Shaw/fix/grok-upstream-user-agent
fix(grok): use official CLI user agent
2026-08-24 22:18:02 +08:00
shaw 9fb260439f fix(grok): use official CLI user agent 2026-08-24 22:14:45 +08:00
Wesley Liddick 07931bbb18 Merge pull request #6143 from akihitohyh/fix/rejected-status-strip-all
fix(openai): clear the rejected input status for the whole item type
2026-08-24 22:01:14 +08:00
Wesley Liddick 2307aa5ca7 Merge pull request #6148 from 759502416/fix/responses-lite-parallel-tool-calls
fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
2026-08-24 21:49:01 +08:00
759502416andClaude Opus 5 1563db3f82 fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level
"tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools
into an input item of type "additional_tools" and deletes the top-level key.
A Responses Lite request that carries tools therefore looks like it has none,
and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls
had just pinned gets deleted on the way out.

OpenAI then applies its default of true and rejects the request:

  400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
  `parallel_tool_calls` to be false."

Note the field cannot simply be pinned to false unconditionally: without tools
OpenAI rejects it with "'parallel_tool_calls' is only allowed when 'tools' are
specified", so the two constraints have to be honoured together.

Reuse the same tool-detection standard the Lite path already uses by adding
openAIRequestBodyHasTools, the []byte counterpart of openAIResponsesLiteHasTools,
so both sides of the repo agree on what "has tools" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:15:39 +08:00
akihitohyhandClaude Opus 5 e440ac48c7 fix(openai): clear the rejected input status for the whole item type
The upstream names one offending index per response, but a replayed
conversation routinely carries dozens of items of the same type, each with a
status the upstream schema does not accept. Clearing a single index per round
trip needs one retry per item, so a conversation with more than
maxOpenAIResponsesRejectedFieldRetries such items exhausts the bounded budget
and the 400 reaches the client. Reported against tool_search_output items,
where the rejection surfaced as "Unknown parameter: 'input[60].status'".

Clear the status of every input item sharing the rejected item's type in the
same pass. Items of other types keep theirs: the rejection only proves that
the rejected item's type has no status field. When the rejected item carries
no type to match on, fall back to clearing the named index alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:03:44 +08:00
wucm667 71aa6e3574 fix(antigravity): preserve explicit Sonnet 4.5 routing 2026-08-24 16:07:27 +08:00
wucm667 99ec347eaf fix(antigravity): migrate legacy Sonnet tests to 4.6 2026-08-24 15:58:47 +08:00
github-actions[bot] 03e8ab4134 chore: sync VERSION to 0.1.180 [skip ci] 2026-08-24 07:30:34 +00:00
wucm667 bc4a9ae432 fix: prevent duplicate Anthropic cache TTL billing 2026-08-24 11:05:57 +08:00
wucm667 19da0f240e fix(gemini): sanitize unsupported tool schema fields 2026-08-23 18:38:11 +08:00
jjaw 49752060fc fix(monitor-v2): resolve composite group error facts to concrete account platform
Composite groups are a routing layer. The usage-side aggregation already
maps their traffic to the concrete account platform via
usageLogEffectivePlatformExpr, but the error aggregation read
ops_error_logs.platform verbatim. Pre-routing failures on composite groups
fall back to the group platform 'composite', which is never an enabled
monitor v2 config platform, so those error facts were filtered out of
every channel monitor v2 query and composite groups looked permanently
healthy.

Join groups/accounts in the error dedup CTE and resolve the effective
platform the same way as the usage side: account platform first, then a
real recorded platform, else 'unknown'. The window delete+rewrite
recompute rewrites historical facts with the corrected key.
2026-08-23 02:33:09 +08:00
William Drake a6b11ccced fix(openai): honor OpenCode Go usage reset durations
Recognize GoUsageLimitError responses and parse both single and composite reset durations from their human-readable messages. Keep API-key accounts rate-limited until the parsed reset instead of falling back to the short default cooldown.
2026-08-22 10:26:56 -07:00
Yoga Sakti e0e5e45cd2 fix(openai): keep restored tool-call item IDs typed
The Responses client-tool restore raised an upstream function_call back to
custom_tool_call and tool_search_call without touching its item ID, so the
client received a custom_tool_call carrying the upstream's fc_ ID. That
combination is invalid: once the item lands in the client's history, any
later replay to an upstream that validates item IDs fails the whole request
with "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with
'ctc'", and every following message re-submits the same bad item.

Re-prefix the ID to match the type it is raised to, keeping the suffix so
the ID stays stable and unique per upstream item. IDs without a known
tool-call prefix are left alone rather than guessed at.

The lowering direction has to reverse this, because the WebSocket HTTP
bridge replays restored items back to the upstream and would otherwise lose
the upstream's own ID. normalizeLoweredFunctionItemID now maps a ctc_/tsc_
call ID back to fc_ instead of dropping it, which makes the round trip
lossless; output item IDs (ctco_/tso_) still have no function-protocol
counterpart and are still dropped. The helper was renamed from
dropInvalidLoweredFunctionItemID to match what it now does.

The stream restorer keeps the upstream ID for its own bookkeeping so later
upstream events still match the call, and emits the client-facing ID on
output_item.added/done and the custom_tool_call_input events.

Closes #6071
2026-08-22 14:03:25 +07:00
86 changed files with 2114 additions and 264 deletions
+5
View File
@@ -162,6 +162,11 @@ Please read the following carefully before using this project:
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
</tr>
</table>
## Overview
+5
View File
@@ -165,6 +165,11 @@
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
</tr>
</table>
## 项目概述
+5
View File
@@ -164,6 +164,11 @@
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
</tr>
</table>
## 概要
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.179
0.1.183
+3 -3
View File
@@ -105,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
// Claude 详细版本 ID 映射
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
// Claude Haiku → Sonnet(无 Haiku 支持)
"claude-haiku-4-5": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
+15
View File
@@ -43,6 +43,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
}
for model, want := range cases {
if got := DefaultAntigravityModelMapping[model]; got != want {
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
t.Parallel()
@@ -31,6 +31,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
}{
{model: "grok-4.3", platform: service.PlatformGrok},
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
}
+8
View File
@@ -312,6 +312,14 @@ func GetInboundEndpoint(c *gin.Context) string {
// and the account platform. Handlers call this after scheduling an
// account, passing account.Platform.
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
return endpoint
}
}
if c != nil {
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
if endpoint, ok := value.(string); ok && endpoint != "" {
+10
View File
@@ -184,6 +184,16 @@ func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
}
func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
c.Set(ctxKeyInboundEndpoint, EndpointResponses)
service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
}
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
tests := []struct {
name string
@@ -235,12 +235,12 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (b
typed["type"] = "function_call"
typed["arguments"] = customToolCallArguments(stringValue(typed["input"]))
delete(typed, "input")
dropInvalidLoweredFunctionItemID(typed)
normalizeLoweredFunctionItemID(typed)
changed = true
}
case "custom_tool_call_output":
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
normalizeLoweredFunctionItemID(typed)
normalizeClientToolOutput(typed)
changed = true
case "tool_search_call":
@@ -249,7 +249,7 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (b
typed["name"] = toolSearchProxyName
typed["arguments"] = rawObjectString(typed["arguments"])
delete(typed, "execution")
dropInvalidLoweredFunctionItemID(typed)
normalizeLoweredFunctionItemID(typed)
changed = true
}
case "tool_search_output":
@@ -259,7 +259,7 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (b
return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output")
}
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
normalizeLoweredFunctionItemID(typed)
if err := normalizeToolSearchOutput(typed); err != nil {
return err
}
@@ -280,14 +280,73 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (b
return changed, nil
}
// dropInvalidLoweredFunctionItemID removes Codex client-only item IDs such as
// normalizeLoweredFunctionItemID reconciles Codex client-only item IDs such as
// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the
// function protocol. Function upstreams validate these IDs with the fc prefix;
// call_id, which is preserved separately, is the tool call/output pairing key.
func dropInvalidLoweredFunctionItemID(item map[string]any) {
// function protocol, which validates these IDs with the fc prefix. A ctc_/tsc_
// call ID maps back to the fc_ ID it was raised from; the remaining IDs have no
// function-protocol counterpart and are dropped. call_id, which is preserved
// separately, stays the tool call/output pairing key either way.
func normalizeLoweredFunctionItemID(item map[string]any) {
id := strings.TrimSpace(stringValue(item["id"]))
if id != "" && !strings.HasPrefix(id, "fc") {
delete(item, "id")
if id == "" || strings.HasPrefix(id, "fc") {
return
}
// A ctc_/tsc_ call ID on a lowered call item is the one we minted from the
// upstream's own fc_ ID when the item was raised, so map it back instead of
// dropping it. Anything else has no known function-protocol counterpart.
if recovered := retypedResponsesToolCallItemID(id, "function_call"); recovered != id {
item["id"] = recovered
return
}
delete(item, "id")
}
// responsesToolCallItemIDPrefixes lists the Responses item ID prefixes that are
// tied to a specific tool-call item type.
var responsesToolCallItemIDPrefixes = []string{"fc_", "ctc_", "tsc_"}
// responsesToolCallItemIDPrefix reports the ID prefix the Responses API
// validates for itemType, or "" when the type constrains no prefix.
func responsesToolCallItemIDPrefix(itemType string) string {
switch itemType {
case "custom_tool_call":
return "ctc_"
case "tool_search_call":
return "tsc_"
case "function_call":
return "fc_"
default:
return ""
}
}
// retypedResponsesToolCallItemID re-prefixes an upstream item ID so it agrees
// with the item type we raise it back to. A function-only upstream answers a
// lowered custom tool with an fc_ item ID; emitting that ID on the restored
// custom_tool_call poisons the client's history, because a later replay of the
// same item to an upstream that validates IDs fails with
// "Invalid 'input[N].id' ... Expected an ID that begins with 'ctc'".
// The suffix is preserved so the ID stays stable and unique per upstream item.
// IDs that carry no known tool-call prefix are left alone rather than guessed at.
func retypedResponsesToolCallItemID(id, itemType string) string {
want := responsesToolCallItemIDPrefix(itemType)
if want == "" || id == "" || strings.HasPrefix(id, want) {
return id
}
for _, known := range responsesToolCallItemIDPrefixes {
if known != want && strings.HasPrefix(id, known) {
return want + strings.TrimPrefix(id, known)
}
}
return id
}
// retypeResponsesToolCallItemID applies retypedResponsesToolCallItemID to a
// decoded item map.
func retypeResponsesToolCallItemID(item map[string]any, itemType string) {
id := strings.TrimSpace(stringValue(item["id"]))
if retyped := retypedResponsesToolCallItemID(id, itemType); retyped != id {
item["id"] = retyped
}
}
@@ -432,12 +491,14 @@ func restoreClientToolValue(value any, adapter *ResponsesClientToolMapping) bool
name := strings.TrimSpace(stringValue(typed["name"]))
if adapter.CustomTools[name] {
typed["type"] = "custom_tool_call"
retypeResponsesToolCallItemID(typed, "custom_tool_call")
typed["input"] = extractCustomToolCallInput(rawObjectString(typed["arguments"]))
delete(typed, "arguments")
delete(typed, "namespace")
changed = true
} else if adapter.ToolSearch && name == toolSearchProxyName {
typed["type"] = "tool_search_call"
retypeResponsesToolCallItemID(typed, "tool_search_call")
typed["execution"] = "client"
typed["arguments"] = json.RawMessage(toolSearchCallArgumentsJSON(rawObjectString(typed["arguments"])))
delete(typed, "name")
@@ -464,12 +525,15 @@ type ResponsesClientToolStreamRestorer struct {
}
type responsesClientToolStreamCall struct {
kind string
name string
callID string
itemID string
outputIdx int
arguments strings.Builder
kind string
name string
// callID and itemID stay as the upstream sent them so later upstream
// events keep matching this call; clientItemID is what we emit.
callID string
itemID string
clientItemID string
outputIdx int
arguments strings.Builder
}
func NewResponsesClientToolStreamRestorer(mapping ResponsesClientToolMapping) *ResponsesClientToolStreamRestorer {
@@ -508,6 +572,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
event.Item.Arguments = "{}"
event.Item.Namespace = ""
}
if call.clientItemID != "" {
event.Item.ID = call.clientItemID
}
}
emit(r.restoreNamespaceEvent(event))
case "response.function_call_arguments.delta":
@@ -525,9 +592,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
if call.kind == "custom" {
input := extractCustomToolCallInput(call.arguments.String())
if input != "" {
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.itemID, Delta: input})
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.clientItemID, Delta: input})
}
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.itemID, CallID: call.callID, Name: call.name, Input: input})
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.clientItemID, CallID: call.callID, Name: call.name, Input: input})
}
return out
}
@@ -548,6 +615,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
}
event.Item.Namespace = ""
}
if call.clientItemID != "" {
event.Item.ID = call.clientItemID
}
delete(r.calls, call.itemID)
delete(r.calls, call.callID)
delete(r.byOutput, call.outputIdx)
@@ -684,6 +754,15 @@ func (r *ResponsesClientToolStreamRestorer) resequenceRaw(payload []byte, sequen
return [][]byte{encoded}, true, nil
}
// responsesClientToolItemType maps a restorer call kind to the item type the
// client sees.
func responsesClientToolItemType(kind string) string {
if kind == "custom" {
return "custom_tool_call"
}
return "tool_search_call"
}
func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEvent) *responsesClientToolStreamCall {
if event.Item == nil || event.Item.Type != "function_call" {
return nil
@@ -704,7 +783,14 @@ func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEven
}
call := r.calls[key]
if call == nil {
call = &responsesClientToolStreamCall{kind: kind, name: name, callID: event.Item.CallID, itemID: event.Item.ID, outputIdx: event.OutputIndex}
call = &responsesClientToolStreamCall{
kind: kind,
name: name,
callID: event.Item.CallID,
itemID: event.Item.ID,
clientItemID: retypedResponsesToolCallItemID(event.Item.ID, responsesClientToolItemType(kind)),
outputIdx: event.OutputIndex,
}
r.calls[key] = call
if call.callID != "" {
r.calls[call.callID] = call
@@ -758,11 +844,13 @@ func restoreResponsesOutputClientTools(outputs []ResponsesOutput, adapter *Respo
}
if adapter.CustomTools[output.Name] {
output.Type = "custom_tool_call"
output.ID = retypedResponsesToolCallItemID(output.ID, output.Type)
output.Input = extractCustomToolCallInput(output.Arguments)
output.Arguments = ""
output.Namespace = ""
} else if adapter.ToolSearch && output.Name == toolSearchProxyName {
output.Type = "tool_search_call"
output.ID = retypedResponsesToolCallItemID(output.ID, output.Type)
output.Name = ""
output.Namespace = ""
}
@@ -0,0 +1,29 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRetypedResponsesToolCallItemID(t *testing.T) {
for _, tc := range []struct {
name string
id string
itemType string
want string
}{
{"function id raised to custom", "fc_abc", "custom_tool_call", "ctc_abc"},
{"function id raised to tool search", "fc_abc", "tool_search_call", "tsc_abc"},
{"already correct is untouched", "ctc_abc", "custom_tool_call", "ctc_abc"},
{"custom id lowered to function", "ctc_abc", "function_call", "fc_abc"},
{"unknown prefix is left alone", "item_abc", "custom_tool_call", "item_abc"},
{"unprefixed id is left alone", "abc", "custom_tool_call", "abc"},
{"empty id stays empty", "", "custom_tool_call", ""},
{"unconstrained item type is left alone", "fc_abc", "message", "fc_abc"},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, retypedResponsesToolCallItemID(tc.id, tc.itemType))
})
}
}
@@ -0,0 +1,132 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/require"
)
// A function-only upstream answers a lowered custom tool with an fc_ item ID.
// Emitting that ID on the restored custom_tool_call breaks the client the next
// time the item is replayed to an upstream that validates item IDs:
// "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with 'ctc'".
func TestRestoreResponsesClientToolPayload_RetypesToolCallItemIDs(t *testing.T) {
mapping := ResponsesClientToolMapping{
CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}},
}
payload := []byte(`{"id":"resp","output":[` +
`{"type":"function_call","id":"fc_abc123","call_id":"call_1","name":"exec","arguments":"{\"input\":\"dir\"}"},` +
`{"type":"function_call","id":"fc_def456","call_id":"call_2","name":"tool_search","arguments":"{\"query\":\"git\"}"},` +
`{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"team__send","arguments":"{}"}]}`)
restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"id":"resp","output":[`+
`{"type":"custom_tool_call","id":"ctc_abc123","call_id":"call_1","name":"exec","input":"dir"},`+
`{"type":"tool_search_call","id":"tsc_def456","call_id":"call_2","execution":"client","arguments":{"query":"git"}},`+
`{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"send","namespace":"team","arguments":"{}"}]}`,
string(restored))
}
func TestRestoreResponsesOutputClientTools_RetypesToolCallItemIDs(t *testing.T) {
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}, ToolSearch: true}
outputs := []ResponsesOutput{
{Type: "function_call", ID: "fc_abc123", CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`},
{Type: "function_call", ID: "fc_def456", CallID: "call_2", Name: toolSearchProxyName, Arguments: `{"query":"git"}`},
}
restoreResponsesOutputClientTools(outputs, &mapping)
require.Equal(t, "custom_tool_call", outputs[0].Type)
require.Equal(t, "ctc_abc123", outputs[0].ID)
require.Equal(t, "call_1", outputs[0].CallID)
require.Equal(t, "tool_search_call", outputs[1].Type)
require.Equal(t, "tsc_def456", outputs[1].ID)
require.Equal(t, "call_2", outputs[1].CallID)
}
func TestResponsesClientToolStreamRestorer_RetypesCustomToolCallItemID(t *testing.T) {
const upstreamID = "fc_09f77ac43cf7db36016a8920e7934487"
const clientID = "ctc_09f77ac43cf7db36016a8920e7934487"
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
added := restorer.Restore(ResponsesStreamEvent{
Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Status: "in_progress"},
})
require.Len(t, added, 1)
require.Equal(t, "custom_tool_call", added[0].Item.Type)
require.Equal(t, clientID, added[0].Item.ID)
// Later upstream events still address the item by its upstream ID.
require.Empty(t, restorer.Restore(ResponsesStreamEvent{
Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: upstreamID, Delta: `{"input":"di`,
}))
done := restorer.Restore(ResponsesStreamEvent{
Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: upstreamID,
CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`,
})
require.Len(t, done, 2)
require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type)
require.Equal(t, clientID, done[0].ItemID)
require.Equal(t, "response.custom_tool_call_input.done", done[1].Type)
require.Equal(t, clientID, done[1].ItemID)
require.Equal(t, "call_1", done[1].CallID)
closed := restorer.Restore(ResponsesStreamEvent{
Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"},
})
require.Len(t, closed, 1)
require.Equal(t, "custom_tool_call", closed[0].Item.Type)
require.Equal(t, clientID, closed[0].Item.ID)
require.Equal(t, "dir", closed[0].Item.Input)
}
func TestResponsesClientToolStreamRestorer_RetypesToolSearchCallItemID(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true})
added := restorer.Restore(ResponsesStreamEvent{
Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", ID: "fc_search1", CallID: "call_1", Name: toolSearchProxyName, Status: "in_progress"},
})
require.Len(t, added, 1)
require.Equal(t, "tool_search_call", added[0].Item.Type)
require.Equal(t, "tsc_search1", added[0].Item.ID)
}
// The WS bridge replays restored items back to the upstream, so the ID we hand
// the client has to map back to the upstream's own fc_ ID on the way down.
func TestAdaptResponsesClientTools_RecoversRetypedToolCallItemID(t *testing.T) {
req := map[string]any{
"tools": []any{
map[string]any{"type": "custom", "name": "exec"},
map[string]any{"type": "tool_search"},
},
"input": []any{
map[string]any{"type": "custom_tool_call", "id": "ctc_upstream1", "call_id": "call_1", "name": "exec", "input": "dir"},
map[string]any{"type": "tool_search_call", "id": "tsc_upstream2", "call_id": "call_2", "arguments": map[string]any{"query": "git"}},
map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "call_1", "output": "ok"},
},
}
_, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
input := requireResponsesClientToolValue[[]any](t, req["input"])
require.Len(t, input, 3)
customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
require.Equal(t, "function_call", customCall["type"])
require.Equal(t, "fc_upstream1", customCall["id"])
searchCall := requireResponsesClientToolValue[map[string]any](t, input[1])
require.Equal(t, "function_call", searchCall["type"])
require.Equal(t, "fc_upstream2", searchCall["id"])
// Output items have no function-protocol ID counterpart and stay dropped.
customOutput := requireResponsesClientToolValue[map[string]any](t, input[2])
require.Equal(t, "function_call_output", customOutput["type"])
require.NotContains(t, customOutput, "id")
}
@@ -48,14 +48,14 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(
input := requireResponsesClientToolValue[[]any](t, req["input"])
customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
require.Equal(t, "function_call", customCall["type"])
require.NotContains(t, customCall, "id")
require.Equal(t, "fc_client", customCall["id"])
require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"]))
customOutput := requireResponsesClientToolValue[map[string]any](t, input[1])
require.Equal(t, "function_call_output", customOutput["type"])
require.NotContains(t, customOutput, "id")
searchCall := requireResponsesClientToolValue[map[string]any](t, input[2])
require.Equal(t, "function_call", searchCall["type"])
require.NotContains(t, searchCall, "id")
require.Equal(t, "fc_client", searchCall["id"])
require.Equal(t, toolSearchProxyName, searchCall["name"])
require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"]))
searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3])
+1 -1
View File
@@ -19,7 +19,7 @@ const (
// repository and service layers build their own client identity from it, so
// one bump here covers OAuth traffic and billing probes together.
// Keep in sync with https://x.ai/cli/stable.
CLIClientVersion = "0.2.114"
CLIClientVersion = "0.2.120"
// billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
// Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
@@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) {
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")
req.Header.Set("User-Agent", "legacy-client/1.0")
ApplyCLIProxyHeaders(req)
@@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "direct-api-client/1.0")
ApplyCLIProxyHeaders(req)
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("x-grok-client-identifier"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent"))
}
@@ -247,18 +247,28 @@ WITH dedup AS (
FROM ops_error_logs
WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL
)
SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text))
date_trunc('minute', created_at) AS bucket_start,
lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform,
COALESCE(group_id, 0) AS group_id,
COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model,
user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code,
COALESCE(upstream_status_code, 0) AS upstream_status_code,
lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text,
(CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END
OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected,
CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts
SELECT DISTINCT ON (COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text))
date_trunc('minute', current_error.created_at) AS bucket_start,
-- Composite groups are a routing layer: resolve the concrete account
-- platform (mirrors usageLogEffectivePlatformExpr on the usage side) so
-- error facts share the usage facts' platform key. Without this, composite
-- group errors aggregate under platform 'composite', which is never an
-- enabled config platform, and are filtered out of every monitor v2 query.
lower(CASE
WHEN g.platform = 'composite' THEN COALESCE(NULLIF(TRIM(a.platform), ''), NULLIF(NULLIF(lower(TRIM(current_error.platform)), ''), 'composite'), 'unknown')
ELSE COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown')
END) AS platform,
COALESCE(current_error.group_id, 0) AS group_id,
COALESCE(NULLIF(TRIM(current_error.requested_model), ''), NULLIF(TRIM(current_error.model), ''), 'unknown') AS model,
current_error.user_id, current_error.error_type, current_error.error_owner, COALESCE(current_error.status_code, 0) AS status_code,
COALESCE(current_error.upstream_status_code, 0) AS upstream_status_code,
lower(CONCAT_WS(' ', current_error.error_type, current_error.error_source, current_error.error_message, current_error.upstream_error_message, current_error.upstream_error_detail, current_error.error_body)) AS text,
(CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) > 0 ELSE FALSE END
OR current_error.error_owner = 'provider' OR current_error.upstream_status_code IS NOT NULL) AS upstream_affected,
CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) ELSE 0 END AS upstream_attempts
FROM ops_error_logs current_error
LEFT JOIN groups g ON g.id = current_error.group_id
LEFT JOIN accounts a ON a.id = current_error.account_id
WHERE (
(NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2)
OR (
@@ -269,7 +279,7 @@ WITH dedup AS (
)
AND NOT current_error.is_count_tokens
AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')
ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC
ORDER BY COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text), current_error.created_at DESC, current_error.id DESC
), classified AS (
SELECT *, CASE
-- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles.
@@ -101,12 +101,25 @@ func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T)
require.Contains(t, query, "candidate_ids")
require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2")
require.Contains(t, query, "upstream_affected_requests")
require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0")
require.Contains(t, query, "jsonb_array_length(current_error.upstream_errors) > 0")
// request_id dedup must be time-bounded (no full-history scan).
require.Contains(t, query, "interval '90 minutes'")
require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'")
}
func TestChannelMonitorV2ErrorAggregationResolvesCompositePlatform(t *testing.T) {
query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
// Composite groups are a routing layer: error facts must resolve the concrete
// account platform (joining groups/accounts) so they aggregate under the same
// platform key as usage facts instead of the never-enabled 'composite' platform.
require.Contains(t, query, "g.platform = 'composite'")
require.Contains(t, query, "left join groups g on g.id = current_error.group_id")
require.Contains(t, query, "left join accounts a on a.id = current_error.account_id")
require.Contains(t, query, "a.platform")
require.Contains(t, query, "nullif(trim(a.platform), '')")
require.NotContains(t, query, "nullif(trim(a.platform))")
}
func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) {
for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} {
require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)")
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
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")
req.Header.Set("User-Agent", "legacy-client/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.114", capturedHeaders.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, 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.114", capturedHeaders.Get("User-Agent"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent"))
})
}
}
@@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "")
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")
req.Header.Set("User-Agent", "legacy-client/1.0")
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
t.Run("accepts a valid operator override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent"))
require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent"))
})
t.Run("rejects an unsafe override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true")
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.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
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.113")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119")
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.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), 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.114-beta.1")
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-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.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
// Every entry sits above the pinned minimum, so a rejection here can only be
// caused by the malformed semver and never by the version being too old.
for _, version := range []string{
"0.2.0115",
"0.2.115-alpha..1",
"0.2.0121",
"0.2.121-alpha..1",
"0.3",
"1",
"0.2.115+build.1",
"0.2.121+build.1",
} {
t.Run("rejects invalid semver "+version, func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", version)
@@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent"))
})
}
@@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(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)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
req.Header.Set("User-Agent", "direct-api-client/1.0")
applyGrokCLIProxyHeaders(req)
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent"))
})
}
+75 -8
View File
@@ -1145,12 +1145,17 @@ func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) (
}
func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) {
_, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, 0)
return exists, err
}
func emailAliasOwnerIDWithClient(ctx context.Context, client *dbent.Client, email string, currentUserID int64) (int64, bool, error) {
if client == nil {
return false, nil
return 0, false, nil
}
probes := service.EmailAliasDedupProbes(email)
if len(probes) == 0 {
return false, nil
return 0, false, nil
}
preds := make([]predicate.User, 0, 2*len(probes))
@@ -1164,20 +1169,82 @@ func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, ema
candidates, err := client.User.Query().
Where(dbuser.Or(preds...)).
Limit(emailAliasCandidateLimit).
Select(dbuser.FieldEmail).
Strings(ctx)
Select(dbuser.FieldID, dbuser.FieldEmail).
All(ctx)
if err != nil {
return false, err
return 0, false, err
}
// 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。
// 返回“其他用户”优先于当前用户,避免历史重复数据让调用方误判为仅当前用户占用。
identity := service.NormalizeEmailForAliasDedup(email)
var selfID int64
selfExists := false
for _, candidate := range candidates {
if service.NormalizeEmailForAliasDedup(candidate) == identity {
return true, nil
if service.NormalizeEmailForAliasDedup(candidate.Email) != identity {
continue
}
if candidate.ID != 0 && candidate.ID != currentUserID {
return candidate.ID, true, nil
}
if candidate.ID == currentUserID {
selfID = candidate.ID
selfExists = true
}
}
return false, nil
return selfID, selfExists, nil
}
// UpdateEmailWithAliasGuard 在调用方事务内更新主邮箱与密码哈希。
//
// 邮箱换绑不能只依赖服务层前置查重:两个并发请求可能同时看到同一收件箱未被占用。
// 这里先按“字面邮箱 + 收件箱身份”加锁,复查是否已被其他用户占用,再执行写入;
// PostgreSQL 使用事务级 advisory lock 跨实例互斥,测试内存库则由进程内锁兜底。
func (r *userRepository) UpdateEmailWithAliasGuard(
ctx context.Context,
userID int64,
email string,
passwordHash string,
) error {
if userID <= 0 {
return service.ErrUserNotFound
}
if strings.TrimSpace(email) == "" || passwordHash == "" {
return fmt.Errorf("email identity update requires email and password hash")
}
tx := dbent.TxFromContext(ctx)
if tx == nil {
return fmt.Errorf("email identity update requires a transaction")
}
client := tx.Client()
releaseEmailLock, err := lockRepositoryScopedKeys(
ctx,
client,
txAwareSQLExecutor(ctx, r.sql, r.client),
normalizedEmailUniquenessLockKey(email),
emailAliasUniquenessLockKey(email),
)
if err != nil {
return err
}
defer releaseEmailLock()
ownerID, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, userID)
if err != nil {
return err
}
if exists && ownerID != userID {
return service.ErrEmailExists
}
if _, err := client.User.UpdateOneID(userID).
SetEmail(email).
SetPasswordHash(passwordHash).
Save(ctx); err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
}
return nil
}
// dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与
@@ -43,8 +43,9 @@ import (
var sseDataPrefix = regexp.MustCompile(`^data:\s*`)
const (
testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
defaultAntigravityTestModel = "claude-sonnet-4-6"
)
// TestEvent represents a SSE event for account testing
@@ -2311,11 +2312,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
ctx := c.Request.Context()
// 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview
testModelID := modelID
if testModelID == "" {
testModelID = "claude-sonnet-4-5"
}
testModelID := antigravityConnectionTestModel(modelID)
if s.antigravityGatewayService == nil {
return s.sendErrorAndEnd(c, "Antigravity gateway service not configured")
@@ -2346,6 +2343,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
return nil
}
func antigravityConnectionTestModel(modelID string) string {
if modelID == "" {
return defaultAntigravityTestModel
}
return modelID
}
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
apiKey := account.GetCredential("api_key")
@@ -0,0 +1,16 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAntigravityConnectionTestModel(t *testing.T) {
t.Parallel()
require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel(""))
require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview"))
}
@@ -29,6 +29,8 @@ const (
AntigravityCredentialRejectedReason GatewayFailureReason = "antigravity_oauth_credential_rejected"
)
const antigravityCompatMaxTokens = 64000
type antigravityCompatRequest struct {
protocol antigravityCompatProtocol
originalBody []byte
@@ -158,7 +160,7 @@ func preserveChatCompletionTokenLimit(request *apicompat.ChatCompletionsRequest,
limit = request.MaxCompletionTokens
}
if limit != nil && *limit > 0 {
claudeRequest.MaxTokens = *limit
claudeRequest.MaxTokens = min(*limit, antigravityCompatMaxTokens)
}
}
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -285,6 +286,21 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) {
body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":13}`,
want: 13,
},
{
name: "max_tokens at safe ceiling is preserved",
body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64000}`,
want: 64000,
},
{
name: "max_tokens above safe ceiling is clamped",
body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64001}`,
want: 64000,
},
{
name: "precedence applies before clamping",
body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":64001}`,
want: 64000,
},
}
for _, tt := range tests {
@@ -310,6 +326,27 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) {
}
}
func TestPreserveChatCompletionTokenLimitIgnoresAbsentAndNonPositiveValues(t *testing.T) {
tests := []struct {
name string
request apicompat.ChatCompletionsRequest
}{
{name: "absent"},
{name: "zero max_tokens", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(0)}},
{name: "negative max_completion_tokens takes precedence", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(12), MaxCompletionTokens: antigravityCompatIntPtr(-1)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claudeRequest := &apicompat.AnthropicRequest{MaxTokens: 99}
preserveChatCompletionTokenLimit(&tt.request, claudeRequest)
require.Equal(t, 99, claudeRequest.MaxTokens)
})
}
}
func antigravityCompatIntPtr(v int) *int { return &v }
func TestAntigravityCompatRoutesByMappedModelFamily(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
@@ -69,10 +69,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
expected: "claude-sonnet-4-6",
},
{
name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5",
name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-6",
requestedModel: "claude-sonnet-4-5-20250929",
accountMapping: nil,
expected: "claude-sonnet-4-5",
expected: "claude-sonnet-4-6",
},
// 3. 默认映射中的透传(映射到自己)
@@ -89,7 +89,7 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
expected: "claude-sonnet-4-6",
},
{
name: "默认映射透传 - claude-sonnet-4-5",
name: "显式 canonical 选择 - claude-sonnet-4-5 透传",
requestedModel: "claude-sonnet-4-5",
accountMapping: nil,
expected: "claude-sonnet-4-5",
@@ -113,10 +113,19 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
expected: "claude-opus-4-6-thinking",
},
{
name: "默认映射透传 - claude-sonnet-4-5-thinking",
name: "默认映射 - claude-sonnet-4-5-thinking → claude-sonnet-4-6",
requestedModel: "claude-sonnet-4-5-thinking",
accountMapping: nil,
expected: "claude-sonnet-4-5-thinking",
expected: "claude-sonnet-4-6",
},
{
name: "账户显式目标只映射一步 - custom-sonnet → claude-sonnet-4-5",
requestedModel: "custom-sonnet",
accountMapping: map[string]string{
"custom-sonnet": "claude-sonnet-4-5",
"claude-sonnet-4-5": "claude-sonnet-4-6",
},
expected: "claude-sonnet-4-5",
},
{
name: "默认映射透传 - gemini-2.5-flash",
@@ -844,17 +844,28 @@ func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) {
require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
}
func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.T) {
func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
svc := &AntigravityGatewayService{}
account := &Account{ID: 790, Platform: PlatformAntigravity}
account := &Account{
ID: 790,
Platform: PlatformAntigravity,
Credentials: map[string]any{
"model_mapping": map[string]any{
"custom-sonnet": "claude-sonnet-4-5",
"claude-sonnet-4-5": "claude-sonnet-4-6",
},
},
}
resetAt := time.Now().Add(30 * time.Second)
canonicalModel := resolveFinalAntigravityModelKey(context.Background(), account, "custom-sonnet")
require.Equal(t, "claude-sonnet-4-5", canonicalModel)
success := svc.setAntigravityModelRateLimits(
context.Background(),
repo,
account,
"claude-sonnet-4-5",
canonicalModel,
"[test]",
429,
resetAt,
@@ -866,6 +877,33 @@ func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
}
func TestSetModelRateLimitAndClearSession_UsesUpstreamReportedModelMetadata(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{
ID: 791,
Platform: PlatformAntigravity,
Credentials: map[string]any{
"model_mapping": map[string]any{
"claude-sonnet-4-5": "claude-sonnet-4-6",
},
},
}
svc.setModelRateLimitAndClearSession(&handleModelRateLimitParams{
ctx: context.Background(),
prefix: "[test]",
account: account,
statusCode: 429,
}, &antigravitySmartRetryInfo{
RetryDelay: 30 * time.Second,
ModelName: "claude-sonnet-4-5",
})
require.Len(t, repo.modelRateLimitCalls, 1)
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
}
func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) {
upstream := &recordingOKUpstream{}
account := &Account{
+58 -21
View File
@@ -56,12 +56,8 @@ func (s *AuthService) BindEmailIdentity(
return nil, ErrPasswordIncorrect
}
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
switch {
case err == nil && existingUser != nil && existingUser.ID != userID:
return nil, ErrEmailExists
case err != nil && !errors.Is(err, ErrUserNotFound):
return nil, ErrServiceUnavailable
if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
return nil, err
}
hashedPassword, err := s.HashPassword(password)
@@ -115,19 +111,16 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6
if s.emailService == nil {
return ErrServiceUnavailable
}
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
currentUser, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return ErrUserNotFound
}
return ErrServiceUnavailable
}
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
switch {
case err == nil && existingUser != nil && existingUser.ID != userID:
return ErrEmailExists
case err != nil && !errors.Is(err, ErrUserNotFound):
return ErrServiceUnavailable
if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
return err
}
siteName := "Sub2API"
@@ -137,6 +130,45 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6
return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale))
}
// ensureEmailIdentityAvailableForUser 在发码 / 提交换绑前做快速查重。
// 精确地址或 provider alias 若已指向其他用户的收件箱则直接拒绝;
// 当前用户自己的收件箱允许继续,便于其更换自身的 alias 变体。
func (s *AuthService) ensureEmailIdentityAvailableForUser(
ctx context.Context,
currentUser *User,
email string,
) error {
if currentUser == nil {
return ErrUserNotFound
}
existingUser, err := s.userRepo.GetByEmail(ctx, email)
switch {
case err == nil:
if existingUser == nil || existingUser.ID == currentUser.ID {
break
}
return ErrEmailExists
case errors.Is(err, ErrUserNotFound):
// Continue to alias lookup below.
default:
return ErrServiceUnavailable
}
if NormalizeEmailForAliasDedup(currentUser.Email) == NormalizeEmailForAliasDedup(email) {
return nil
}
aliasExists, err := s.userRepo.ExistsByEmailAlias(ctx, email)
if err != nil {
return ErrServiceUnavailable
}
if aliasExists {
return ErrEmailExists
}
return nil
}
func normalizeEmailForIdentityBinding(email string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(email))
if normalized == "" || len(normalized) > 255 {
@@ -153,6 +185,12 @@ func hasBindableEmailIdentitySubject(email string) bool {
return normalized != "" && !isReservedEmail(normalized)
}
// emailIdentityAliasGuardRepository 是主邮箱替换所需的事务内原子仓储能力,
// 用于关闭服务层前置查重与实际写入之间的并发窗口。
type emailIdentityAliasGuardRepository interface {
UpdateEmailWithAliasGuard(ctx context.Context, userID int64, email string, passwordHash string) error
}
func (s *AuthService) updateBoundEmailIdentityTx(
ctx context.Context,
currentUser *User,
@@ -192,16 +230,15 @@ func (s *AuthService) updateBoundEmailIdentityWithClient(
return ErrServiceUnavailable
}
oldEmail := currentUser.Email
if _, err := client.User.UpdateOneID(currentUser.ID).
SetEmail(email).
SetPasswordHash(hashedPassword).
Save(ctx); err != nil {
if dbent.IsConstraintError(err) {
return ErrEmailExists
}
guard, ok := s.userRepo.(emailIdentityAliasGuardRepository)
if !ok {
return ErrServiceUnavailable
}
if err := guard.UpdateEmailWithAliasGuard(ctx, currentUser.ID, email, hashedPassword); err != nil {
return err
}
oldEmail := currentUser.Email
if err := replaceBoundEmailAuthIdentityWithClient(ctx, client, currentUser.ID, oldEmail, email, "auth_service_email_bind"); err != nil {
if errors.Is(err, ErrEmailExists) {
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/enttest"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/repository"
@@ -69,7 +71,8 @@ func newAuthServiceForEmailBindWithRefreshCache(
) (*service.AuthService, service.UserRepository, *dbent.Client) {
t.Helper()
db, err := sql.Open("sqlite", "file:auth_service_email_bind?mode=memory&cache=shared")
dbName := fmt.Sprintf("file:auth_service_email_bind_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := sql.Open("sqlite", dbName)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
@@ -214,6 +217,143 @@ func TestAuthServiceBindEmailIdentity_RejectsExistingEmailOnAnotherUser(t *testi
require.Equal(t, 0, countProviderGrantRecords(t, client, sourceUser.ID, "email", "first_bind"))
}
func TestAuthServiceBindEmailIdentity_RejectsAliasOfExistingEmailOnAnotherUser(t *testing.T) {
cache := &emailBindCacheStub{
data: &service.VerificationCodeData{
Code: "123456",
CreatedAt: time.Now().UTC().Add(-10 * time.Minute),
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
},
}
svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
ctx := context.Background()
sourceUser := createEmailBindTestUser(
t,
client,
"source-user"+service.OIDCConnectSyntheticEmailDomain,
"source-user",
"old-hash",
)
createEmailBindTestUser(t, client, "zck.ioio123@gmail.com", "inbox-owner", "hash")
err := svc.SendEmailIdentityBindCode(ctx, sourceUser.ID, "zckioio123+new@gmail.com")
require.ErrorIs(t, err, service.ErrEmailExists)
require.Empty(t, cache.setEmails)
updatedUser, err := svc.BindEmailIdentity(
ctx,
sourceUser.ID,
"zckioio123+new@gmail.com",
"123456",
"new-password",
)
require.ErrorIs(t, err, service.ErrEmailExists)
require.Nil(t, updatedUser)
storedUser, err := client.User.Get(ctx, sourceUser.ID)
require.NoError(t, err)
require.Equal(t, "source-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email)
require.Equal(t, "old-hash", storedUser.PasswordHash)
}
func TestAuthServiceBindEmailIdentity_AllowsOnlyOneConcurrentAliasVariant(t *testing.T) {
cache := &emailBindCacheStub{
data: &service.VerificationCodeData{
Code: "123456",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
},
}
svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
ctx := context.Background()
unique := fmt.Sprintf("%d", time.Now().UnixNano())
first := createEmailBindTestUser(
t,
client,
"first-"+unique+service.OIDCConnectSyntheticEmailDomain,
"first-"+unique,
"old-hash",
)
second := createEmailBindTestUser(
t,
client,
"second-"+unique+service.OIDCConnectSyntheticEmailDomain,
"second-"+unique,
"old-hash",
)
start := make(chan struct{})
results := make(chan error, 2)
go func() {
<-start
_, err := svc.BindEmailIdentity(ctx, first.ID, "inbox-"+unique+"+one@gmail.com", "123456", "new-password")
results <- err
}()
go func() {
<-start
_, err := svc.BindEmailIdentity(ctx, second.ID, "inbox-"+unique+"+two@gmail.com", "123456", "new-password")
results <- err
}()
close(start)
var successes, conflicts int
for range 2 {
err := <-results
switch {
case err == nil:
successes++
case errors.Is(err, service.ErrEmailExists):
conflicts++
default:
t.Fatalf("unexpected bind error: %v", err)
}
}
require.Equal(t, 1, successes)
require.Equal(t, 1, conflicts)
boundCount, err := client.User.Query().
Where(dbuser.EmailIn(
"inbox-"+unique+"+one@gmail.com",
"inbox-"+unique+"+two@gmail.com",
)).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, boundCount)
}
func TestAuthServiceBindEmailIdentity_RejectsNewAliasWhenAnotherUserSharesCurrentUserInbox(t *testing.T) {
cache := &emailBindCacheStub{
data: &service.VerificationCodeData{
Code: "123456",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
},
}
svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
ctx := context.Background()
hashedPassword, err := svc.HashPassword("current-password")
require.NoError(t, err)
currentUser := createEmailBindTestUser(t, client, "inbox+own@gmail.com", "current", hashedPassword)
createEmailBindTestUser(t, client, "inbox+legacy@gmail.com", "legacy", "hash")
updatedUser, err := svc.BindEmailIdentity(
ctx,
currentUser.ID,
"inbox+new@gmail.com",
"123456",
"current-password",
)
require.ErrorIs(t, err, service.ErrEmailExists)
require.Nil(t, updatedUser)
storedUser, err := client.User.Get(ctx, currentUser.ID)
require.NoError(t, err)
require.Equal(t, "inbox+own@gmail.com", storedUser.Email)
}
func TestAuthServiceBindEmailIdentity_RollsBackWhenFirstBindDefaultsFail(t *testing.T) {
assigner := &flakyEmailBindDefaultSubAssignerStub{err: errors.New("temporary assign failure")}
cache := &emailBindCacheStub{
+31 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"math"
"strings"
"sync"
"time"
@@ -1370,16 +1371,43 @@ func (s *BillingService) computeTokenBreakdown(
// multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。
func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 {
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
cacheCreation5mTokens, cacheCreation1hTokens := normalizeCacheCreationBreakdown(tokens)
if cacheCreation5mTokens == 0 && cacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier
}
return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
return float64(cacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
float64(cacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
}
return float64(tokens.CacheCreationTokens) * price * multiplier
}
// normalizeCacheCreationBreakdown caps contradictory 5m/1h details at an explicitly
// positive aggregate while retaining their reported ratio as closely as integer tokens allow.
func normalizeCacheCreationBreakdown(tokens UsageTokens) (int, int) {
cacheCreation5mTokens := tokens.CacheCreation5mTokens
cacheCreation1hTokens := tokens.CacheCreation1hTokens
aggregate := tokens.CacheCreationTokens
if cacheCreation5mTokens < 0 {
cacheCreation5mTokens = 0
}
if cacheCreation1hTokens < 0 {
cacheCreation1hTokens = 0
}
if aggregate <= 0 || (cacheCreation5mTokens <= aggregate && cacheCreation1hTokens <= aggregate-cacheCreation5mTokens) {
return cacheCreation5mTokens, cacheCreation1hTokens
}
detailTotal := float64(cacheCreation5mTokens) + float64(cacheCreation1hTokens)
normalized5mTokens := math.Round(float64(aggregate) * float64(cacheCreation5mTokens) / detailTotal)
if normalized5mTokens >= float64(aggregate) {
cacheCreation5mTokens = aggregate
} else {
cacheCreation5mTokens = int(normalized5mTokens)
}
return cacheCreation5mTokens, aggregate - cacheCreation5mTokens
}
// calculatePerRequestCost 按次/图片计费
func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
units := input.UsageUnits
@@ -1384,6 +1384,122 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
}
func TestComputeCacheCreationCost_CapsContradictoryBreakdownAtAggregate(t *testing.T) {
svc := &BillingService{}
pricing := &ModelPricing{
SupportsCacheBreakdown: true,
CacheCreation5mPrice: 1,
CacheCreation1hPrice: 1,
}
tokens := UsageTokens{
CacheCreationTokens: 463184,
CacheCreation5mTokens: 463184,
CacheCreation1hTokens: 463184,
}
cost := svc.computeCacheCreationCost(pricing, tokens, 0, 1)
require.Equal(t, float64(tokens.CacheCreationTokens), cost,
"billed cache-creation token equivalent must not exceed the positive aggregate")
}
func TestNormalizeCacheCreationBreakdown_BillingSafetyInvariant(t *testing.T) {
tests := []struct {
name string
tokens UsageTokens
want5m int
want1h int
}{
{
name: "preserves ratio when capping",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
want5m: 60,
want1h: 40,
},
{
name: "details below aggregate unchanged",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 60},
want5m: 30,
want1h: 60,
},
{
name: "absent 5m detail unchanged",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation1hTokens: 60},
want5m: 0,
want1h: 60,
},
{
name: "absent 1h detail unchanged",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30},
want5m: 30,
want1h: 0,
},
{
name: "negative detail clamped",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 60},
want5m: 0,
want1h: 60,
},
{
name: "negative detail cannot hide oversized positive detail",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 150},
want5m: 0,
want1h: 100,
},
{
name: "integer boundary details capped without overflow",
tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: int(^uint(0) >> 1)},
want5m: 50,
want1h: 50,
},
{
name: "integer boundary aggregate avoids float conversion overflow",
tokens: UsageTokens{CacheCreationTokens: int(^uint(0) >> 1), CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: 1},
want5m: int(^uint(0) >> 1),
want1h: 0,
},
{
name: "zero aggregate unchanged",
tokens: UsageTokens{CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
want5m: 90,
want1h: 60,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got5m, got1h := normalizeCacheCreationBreakdown(tt.tokens)
require.Equal(t, tt.want5m, got5m)
require.Equal(t, tt.want1h, got1h)
})
}
}
func TestComputeCacheCreationCost_PreservesZeroDetailFallback(t *testing.T) {
svc := &BillingService{}
pricing := &ModelPricing{
SupportsCacheBreakdown: true,
CacheCreation5mPrice: 4e-6,
CacheCreation1hPrice: 5e-6,
}
tests := []struct {
name string
tokens UsageTokens
}{
{name: "zero details", tokens: UsageTokens{CacheCreationTokens: 100}},
{name: "one negative detail", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25}},
{name: "both negative details", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25, CacheCreation1hTokens: -75}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cost := svc.computeCacheCreationCost(pricing, tt.tokens, 0, 1)
require.InDelta(t, 100*4e-6, cost, 1e-12)
})
}
}
func TestCalculateCost_LargeTokenCount(t *testing.T) {
svc := newTestBillingService()
@@ -139,7 +139,9 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformGemini, true
case normalized == "grok" || strings.HasPrefix(normalized, "grok-"):
return PlatformGrok, true
case strings.HasPrefix(normalized, "kimi-"),
case normalized == "k3",
normalized == "k3-256k",
strings.HasPrefix(normalized, "kimi-"),
strings.HasPrefix(normalized, "moonshot-"):
return PlatformKimi, true
case strings.HasPrefix(normalized, "glm-"):
@@ -26,9 +26,13 @@ func TestDetectModelPlatform(t *testing.T) {
{name: "grok", model: "grok-4", platform: PlatformGrok, ok: true},
{name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true},
{name: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true},
{name: "kimi code bare k3", model: "K3", platform: PlatformKimi, ok: true},
{name: "kimi code bare k3 256k", model: "k3-256k", platform: PlatformKimi, ok: true},
{name: "kimi code provider prefix", model: "kimi-code/k3", platform: PlatformKimi, ok: true},
{name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true},
{name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true},
{name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true},
{name: "unknown k3 alias", model: "k3-preview", ok: false},
{name: "unknown", model: "llama-4-maverick", ok: false},
}
@@ -191,6 +191,22 @@ func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *te
require.Nil(t, decision.Route)
}
func TestCompositeRouteResolverDetectsKimiCodeBareModels(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
for _, model := range []string{"k3", "k3-256k", "kimi-code/k3"} {
t.Run(model, func(t *testing.T) {
decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages)
require.NoError(t, err)
require.True(t, decision.Matched)
require.Equal(t, CompositeRouteSourceDetector, decision.Source)
require.Equal(t, PlatformKimi, decision.TargetPlatform)
require.Equal(t, model, decision.UpstreamModel)
})
}
}
func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) {
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []CompositeModelRoute{
@@ -1295,21 +1295,20 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testin
}
func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) {
usage := &ClaudeUsage{
InputTokens: 10,
CacheCreation5mTokens: 2,
CacheCreation1hTokens: 6,
}
data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}`
usage := &ClaudeUsage{}
start := `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`
parseSSEUsagePassthrough(start, usage)
data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":463184,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`
parseSSEUsagePassthrough(data, usage)
require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens")
require.Equal(t, 5, usage.OutputTokens)
require.Equal(t, 8, usage.CacheCreationInputTokens)
require.Equal(t, 463184, usage.CacheCreationInputTokens)
require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens")
require.Equal(t, 1, usage.CacheCreation5mTokens)
require.Equal(t, 6, usage.CacheCreation1hTokens, "message_delta 中 0 值不应覆盖已有 1h 明细")
require.Equal(t, 463184, usage.CacheCreation5mTokens)
require.Equal(t, 0, usage.CacheCreation1hTokens)
}
func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) {
@@ -669,10 +669,10 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) {
cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens")
cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens")
if cc5m.Exists() && cc5m.Int() > 0 {
if cc5m.Exists() {
usage.CacheCreation5mTokens = int(cc5m.Int())
}
if cc1h.Exists() && cc1h.Int() > 0 {
if cc1h.Exists() {
usage.CacheCreation1hTokens = int(cc1h.Int())
}
}
@@ -82,20 +82,19 @@ func TestParseSSEUsage_DeltaOverwritesWithNonZero(t *testing.T) {
require.Equal(t, 60, usage.CacheReadInputTokens)
}
func TestParseSSEUsage_DeltaDoesNotResetCacheCreationBreakdown(t *testing.T) {
func TestParseSSEUsage_DeltaAuthoritativelyUpdatesCacheCreationBreakdown(t *testing.T) {
svc := newMinimalGatewayService()
usage := &ClaudeUsage{}
// 先在 message_start 中写入非零 5m/1h 明细
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`, usage)
require.Equal(t, 30, usage.CacheCreation5mTokens)
require.Equal(t, 70, usage.CacheCreation1hTokens)
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`, usage)
require.Equal(t, 463184, usage.CacheCreationInputTokens)
require.Equal(t, 0, usage.CacheCreation5mTokens)
require.Equal(t, 463184, usage.CacheCreation1hTokens)
// 后续 delta 带默认 0,不应覆盖已有非零值
svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":12,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0}}}`, usage)
require.Equal(t, 30, usage.CacheCreation5mTokens, "delta 的 0 值不应重置 5m 明细")
require.Equal(t, 70, usage.CacheCreation1hTokens, "delta 的 0 值不应重置 1h 明细")
require.Equal(t, 12, usage.OutputTokens)
svc.parseSSEUsage(`{"type":"message_delta","usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`, usage)
require.Equal(t, 463184, usage.CacheCreationInputTokens)
require.Equal(t, 463184, usage.CacheCreation5mTokens)
require.Equal(t, 0, usage.CacheCreation1hTokens)
}
func TestParseSSEUsage_InvalidJSON(t *testing.T) {
@@ -1237,11 +1237,11 @@ func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePat
patch.hasCacheReadInput = true
}
if cc, ok := usageObj["cache_creation"].(map[string]any); ok {
if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 {
if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists {
patch.cacheCreation5mTokens = v
patch.hasCacheCreation5m = true
}
if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 {
if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists {
patch.cacheCreation1hTokens = v
patch.hasCacheCreation1h = true
}
@@ -3587,12 +3587,20 @@ func cleanToolSchema(schema any) any {
if key == "$schema" || key == "$id" || key == "$ref" ||
key == "$defs" || key == "definitions" ||
key == "additionalProperties" || key == "patternProperties" || key == "minLength" ||
key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" {
key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" ||
key == "deprecated" {
continue
}
// 递归清理嵌套对象
cleaned[key] = cleanToolSchema(value)
}
if enum, ok := cleaned["enum"].([]any); ok {
if normalized, ok := normalizeGeminiEnum(enum); ok {
cleaned["enum"] = normalized
} else {
delete(cleaned, "enum")
}
}
// 规范化 type 字段为大写
if typeVal, ok := cleaned["type"].(string); ok {
cleaned["type"] = strings.ToUpper(typeVal)
@@ -3627,6 +3635,28 @@ func cleanToolSchema(schema any) any {
}
}
func normalizeGeminiEnum(values []any) ([]any, bool) {
normalized := make([]any, len(values))
for i, value := range values {
if stringValue, ok := value.(string); ok {
normalized[i] = stringValue
continue
}
switch value.(type) {
case nil, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
encoded, err := json.Marshal(value)
if err != nil {
return nil, false
}
normalized[i] = string(encoded)
default:
return nil, false
}
}
return normalized, true
}
func incrementIntegralSchemaBound(value any) (any, bool) {
switch v := value.(type) {
case float64:
@@ -471,6 +471,44 @@ func TestCleanToolSchema_DropsAmbiguousExclusiveMinimumWithoutConversion(t *test
}
}
func TestCleanToolSchema_RemovesNestedDeprecatedAndNormalizesMixedScalarEnum(t *testing.T) {
schema := map[string]any{
"anyOf": []any{
map[string]any{
"type": "string",
"deprecated": true,
},
map[string]any{
"enum": []any{"enabled", false, float64(1), nil},
},
},
}
cleaned, ok := cleanToolSchema(schema).(map[string]any)
require.True(t, ok)
anyOf, ok := cleaned["anyOf"].([]any)
require.True(t, ok)
require.Len(t, anyOf, 2)
deprecatedSchema, ok := anyOf[0].(map[string]any)
require.True(t, ok)
require.NotContains(t, deprecatedSchema, "deprecated")
enumSchema, ok := anyOf[1].(map[string]any)
require.True(t, ok)
require.Equal(t, []any{"enabled", "false", "1", "null"}, enumSchema["enum"])
}
func TestCleanToolSchema_DropsEnumWithNonScalarValue(t *testing.T) {
schema := map[string]any{
"enum": []any{"valid", map[string]any{"invalid": true}},
}
cleaned, ok := cleanToolSchema(schema).(map[string]any)
require.True(t, ok)
require.NotContains(t, cleaned, "enum")
}
func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
tools := []any{
map[string]any{
@@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account *
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", grokUpstreamUserAgent)
req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent())
if account.IsGrokOAuth() {
applyGrokCLIHeaders(req.Header)
if isGrokCLIProxyTarget(req.URL.String()) {
@@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) {
require.NoError(t, svc.syncGrokObservedModels(context.Background(), account))
require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String())
require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode"))
require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID"))
require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email"))
@@ -10,10 +10,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// grokUpstreamUserAgent is kept for compatibility with older Grok request
// tests. Current requests use the pinned default UA from this package.
const grokUpstreamUserAgent = "sub2api-grok/1.0"
// Fixed CLI identity aliases — single source of truth is internal/pkg/xai.
const (
grokClientVersionHeader = xai.CLIStableVersion
@@ -28,6 +28,49 @@ type OpenAIOAuth429FailoverState struct {
grokOAuth429FollowupPending bool
}
type openAIOAuth429Disposition uint8
const (
openAIOAuth429Transient openAIOAuth429Disposition = iota
openAIOAuth429Quota5h
openAIOAuth429Quota7d
openAIOAuth429QuotaReset
)
// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。明确窗口达到
// 100% 时以该窗口为准;没有 100% 标记但包含重置头时,沿用 v179 的兼容语义,
// 仍视为配额限流信号。
func classifyOpenAIOAuth429(headers http.Header, responseBody []byte) (openAIOAuth429Disposition, *time.Time) {
if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil {
if normalized := snapshot.Normalize(); normalized != nil {
if normalized.Used7dPercent != nil && *normalized.Used7dPercent >= 100 {
if normalized.Reset7dSeconds != nil {
now := time.Now()
resetAt := now.Add(time.Duration(*normalized.Reset7dSeconds) * time.Second)
return openAIOAuth429Quota7d, &resetAt
}
return openAIOAuth429Quota7d, nil
}
if normalized.Used5hPercent != nil && *normalized.Used5hPercent >= 100 {
if normalized.Reset5hSeconds != nil {
now := time.Now()
resetAt := now.Add(time.Duration(*normalized.Reset5hSeconds) * time.Second)
return openAIOAuth429Quota5h, &resetAt
}
return openAIOAuth429Quota5h, nil
}
}
}
if resetAt := calculateOpenAI429ResetTime(headers); resetAt != nil {
return openAIOAuth429QuotaReset, resetAt
}
if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil {
resetAt := time.Unix(*resetUnix, 0)
return openAIOAuth429QuotaReset, &resetAt
}
return openAIOAuth429Transient, nil
}
func openAIAccountStateContext(ctx context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
@@ -165,19 +208,16 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
return
}
s.recordOpenAIOAuth429()
if s.openAIOAuth429RetryWindowActive(account) {
disposition, resetAt := classifyOpenAIOAuth429(headers, responseBody)
if disposition == openAIOAuth429Transient && s.openAIOAuth429RetryWindowActive(account) {
return
}
cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown)
if s.rateLimitService != nil {
if resetAt := s.rateLimitService.calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(time.Now()) {
cooldownUntil = *resetAt
} else if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil {
if resetAt := time.Unix(*resetUnix, 0); resetAt.After(time.Now()) {
cooldownUntil = resetAt
}
} else if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 {
if resetAt != nil && resetAt.After(time.Now()) {
cooldownUntil = *resetAt
} else if s.rateLimitService != nil {
if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 {
cooldownUntil = time.Now().Add(cooldown)
}
}
@@ -186,9 +226,17 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
}
func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool {
return s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, nil, nil)
}
func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account *Account, statusCode int, shouldDisable bool, headers http.Header, responseBody []byte) bool {
if shouldDisable || statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) || account.IsShadow() {
return false
}
disposition, _ := classifyOpenAIOAuth429(headers, responseBody)
if disposition != openAIOAuth429Transient {
return false
}
// markOpenAIOAuth429RateLimited parks the account once the window expires.
// Do not accidentally create a fresh window after that transition.
if s.isOpenAIAccountRuntimeBlocked(account) {
@@ -199,10 +247,14 @@ func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *A
// ShouldRetryOpenAIOAuth429 lets RateLimitService defer persistent account
// cooldown until the gateway's same-account retry window is exhausted.
func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, _ http.Header, _ []byte) bool {
func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, headers http.Header, responseBody []byte) bool {
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() || s.isOpenAIAccountRuntimeBlocked(account) {
return false
}
disposition, _ := classifyOpenAIOAuth429(headers, responseBody)
if disposition != openAIOAuth429Transient {
return false
}
return s.openAIOAuth429RetryWindowActive(account)
}
@@ -14,7 +14,7 @@ import (
)
type oauth429RateLimitRepo struct {
AccountRepository
mockAccountRepoForGemini
setRateLimitedCalls int
lastRateLimitedUntil time.Time
}
@@ -62,6 +62,38 @@ func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) {
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
}
func TestOpenAI429FastPath_BlocksOAuthImmediatelyWhenSevenDayQuotaIsExhausted(t *testing.T) {
repo := &oauth429RateLimitRepo{}
rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
rateLimits.SetAccountRuntimeBlocker(svc)
account := &Account{ID: 423, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
headers := http.Header{}
headers.Set("x-codex-primary-used-percent", "100")
headers.Set("x-codex-primary-reset-after-seconds", "604800")
headers.Set("x-codex-primary-window-minutes", "10080")
headers.Set("x-codex-secondary-used-percent", "20")
headers.Set("x-codex-secondary-reset-after-seconds", "3600")
headers.Set("x-codex-secondary-window-minutes", "300")
shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded"}}`))
require.False(t, shouldDisable)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.Equal(t, 1, repo.setRateLimitedCalls)
require.Greater(t, time.Until(repo.lastRateLimitedUntil), 6*24*time.Hour)
require.False(t, svc.ShouldRetryOpenAIOAuth429(account, headers, nil))
}
func TestOpenAI429FastPath_RetriesOAuthWhenNoQuotaSignalExists(t *testing.T) {
svc := &OpenAIGatewayService{}
account := &Account{ID: 424, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
headers := http.Header{"Retry-After": []string{"1"}}
require.True(t, svc.ShouldRetryOpenAIOAuth429(account, headers, []byte(`{"error":{"type":"rate_limit_error","message":"try again"}}`)))
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestOpenAIStream429IgnoresSuccessfulQuotaSnapshotHeaders(t *testing.T) {
repo := &oauth429RateLimitRepo{}
rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
@@ -114,6 +146,33 @@ func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) {
require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline))
}
func TestOpenAI429FastPath_OpenCodeGoUsageLimitUsesMessageResetDuration(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
svc := &OpenAIGatewayService{rateLimitService: rateLimitService}
rateLimitService.SetAccountRuntimeBlocker(svc)
account := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
body := []byte(`{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_test/go"},"metadata":{"workspace":"wrk_test","limitName":"5 hour"}}`)
before := time.Now()
shouldDisable := svc.handleOpenAIAccountUpstreamError(
context.Background(),
account,
http.StatusTooManyRequests,
http.Header{},
body,
)
after := time.Now()
require.False(t, shouldDisable)
require.Equal(t, 1, repo.rateLimitCalls)
require.Equal(t, account.ID, repo.lastRateLimitID)
expectedResetAfter := 4*time.Hour + 59*time.Minute
require.False(t, repo.lastRateLimitReset.Before(before.Add(expectedResetAfter-time.Second)))
require.False(t, repo.lastRateLimitReset.After(after.Add(expectedResetAfter)))
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
// TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429,
// 不得按 global x-codex-* 信号写内存运行时熔断(否则 spark 被冷却到 global reset、单影子场景无可用账号)。
func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) {
@@ -137,7 +196,7 @@ func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) {
svc.markOpenAIOAuth429RateLimited(context.Background(), normal, headers, nil)
require.False(t, svc.isOpenAIAccountRuntimeBlocked(shadow), "spark shadow must not be runtime-blocked by /responses global 429")
require.False(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account stays schedulable during its retry window")
require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account with an exhausted 5h window must be paused")
}
func TestOpenAIRuntimeBlock_AppliesToOpenAIAPIKeyWhenRateLimitServiceStopsScheduling(t *testing.T) {
@@ -91,7 +91,7 @@ func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) {
require.Equal(t, codexPythonToolAlias, tool["name"])
apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(apiKeyBody), string(normalized))
@@ -96,7 +96,7 @@ func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *test
body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false)
require.NoError(t, err)
require.True(t, changed)
items := gjson.GetBytes(normalized, "input").Array()
@@ -271,7 +271,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
}, false)
require.NoError(t, err)
require.True(t, changed)
@@ -285,7 +285,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere
second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
}, false)
require.NoError(t, err)
require.False(t, changedAgain)
require.JSONEq(t, string(normalized), string(second))
@@ -186,6 +186,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
// 记录本次实际选择的协议端点,供错误日志和用量日志在没有
// OpenAIForwardResult(例如 503/传输失败)时使用。每次发送都覆盖,
// 避免 Gin context 在账号 failover 尝试之间残留旧端点。
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
upstreamReq = upstreamReq.WithContext(WithHTTPUpstreamProfile(upstreamReq.Context(), HTTPUpstreamProfileOpenAI))
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+bearerToken)
@@ -60,6 +60,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
ClearActualOpenAIUpstreamEndpoint(c)
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
}
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
@@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
customUA := account.GetOpenAIUserAgent()
if customUA == "" && account.IsGrokOAuth() {
customUA = "sub2api-grok/1.0"
customUA = defaultGrokUpstreamUserAgent()
}
resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity)
if err != nil {
@@ -11,6 +11,7 @@ package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -142,3 +143,112 @@ func TestHandle403_CNProviderStructured403TempUnschedulableFirstHit(t *testing.T
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, "(1/3)")
}
func TestIsCNProviderConcurrencyLimit403_ExactClassification(t *testing.T) {
kimi := &Account{Platform: PlatformKimi}
require.True(t, isCNProviderConcurrencyLimit403(kimi, kimiConcurrentRequestLimitMessage))
require.True(t, isCNProviderConcurrencyLimit403(kimi, " "+kimiConcurrentRequestLimitMessage+"\n"))
for name, tc := range map[string]struct {
account *Account
message string
}{
"permission denied": {kimi, "You do not have permission to access this resource."},
"generic concurrency wording": {kimi, "concurrent request limit reached"},
"near match missing punctuation": {kimi, "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again"},
"other CN provider": {&Account{Platform: PlatformZhipu}, kimiConcurrentRequestLimitMessage},
"non CN provider": {&Account{Platform: PlatformOpenAI}, kimiConcurrentRequestLimitMessage},
"nil account": {nil, kimiConcurrentRequestLimitMessage},
} {
t.Run(name, func(t *testing.T) {
require.False(t, isCNProviderConcurrencyLimit403(tc.account, tc.message))
})
}
}
func TestHandle403_OtherCNProviderWithKimiConcurrencyMessageUsesNormalPolicy(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}
blocker := &runtimeBlockRecorder{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
service.SetAccountRuntimeBlocker(blocker)
account := &Account{ID: 405, Platform: PlatformZhipu, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(), account, http.StatusForbidden, http.Header{},
[]byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`),
)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-Kimi CN provider must retain the normal permanent-error policy")
require.Equal(t, 0, repo.tempCalls)
require.Empty(t, counter.counts, "normal CN 403 policy must consume the counter result")
require.Equal(t, []string{"auth_error"}, blocker.reasons, "the Kimi-specific runtime block must not apply")
}
func TestHandle403_CNProviderConcurrencyLimitAlwaysUsesTemporaryCooldown(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}
blocker := &runtimeBlockRecorder{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
service.SetAccountRuntimeBlocker(blocker)
account := &Account{ID: 403, Platform: PlatformKimi, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(), account, http.StatusForbidden, http.Header{},
[]byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`),
)
require.True(t, shouldDisable, "the request must still fail over to another account")
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix)
require.Equal(t, []int64{openAI403DisableThreshold}, counter.counts, "transient concurrency 403 must bypass the permanent-error counter")
require.Len(t, blocker.accounts, 1)
require.Equal(t, cnConcurrencyLimitReasonPrefix, blocker.reasons[0])
require.True(t, blocker.until[0].After(time.Now()))
}
func TestHandle403_KimiConcurrencyLimitRepositoryFailureKeepsRuntimeBlock(t *testing.T) {
repo := &rateLimitAccountRepoStub{tempErr: errors.New("repository unavailable")}
counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}
blocker := &runtimeBlockRecorder{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
service.SetAccountRuntimeBlocker(blocker)
account := &Account{ID: 406, Platform: PlatformKimi, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(), account, http.StatusForbidden, http.Header{},
[]byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`),
)
require.True(t, shouldDisable, "the current request must fail over even when persistence fails")
require.Equal(t, 1, repo.tempCalls, "the temporary cooldown should still be persisted when possible")
require.Equal(t, 0, repo.setErrorCalls, "persistence failure must not fall back to permanent account error")
require.Equal(t, []int64{openAI403DisableThreshold}, counter.counts, "persistence failure must not enter the permanent-error counter path")
require.Len(t, blocker.accounts, 1, "the in-memory runtime block must survive repository failure")
require.Same(t, account, blocker.accounts[0])
require.Equal(t, cnConcurrencyLimitReasonPrefix, blocker.reasons[0])
require.True(t, blocker.until[0].After(time.Now()))
}
func TestHandle403_CNProviderNearMatchRetainsNormalPermanentErrorPolicy(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := &Account{ID: 404, Platform: PlatformKimi, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(), account, http.StatusForbidden, http.Header{},
[]byte(`{"error":{"message":"You've reached your concurrent request limit. Please contact support."}}`),
)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-exact 403 must retain existing permission/auth protection")
require.Equal(t, 0, repo.tempCalls)
}
@@ -20,6 +20,10 @@ 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)
ClearActualOpenAIUpstreamEndpoint(c)
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
}
clearGrokResponsesClientToolMapping(c)
clearOpenAIResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
@@ -76,8 +80,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
body = reasoningBody
}
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader))
if responsesLite {
liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account)
if liteErr != nil {
param := "tools"
var validationErr *openAIResponsesLiteValidationError
@@ -151,7 +156,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
}
if account.IsOpenAIApiKey() {
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil {
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil {
return nil, normalizeErr
} else if changed {
body = normalized
@@ -22,14 +22,13 @@ import (
const (
grokComposerImageBridgeVisionModel = "grok-build-0.1"
grokComposerImageBridgeMaxOutputTokens = 512
// grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers).
grokCLIVersion = xai.CLIClientVersion
grokDefaultResponsesModel = "grok-4.5"
grokRateLimitFallbackCooldown = 2 * time.Minute
grokRateLimitRepeatCooldown = 10 * time.Minute
grokRateLimitSustainedCooldown = 30 * time.Minute
grokRateLimitMaxAdaptiveCooldown = time.Hour
grokRateLimitBackoffQuietPeriod = time.Hour
grokCLIVersion = xai.CLIClientVersion
grokDefaultResponsesModel = "grok-4.5"
grokRateLimitFallbackCooldown = 2 * time.Minute
grokRateLimitRepeatCooldown = 10 * time.Minute
grokRateLimitSustainedCooldown = 30 * time.Minute
grokRateLimitMaxAdaptiveCooldown = time.Hour
grokRateLimitBackoffQuietPeriod = time.Hour
)
func (s *OpenAIGatewayService) forwardGrokResponses(
@@ -828,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing.
require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String())
require.Equal(t, "Bearer api-key", req.Header.Get("Authorization"))
require.Empty(t, req.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent"))
}
func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) {
@@ -1132,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) {
require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody))
require.Equal(t, http.StatusOK, recorder.Code)
require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String())
@@ -1646,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) {
require.Equal(t, http.MethodGet, upstream.lastReq.Method)
require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Empty(t, upstream.lastReq.Header.Get("Content-Type"))
require.Empty(t, upstream.lastBody)
require.Equal(t, http.StatusOK, recorder.Code)
@@ -1982,7 +1982,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) {
require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "resp_grok_api_key", result.ResponseID)
require.Equal(t, 2, result.Usage.InputTokens)
@@ -2259,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA
require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String())
require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization"))
require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
}
func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) {
@@ -34,6 +34,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
ClearActualOpenAIUpstreamEndpoint(c)
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
}
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
@@ -212,7 +212,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
}
if account != nil && account.IsOpenAI() {
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body)
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite)
if normalizeErr != nil {
return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
}
@@ -1677,7 +1678,14 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
},
})
retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message)
failoverErr := s.newOpenAIAccountFailoverError(account, statusCode, headers, payload, message, shouldDisable, retryableOnSameAccount)
// 流终止事件承载在 HTTP 200 内,外层响应头描述的是成功流状态,而不是语义上的
// 429 事件。仅在配额分类时忽略这些头;故障转移错误仍保留它们,使 Retry-After
// 和请求 ID 能继续传递给后续处理。
classificationHeaders := headers
if statusCode == http.StatusTooManyRequests {
classificationHeaders = nil
}
failoverErr := s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, headers, classificationHeaders, payload, message, shouldDisable, retryableOnSameAccount)
if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient {
return failoverErr
}
@@ -349,7 +349,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
}
normalized = next
}
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil {
return body, false, err
} else if removed {
normalized = next
@@ -361,13 +361,15 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
return normalized, true, nil
}
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) {
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) {
if responsesLite {
return body, false, nil
}
parallel := gjson.GetBytes(body, "parallel_tool_calls")
if !parallel.Exists() {
return body, false, nil
}
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() && len(tools.Array()) > 0 {
if openAIRequestBodyHasTools(body) {
return body, false, nil
}
normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
@@ -377,6 +379,22 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er
return normalized, true, nil
}
// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。
func openAIRequestBodyHasTools(body []byte) bool {
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
for _, item := range gjson.GetBytes(body, "input").Array() {
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
continue
}
if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
}
return false
}
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
@@ -924,7 +942,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
return normalized, true, nil
}
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) {
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) {
if account == nil || !account.IsOpenAI() {
return body, false, nil
}
@@ -938,7 +956,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
}
}
if account.IsOpenAIApiKey() {
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
return body, false, err
} else if normalizedParallel {
normalized = next
@@ -259,14 +259,37 @@ func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedCont
func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools)
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(withTools), string(normalized))
withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
}
// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。
func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
// 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。
emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
// Lite 请求即使没有工具,也必须保留已经固定的 false。
toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
}
@@ -39,11 +39,13 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c))
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
@@ -28,6 +28,7 @@ const (
)
var explicitOpenAIHeaderSessionNames = []string{
"session-id",
"session_id",
"conversation_id",
openCodeSessionAffinityHeader,
@@ -145,7 +146,7 @@ func (s *OpenAIGatewayService) GenerateExplicitSessionHash(c *gin.Context, body
// GenerateSessionHash generates a sticky-session hash for OpenAI requests.
//
// Priority:
// 1. Header: session_id
// 1. Header: session-id / session_id
// 2. Header: conversation_id
// 3. Header: x-session-affinity / x-session-id / x-opencode-session (OpenCode)
// 4. Header: x-conversation-id (CodeBuddy)
@@ -1173,6 +1174,11 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
}
// ============ Layer 1: Sticky session ============
// A healthy sticky account whose bounded wait queue is full may be used as a
// one-request capacity spillover in Layer 2. Keep that spillover temporary:
// rewriting the durable binding here would make a short burst migrate the
// whole conversation to a cache-cold account.
stickySpillover := false
if sessionHash != "" {
accountID := stickyAccountID
if accountID > 0 && !isExcluded(accountID) {
@@ -1214,6 +1220,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
MaxWaiting: cfg.StickySessionMaxWaiting,
})
}
stickySpillover = true
}
}
}
@@ -1365,7 +1372,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
if selectErr != nil {
return nil, true, selectErr
}
if sessionHash != "" && !gatewayProfitControlGateActive(ctx) {
if sessionHash != "" && !stickySpillover && !gatewayProfitControlGateActive(ctx) {
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
}
return selection, true, nil
@@ -1404,7 +1411,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
if selectErr != nil {
return nil, selectErr
}
if sessionHash != "" && !gatewayProfitControlGateActive(ctx) {
if sessionHash != "" && !stickySpillover && !gatewayProfitControlGateActive(ctx) {
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
}
return selection, nil
@@ -319,6 +319,16 @@ func SetActualOpenAIUpstreamEndpoint(c *gin.Context, endpoint string) {
}
}
// ClearActualOpenAIUpstreamEndpoint 清理当前转发尝试记录的端点。
// Handler 会在账号 failover 尝试间复用同一个 Gin context,因此每次尝试
// 都必须从无残留状态开始。
func ClearActualOpenAIUpstreamEndpoint(c *gin.Context) {
if c == nil {
return
}
c.Set(openAIUpstreamEndpointContextKey, "")
}
// GetActualOpenAIUpstreamEndpoint returns the endpoint recorded by the latest
// forwarding attempt in this request.
func GetActualOpenAIUpstreamEndpoint(c *gin.Context) string {
@@ -391,6 +391,7 @@ func TestOpenAIGatewayService_ClientSessionHeaderPriority(t *testing.T) {
name string
value string
}{
{name: "session-id", value: "codex-session"},
{name: "session_id", value: "generic-session"},
{name: "conversation_id", value: "generic-conversation"},
{name: openCodeSessionAffinityHeader, value: "opencode-affinity"},
@@ -416,6 +417,31 @@ func TestOpenAIGatewayService_ClientSessionHeaderPriority(t *testing.T) {
require.Equal(t, "body-session", svc.ExtractSessionID(c, body))
}
func TestOpenAIGatewayService_CodexSessionIDKeepsReconnectHashStable(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("session-id", "codex-reconnect-session")
svc := &OpenAIGatewayService{}
warmup := []byte(`{
"type":"response.create",
"model":"gpt-5.6-sol",
"generate":false,
"tools":[{"type":"custom","name":"exec"}],
"input":[{"role":"user","content":"warmup"}]
}`)
business := []byte(`{
"type":"response.create",
"model":"gpt-5.6-sol",
"input":[{"role":"user","content":"install codex"}]
}`)
require.Equal(t, svc.GenerateSessionHash(c, warmup), svc.GenerateSessionHash(c, business))
require.Equal(t, "codex-reconnect-session", svc.ExtractSessionID(c, business))
}
func TestOpenAIGatewayService_ClientSessionHeadersIgnorePerRequestIDs(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -1181,6 +1207,49 @@ func TestOpenAISelectAccountWithLoadAwareness_StickyWaitPlan(t *testing.T) {
}
}
func TestOpenAISelectAccountWithLoadAwareness_StickyCapacitySpilloverKeepsBinding(t *testing.T) {
sessionHash := "sticky-spillover"
groupID := int64(1)
repo := stubOpenAIAccountRepo{
accounts: []Account{
{ID: 1, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true, Concurrency: 6, Priority: 1, GroupIDs: []int64{groupID}},
{ID: 2, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true, Concurrency: 6, Priority: 1, GroupIDs: []int64{groupID}},
},
}
cache := &stubGatewayCache{
sessionBindings: map[string]int64{"openai:" + sessionHash: 1},
}
concurrencyCache := stubConcurrencyCache{
acquireResults: map[int64]bool{1: false, 2: true},
waitCounts: map[int64]int{1: 1},
loadMap: map[int64]*AccountLoadInfo{
1: {AccountID: 1, LoadRate: 100},
2: {AccountID: 2, LoadRate: 10},
},
}
cfg := &config.Config{RunMode: config.RunModeStandard}
cfg.Gateway.Scheduling.LoadBatchEnabled = true
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 1
svc := &OpenAIGatewayService{
accountRepo: repo,
cache: cache,
cfg: cfg,
concurrencyService: NewConcurrencyService(concurrencyCache),
}
selection, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, sessionHash, "gpt-4", nil)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(2), selection.Account.ID, "capacity spillover should use the other account for this request")
require.True(t, selection.Acquired)
require.Equal(t, int64(1), cache.sessionBindings["openai:"+sessionHash], "capacity spillover must not migrate the durable sticky binding")
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
}
func TestOpenAISelectAccountWithLoadAwareness_PrefersLowerLoad(t *testing.T) {
groupID := int64(1)
repo := stubOpenAIAccountRepo{
@@ -333,7 +333,20 @@ func (s *OpenAIGatewayService) newOpenAIAccountFailoverError(
shouldDisable bool,
retryableOnSameAccount bool,
) *UpstreamFailoverError {
oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccount(account, statusCode, shouldDisable)
return s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, responseHeaders, responseHeaders, responseBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
}
func (s *OpenAIGatewayService) newOpenAIAccountFailoverErrorWithClassificationHeaders(
account *Account,
statusCode int,
responseHeaders http.Header,
classificationHeaders http.Header,
responseBody []byte,
upstreamMsg string,
shouldDisable bool,
retryableOnSameAccount bool,
) *UpstreamFailoverError {
oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, classificationHeaders, responseBody)
failoverErr := newOpenAIUpstreamFailoverError(
statusCode,
responseHeaders,
+7 -6
View File
@@ -34,12 +34,13 @@ const (
openAIImagesGenerationsURL = "https://api.openai.com/v1/images/generations"
openAIImagesEditsURL = "https://api.openai.com/v1/images/edits"
openAIChatGPTStartURL = "https://chatgpt.com/"
openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files"
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download
openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part
openAIImagesResponsesMainModel = "gpt-5.4-mini"
openAIChatGPTStartURL = "https://chatgpt.com/"
openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files"
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download
openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part
openAIImagesResponsesMainModel = "gpt-5.4-mini"
openAIImagesVerbatimPromptInstructions = "When invoking the image_generation tool, use the user's image prompt verbatim. Do not rewrite, expand, summarize, embellish, translate, normalize punctuation, or add or remove visual details or constraints. Preserve the original language, wording, capitalization, quotes, and punctuation exactly."
)
type OpenAIImagesCapability string
@@ -356,6 +356,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st
req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel)
req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions)
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
@@ -2011,6 +2011,21 @@ func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) {
require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String())
}
func TestBuildOpenAIImagesResponsesRequest_RequiresVerbatimUserPrompt(t *testing.T) {
prompt := "画一个蓝色马克杯,杯身只写“SkelOT”,保持大小写;白色背景,不要增加其他文字。"
parsed := &OpenAIImagesRequest{
Endpoint: openAIImagesGenerationsEndpoint,
Model: "gpt-image-2",
Prompt: prompt,
N: 1,
}
body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2")
require.NoError(t, err)
require.Equal(t, openAIImagesVerbatimPromptInstructions, gjson.GetBytes(body, "instructions").String())
require.Equal(t, prompt, gjson.GetBytes(body, "input.0.content.0.text").String())
}
func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) {
body := []byte(
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" +
@@ -55,7 +55,7 @@ func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) {
body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth})
oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String())
@@ -63,7 +63,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthField
require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field)
}
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(body), string(apiKeyBody))
@@ -81,7 +81,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI
if oauth {
accountType = AccountTypeOAuth
}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String())
@@ -105,7 +105,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseRepl
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
})
}, false)
require.NoError(t, err)
require.True(t, changed)
@@ -141,13 +141,13 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) {
body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`)
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
}
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(body), string(apiKeyBody))
@@ -156,7 +156,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccount
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) {
body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`)
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
@@ -1,7 +1,6 @@
package service
import (
"encoding/json"
"fmt"
"reflect"
"strings"
@@ -101,9 +100,11 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
}
func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) {
parallel := reqBody["parallel_tool_calls"]
if !openAIResponsesLiteHasTools(reqBody) {
return changed, nil
parallel, exists := reqBody["parallel_tool_calls"]
if exists {
if _, ok := parallel.(bool); !ok {
return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
}
}
if parallel == false {
return changed, nil
@@ -112,23 +113,6 @@ func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed
return true, nil
}
func openAIResponsesLiteHasTools(reqBody map[string]any) bool {
if tools, ok := reqBody["tools"].([]any); ok && len(tools) > 0 {
return true
}
input, _ := reqBody["input"].([]any)
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
continue
}
if tools, ok := item["tools"].([]any); ok && len(tools) > 0 {
return true
}
}
return false
}
func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
rawReasoning, exists := reqBody["reasoning"]
if !exists || rawReasoning == nil {
@@ -254,7 +238,7 @@ func openAIResponsesLiteToolIdentityForError(rawTool any) string {
func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) {
var requestBody map[string]any
if err := json.Unmarshal(body, &requestBody); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil {
return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
}
changed, err := normalizeOpenAIResponsesLiteTools(requestBody)
@@ -267,3 +251,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error)
}
return rebuilt, true, nil
}
func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) {
var requestBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil {
return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
}
changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false)
if err != nil || !changed {
return body, false, err
}
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
}
return rebuilt, true, nil
}
func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) {
if account == nil || !account.IsOpenAI() {
return body, false, nil
}
if account.IsOpenAIOAuthLike() {
return normalizeOpenAIResponsesLiteToolsPayload(body)
}
return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body)
}
@@ -201,17 +201,33 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi
}
}
func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) {
reqBody := map[string]any{
"reasoning": map[string]any{"context": "all_turns"},
"parallel_tool_calls": true,
func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) {
tests := []struct {
name string
parallel any
include bool
wantChanged bool
}{
{name: "字段缺失", wantChanged: true},
{name: "值为 true", parallel: true, include: true, wantChanged: true},
{name: "值为 false", parallel: false, include: true, wantChanged: false},
}
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}}
if tt.include {
reqBody["parallel_tool_calls"] = tt.parallel
}
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, true, reqBody["parallel_tool_calls"])
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
require.NoError(t, err)
require.Equal(t, tt.wantChanged, changed)
require.Contains(t, reqBody, "parallel_tool_calls")
require.Equal(t, false, reqBody["parallel_tool_calls"])
})
}
}
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) {
@@ -336,6 +352,34 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t
require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool())
}
func TestNormalizeOpenAIResponsesLitePayloads_PreserveLargeSequence(t *testing.T) {
body := []byte(`{
"type":"response.create",
"sequence":900719925474099312345,
"tools":[{"type":"function","name":"lookup"}],
"parallel_tool_calls":true
}`)
tests := []struct {
name string
normalize func([]byte) ([]byte, bool, error)
}{
{name: "OAuth-like tools normalization", normalize: normalizeOpenAIResponsesLiteToolsPayload},
{name: "API key parallel normalization", normalize: normalizeOpenAIResponsesLiteParallelToolCallsPayload},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
updated, changed, err := tt.normalize(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "900719925474099312345", gjson.GetBytes(updated, "sequence").Raw)
require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool())
})
}
}
func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.6-terra",
@@ -453,3 +497,118 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
})
}
}
func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(t *testing.T) {
gin.SetMode(gin.TestMode)
accountCases := []struct {
name string
accountType string
credentials map[string]any
}{
{name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}},
{name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}},
}
parallelCases := []struct {
name string
field string
}{
{name: "字段缺失"},
{name: "值为 true", field: `,"parallel_tool_calls":true`},
{name: "值为 false", field: `,"parallel_tool_calls":false`},
}
for _, accountCase := range accountCases {
for _, passthrough := range []bool{false, true} {
mode := "managed"
if passthrough {
mode = "passthrough"
}
for _, parallelCase := range parallelCases {
name := accountCase.name + "/" + mode + "/" + parallelCase.name
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set(responsesLiteHeader, "true")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
"data: [DONE]\n\n",
)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType,
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Credentials: accountCase.credentials,
Extra: map[string]any{"openai_passthrough": passthrough},
}
body := []byte(`{
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
"reasoning":{"effort":"high","context":"current_turn"},
"input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + `
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody))
})
}
}
}
}
func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, passthrough := range []bool{false, true} {
name := "managed"
if passthrough {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set(responsesLiteHeader, "true")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
"data: [DONE]\n\n",
)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Credentials: map[string]any{"api_key": "sk-test"},
Extra: map[string]any{"openai_passthrough": passthrough},
}
body := []byte(`{
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"parallel_tool_calls":true,
"input":[{"type":"message","role":"user","content":"hello"}]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray())
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
})
}
}
@@ -271,18 +271,56 @@ func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (in
return 0, false
}
// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the
// upstream rejected, and the status of every other input item sharing the
// rejected item's type.
//
// The upstream names one offending index per response, but a replayed
// conversation routinely carries dozens of items of the same type, each with a
// status its schema does not accept. Clearing one index per round trip would
// need one retry per item and exhaust the bounded retry budget long before the
// request could succeed. Items of other types keep their status: the rejection
// only proves that this type has no status field.
func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
if !gjson.GetBytes(body, itemPath).IsObject() {
rejected := gjson.GetBytes(body, itemPath)
if !rejected.IsObject() {
return nil, "", false, nil
}
statusPath := itemPath + ".status"
if !gjson.GetBytes(body, statusPath).Exists() {
if !gjson.GetBytes(body, itemPath+".status").Exists() {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
retryBody := body
cleared := 0
rejectedType := strings.TrimSpace(rejected.Get("type").String())
if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() {
// Deleting a field never shifts array indexes, so positions read from
// the original body stay valid against the rewritten one.
for itemIndex, item := range input.Array() {
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType {
continue
}
statusPath := fmt.Sprintf("input.%d.status", itemIndex)
if !gjson.GetBytes(retryBody, statusPath).Exists() {
continue
}
next, err := sjson.DeleteBytes(retryBody, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err)
}
retryBody = next
cleared++
}
}
if cleared == 0 {
// The rejected item carries no type to match on; fall back to clearing
// just the index the upstream named.
next, err := sjson.DeleteBytes(retryBody, itemPath+".status")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
}
retryBody = next
}
return retryBody, "indexed status parameter rejection", true, nil
}
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -681,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response
Body: io.NopCloser(strings.NewReader(body)),
}
}
// A replayed conversation carries many items of the same type, each with a
// status the upstream schema rejects. One rejection must clear all of them:
// clearing one index per round trip exhausts the bounded retry budget.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) {
input := make([]string, 0, 12)
for i := 0; i < 10; i++ {
input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`)
}
input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`)
body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`)
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.NotEmpty(t, reason)
for i := 0; i < 10; i++ {
require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(),
"every tool_search_output must lose its status in a single retry, index %d did not", i)
require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(),
"unrelated fields must survive")
}
require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(),
"a different item type keeps its status: the rejection only proves this type has none")
}
// The rejected item may carry no type to match on.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) {
body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`)
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String())
require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
}
@@ -497,7 +497,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
}, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
@@ -508,7 +508,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformGrok,
Type: AccountTypeAPIKey,
})
}, false)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(normalized))
@@ -262,7 +262,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
normalized = capped
}
}
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account); compatibilityErr != nil {
responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized)
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr)
} else if compatibilityChanged {
normalized = compatibilityBody
@@ -320,8 +321,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if accountScoped {
normalized = accountScopedPayload
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
if responsesLite {
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account)
if liteErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
coderws.StatusPolicyViolation,
@@ -1361,7 +1361,11 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
}()
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
"type":"response.create","model":"gpt-5.1","stream":false,
"parallel_tool_calls":true,
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}
}`))
cancelWrite()
require.NoError(t, err)
@@ -1402,6 +1406,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
}
require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游")
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) {
@@ -65,7 +65,10 @@ func resolveOpenAIWSSessionHeaders(c *gin.Context, promptCacheKey string) openAI
ConversationSource: "none",
}
if c != nil && c.Request != nil {
if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" {
if sessionID := strings.TrimSpace(c.Request.Header.Get("session-id")); sessionID != "" {
resolution.SessionID = sessionID
resolution.SessionSource = "header_session-id"
} else if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" {
resolution.SessionID = sessionID
resolution.SessionSource = "header_session_id"
}
@@ -0,0 +1,37 @@
package service
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestResolveOpenAIWSSessionHeadersPrefersCodexHyphenHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("session-id", "codex-session")
c.Request.Header.Set("session_id", "legacy-session")
resolution := resolveOpenAIWSSessionHeaders(c, "prompt-cache")
require.Equal(t, "codex-session", resolution.SessionID)
require.Equal(t, "header_session-id", resolution.SessionSource)
}
func TestResolveOpenAIWSSessionHeadersFallsBackToLegacyHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("session_id", "legacy-session")
resolution := resolveOpenAIWSSessionHeaders(c, "prompt-cache")
require.Equal(t, "legacy-session", resolution.SessionID)
require.Equal(t, "header_session_id", resolution.SessionSource)
}
@@ -349,6 +349,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
LoweredTools: loweredTools,
})
}
if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) {
liteBody, liteChanged, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account)
if liteErr != nil {
return nil, fmt.Errorf("normalize responses Lite payload: %w", liteErr)
}
if liteChanged {
body = liteBody
}
}
buildUpstreamRequest := func(requestBody []byte) (*http.Request, error) {
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
@@ -1239,7 +1239,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) {
Concurrency: 1,
Status: StatusActive,
}
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`)
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","parallel_tool_calls":true}`)
type bridgeResult struct {
result *OpenAIForwardResult
@@ -1325,6 +1325,8 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) {
require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists())
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
}
func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T) {
@@ -679,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
if err := validateOpenAIWSBearerToken(account, token); err != nil {
return err
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) {
liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage)
if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) {
liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account)
if liteErr != nil {
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
}
@@ -735,7 +735,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) {
firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel)
}
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil {
firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage)
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil {
return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr)
} else if compatibilityChanged {
firstClientMessage = normalized
@@ -984,8 +985,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
}()
}
responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload)
if isResponseCreate {
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil {
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr)
} else if compatibilityChanged {
payload = normalized
@@ -1011,8 +1013,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
}
if isResponseCreate {
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload)
if responsesLite {
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account)
if liteErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
}
@@ -190,13 +190,18 @@ func startPassthroughLifecycleServer(
}
func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn {
t.Helper()
return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`)
}
func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn {
t.Helper()
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
cancelDial()
require.NoError(t, err)
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))
cancelWrite()
require.NoError(t, err)
return clientConn
@@ -221,6 +226,35 @@ func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughCo
}
}
func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{
"type":"response.create","model":"gpt-5.1","stream":false,
"parallel_tool_calls":true,
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}
}`)
defer func() { _ = clientConn.CloseNow() }()
upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second)
require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody))
event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done"))
select {
case <-serverErr:
case <-time.After(3 * time.Second):
t.Fatal("Lite 首帧测试等待 passthrough 退出超时")
}
}
func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) {
clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)}
clientFrameConn.markTurnCompleted()
@@ -26,6 +26,33 @@ const cnBalanceExtraSuffixLow = "balance_low"
// 其他子系统(阈值/限流/401)写入的临时停调。
const cnBalanceLowReasonPrefix = "cn_balance_low"
const kimiConcurrentRequestLimitMessage = "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."
const cnConcurrencyLimitReasonPrefix = "cn_concurrency_limit"
func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool {
return account != nil && account.Platform == PlatformKimi &&
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
}
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
ctx context.Context,
account *Account,
) {
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info("cn_provider_concurrency_limited",
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
}
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
func cnBalanceLowReason(upstreamMsg string) string {
if upstreamMsg = strings.TrimSpace(upstreamMsg); upstreamMsg != "" {
+81 -3
View File
@@ -79,6 +79,10 @@ const (
var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`)
var openCodeGoUsageLimitResetPattern = regexp.MustCompile(`(?i)\bresets\s+in\s+`)
var openCodeGoUsageLimitDurationPartPattern = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)\b`)
const (
openAI403CooldownMinutesDefault = 10
openAI403DisableThreshold = 3
@@ -932,6 +936,13 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
if account.Platform == PlatformAntigravity {
return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody)
}
// Kimi reports its transient per-account concurrency/business limit as a 403.
// Keep the normal 403 failover signal (true), but never feed this exact message
// into the escalating 403 counter that can permanently mark the account error.
if isCNProviderConcurrencyLimit403(account, upstreamMsg) {
s.handleCNProviderConcurrencyLimit403(ctx, account)
return true
}
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
@@ -1629,7 +1640,7 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou
notifyOpenAIAutoReset(account.ID)
}
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳
// OpenAI 的 usage_limit_reached 错误格式:
//
// {
@@ -1651,9 +1662,9 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 {
return nil
}
// 检查是否为 usage_limit_reached 或 rate_limit_exceeded 类型
// 检查是否为已知的账号用量限制类型。
errType, _ := errObj["type"].(string)
if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" {
if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" && errType != "GoUsageLimitError" {
return nil
}
@@ -1680,9 +1691,76 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 {
}
}
// OpenCode Go subscriptions expose the reset only in a human-readable message,
// for example: "Weekly usage limit reached. Resets in 2 days."
if errType == "GoUsageLimitError" {
message, _ := errObj["message"].(string)
if resetAfter := parseOpenCodeGoUsageLimitResetDuration(message); resetAfter > 0 {
ts := time.Now().Add(resetAfter).Unix()
return &ts
}
}
return nil
}
func parseOpenCodeGoUsageLimitResetDuration(message string) time.Duration {
resetPrefix := openCodeGoUsageLimitResetPattern.FindStringIndex(message)
if resetPrefix == nil {
return 0
}
remainder := message[resetPrefix[1]:]
var total time.Duration
for {
remainder = strings.TrimSpace(remainder)
matches := openCodeGoUsageLimitDurationPartPattern.FindStringSubmatchIndex(remainder)
if matches == nil {
break
}
value, err := strconv.ParseFloat(remainder[matches[2]:matches[3]], 64)
if err != nil || value <= 0 {
return 0
}
unit := openCodeGoUsageLimitDurationUnit(remainder[matches[4]:matches[5]])
if unit <= 0 {
return 0
}
const maxDuration = time.Duration(1<<63 - 1)
if value >= float64(maxDuration)/float64(unit) {
return 0
}
part := time.Duration(value * float64(unit))
if part <= 0 || total > maxDuration-part {
return 0
}
total += part
remainder = remainder[matches[1]:]
}
return total
}
func openCodeGoUsageLimitDurationUnit(raw string) time.Duration {
switch strings.ToLower(raw) {
case "s", "sec", "secs", "second", "seconds":
return time.Second
case "m", "min", "mins", "minute", "minutes":
return time.Minute
case "h", "hr", "hrs", "hour", "hours":
return time.Hour
case "d", "day", "days":
return 24 * time.Hour
case "w", "week", "weeks":
return 7 * 24 * time.Hour
default:
return 0
}
}
func parseOpenAIRateLimitPlanType(body []byte) string {
var parsed map[string]any
if err := json.Unmarshal(body, &parsed); err != nil {
@@ -25,6 +25,7 @@ type rateLimitAccountRepoStub struct {
lastTempReason string
lastErrorID int64
lastTempID int64
tempErr error
}
func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error {
@@ -38,7 +39,7 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
r.tempCalls++
r.lastTempID = id
r.lastTempReason = reason
return nil
return r.tempErr
}
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
@@ -117,6 +117,49 @@ func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) {
}
}
func TestParseOpenAIRateLimitResetTime_OpenCodeGoUsageLimit(t *testing.T) {
tests := []struct {
name string
body string
want time.Duration
}{
{
name: "days",
body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 2 days."}}`,
want: 48 * time.Hour,
},
{
name: "hours",
body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 18 hours."}}`,
want: 18 * time.Hour,
},
{
name: "hours and minutes",
body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min."}}`,
want: 4*time.Hour + 59*time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
before := time.Now()
resetAt := parseOpenAIRateLimitResetTime([]byte(tt.body))
after := time.Now()
require.NotNil(t, resetAt)
actual := time.Unix(*resetAt, 0)
require.False(t, actual.Before(before.Add(tt.want).Truncate(time.Second)))
require.False(t, actual.After(after.Add(tt.want)))
})
}
}
func TestParseOpenAIRateLimitResetTime_DoesNotParseUnknownErrorMessage(t *testing.T) {
body := []byte(`{"error":{"type":"rate_limit_error","message":"Resets in 2 days."}}`)
require.Nil(t, parseOpenAIRateLimitResetTime(body))
}
func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {
svc := &RateLimitService{}
@@ -107,6 +107,7 @@ import {
readPaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
import { useAuthStore } from '@/stores/auth'
import { paymentAPI } from '@/api/payment'
import type { PublicOrderVerifyResult } from '@/api/payment'
import type { OrderStatus, PaymentOrder } from '@/types/payment'
@@ -118,6 +119,7 @@ const { t } = i18n
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const authStore = useAuthStore()
type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult
@@ -139,6 +141,7 @@ const STATUS_REFRESH_INTERVAL_MS = 2000
const STATUS_REFRESH_MAX_ATTEMPTS = 15
let statusRefreshTimer: ReturnType<typeof setTimeout> | null = null
let userBalanceRefreshStarted = false
const refreshAttempts = ref(0)
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
@@ -197,6 +200,21 @@ function setResolvedOrder(nextOrder: ResolvedOrder | null): void {
if (nextOrder && 'currency' in nextOrder && nextOrder.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
refreshUserBalanceForSuccessfulOrder(nextOrder)
}
function refreshUserBalanceForSuccessfulOrder(nextOrder: ResolvedOrder | null): void {
if (!nextOrder || userBalanceRefreshStarted || normalizeOrderStatus(nextOrder.status) !== 'COMPLETED') {
return
}
if ('order_type' in nextOrder && nextOrder.order_type !== 'balance') {
return
}
userBalanceRefreshStarted = true
void authStore.refreshUser().catch(() => {
// The order result remains authoritative even if refreshing profile data fails.
})
}
function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
@@ -10,6 +10,7 @@ const pollOrderStatus = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const verifyOrderPublic = vi.hoisted(() => vi.fn())
const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn())
const refreshUser = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => {
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
@@ -36,6 +37,12 @@ vi.mock('@/stores/payment', () => ({
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
refreshUser,
}),
}))
vi.mock('@/api/payment', () => ({
paymentAPI: {
verifyOrder,
@@ -91,6 +98,8 @@ describe('PaymentResultView', () => {
verifyOrder.mockReset()
verifyOrderPublic.mockReset()
resolveOrderPublicByResumeToken.mockReset()
refreshUser.mockReset()
refreshUser.mockResolvedValue({})
window.localStorage.clear()
})
@@ -171,7 +180,7 @@ describe('PaymentResultView', () => {
}))
resolveOrderPublicByResumeToken.mockResolvedValue({
data: {
...orderFactory('PAID'),
...orderFactory('COMPLETED'),
amount: 100,
pay_amount: 103,
fee_rate: 3,
@@ -190,13 +199,14 @@ describe('PaymentResultView', () => {
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative')
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('103.00')
expect(wrapper.text()).toContain('100.00')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('refreshes a pending resume-token result until the order becomes paid', async () => {
it('waits for completed fulfillment before refreshing the user balance', async () => {
vi.useFakeTimers()
routeState.query = {
resume_token: 'resume-77',
@@ -210,7 +220,7 @@ describe('PaymentResultView', () => {
data: orderFactory('PENDING'),
})
.mockResolvedValueOnce({
data: orderFactory('PAID'),
data: orderFactory('COMPLETED'),
})
const wrapper = mount(PaymentResultView, {
@@ -224,6 +234,7 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
expect(refreshUser).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.processing')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
@@ -231,11 +242,36 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('keeps the successful result when refreshing the user balance fails', async () => {
routeState.query = {
resume_token: 'resume-refresh-failure',
}
resolveOrderPublicByResumeToken.mockResolvedValue({
data: orderFactory('COMPLETED'),
})
refreshUser.mockRejectedValueOnce(new Error('profile refresh failed'))
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
})
it('falls back to order_id polling when resume-token recovery fails', async () => {
routeState.query = {
resume_token: 'resume-fail',