mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4795650d21 | ||
|
|
7634e3c23b | ||
|
|
e8cb019fab | ||
|
|
7a7bd3729c | ||
|
|
49ad7021d7 | ||
|
|
4952b919a4 | ||
|
|
fa0685a4a3 | ||
|
|
8040428716 | ||
|
|
4ff136cfd9 | ||
|
|
832cf4df65 | ||
|
|
f1aadd48d5 | ||
|
|
3802268e2d | ||
|
|
8e60d57475 | ||
|
|
b20f29d115 | ||
|
|
1a9898a6a4 | ||
|
|
aa2c4e8d13 | ||
|
|
5a7d469622 | ||
|
|
095b525360 | ||
|
|
027d442f9b | ||
|
|
810d50a00f | ||
|
|
41d712be09 | ||
|
|
3c714d8736 | ||
|
|
636d7debf9 | ||
|
|
3dd717ab0d | ||
|
|
1dc1b44268 | ||
|
|
4ca86c52e3 | ||
|
|
e55727d4c8 | ||
|
|
3b7753a8e4 | ||
|
|
d5e43ef7d1 | ||
|
|
d6012b0b35 | ||
|
|
53d76ad800 | ||
|
|
4347e5555b | ||
|
|
329b92ef04 | ||
|
|
e2d9b823f6 | ||
|
|
eb594eefc9 | ||
|
|
3af5443b22 | ||
|
|
7ba3e1ac52 | ||
|
|
9fb260439f | ||
|
|
07931bbb18 | ||
|
|
2307aa5ca7 | ||
|
|
1563db3f82 | ||
|
|
e440ac48c7 | ||
|
|
71aa6e3574 | ||
|
|
99ec347eaf | ||
|
|
03e8ab4134 | ||
|
|
bc4a9ae432 | ||
|
|
19da0f240e | ||
|
|
49752060fc | ||
|
|
a6b11ccced | ||
|
|
e0e5e45cd2 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -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 @@
|
||||
0.1.179
|
||||
0.1.183
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user