Compare commits

...
Author SHA1 Message Date
shaw 71e6b6a971 test(responses): 模型改写转发测试夹具补齐上游 created_at
网关现会为缺 created_at 的 Response 对象补齐该字段(#8118),该测试以
"输出包含上游原始字节"断言模型改写,夹具缺 created_at 时输出多出补齐字段
而失配。夹具改为真实上游必带 created_at 的形态,测试意图(仅改写 model)不变。
2026-10-16 11:38:13 +08:00
shaw bdfe215cbb fix(responses): 网关送出的 Responses 对象一律带 created_at,修复 grok-build 反序列化失败
grok-build / Codex 等 Rust serde 客户端把 Response.created_at 与事件的
sequence_number 当必填字段,缺失即 `missing field 'created_at'` 整轮读不出响应。
#5601 只补了 apicompat 两座桥的合成路径,以下出口仍会送出缺字段的对象:

- 合成的 response.failed:buildOpenAIResponseFailedSSE(主流 + 透传)与
  buildOpenAIWSHTTPBridgeFailedEvent 在流以裸 error 结束时补发终止帧,
  缺 created_at,前者还缺 sequence_number。现统一写出:created_at 取本流
  上游值(无则当前时间),sequence_number 沿用被替换的裸 error 帧序号(无则 0)。
- 上游原样透出:Grok 账号挂第三方中转时,上游 Response 对象可能不带
  created_at,网关逐字节转发后客户端同样失败。现于 Responses 流式出口、
  WS HTTP 桥(Grok WS 恒走此路)与非流式 JSON 出口,对缺失/null 的
  created_at 补一个整条流固定的时间戳;上游已带时输出逐字节不变。

透传路径保持不改写上游事件,只观察 created_at 供合成终止帧对齐。

Fixes #8118
2026-10-16 11:22:48 +08:00
Wesley Liddick 455d317d6a Merge pull request #8114 from feeeei/feat/gemini-compat-thinking
feat(gemini): 兼容入口转发思考档位并流式返回思考摘要
2026-10-16 10:53:56 +08:00
shaw a431e53f9e Merge remote-tracking branch 'origin/main' into pr-8114
# Conflicts:
#	backend/internal/service/gemini_chat_completions_compat_service.go
2026-10-16 10:09:46 +08:00
Wesley Liddick b8cfb78840 Merge pull request #8133 from Wei-Shaw/fix/easypay-query-confirm
fix(payment): fix EasyPay order queries and confirm notifications before crediting
2026-10-16 10:06:43 +08:00
feeeei 64211de8de feat(gemini): 兼容入口转发思考档位并流式返回思考摘要
- chat/messages 兼容入口把 reasoning_effort、thinking、output_config.effort
  映射为 Gemini thinkingConfig:2.5 用 thinkingBudget,3 及以上用 thinkingLevel;
  minimal 只发给 flash 系列,medium 只发给 flash 系列与 3.1 Pro,其余升为 high;
  图像/语音/向量与无法识别版本的型号、带档位后缀的型号不写档位
- 流式请求开启 includeThoughts(messages 需客户端开启 thinking),thought part
  转为 Anthropic thinking 块,chat 侧输出为 reasoning_content
- 历史中的 thinking 块与 chat 回传的推理内容不再发给 Gemini;签名降级重试先删
  历史思考块再降级,思考参数沿用客户端原始请求
- messages 兼容路径记录实际转发的思考档位,与 chat 兼容路径一致
2026-10-15 16:26:46 +08:00
12 changed files with 1314 additions and 28 deletions
@@ -42,6 +42,15 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions(
clientStream := ccReq.Stream
includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage
// 历史推理内容不回传给 Gemini:Gemini 的思考连续性由 thoughtSignature 承载。
for i := range ccReq.Messages {
ccReq.Messages[i].ReasoningContent = ""
ccReq.Messages[i].Reasoning = ""
if ccReq.Messages[i].Role == "assistant" {
ccReq.Messages[i].Content = dropChatReasoningContentParts(ccReq.Messages[i].Content)
}
}
responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq)
if err != nil {
return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
@@ -92,6 +101,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq)
geminiReq = applyGeminiCompatThinkingConfig(geminiReq, claudeBody, mappedModel, clientStream)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -570,6 +580,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
openBlockIndex := -1
openBlockType := ""
seenText := ""
seenThought := ""
openToolIndex := -1
openToolName := ""
seenToolJSON := ""
@@ -624,6 +635,41 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
}
for _, part := range extractGeminiParts(geminiResp) {
if text, ok := part["text"].(string); ok && isGeminiThoughtPart(part) {
delta, newSeen := computeGeminiTextDelta(seenThought, text)
seenThought = newSeen
if delta == "" {
continue
}
if openToolIndex >= 0 {
if closeOpenTool() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
}
}
if openBlockType != "thinking" {
if closeOpenBlock() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
}
idx := nextBlockIndex
nextBlockIndex++
openBlockIndex = idx
openBlockType = "thinking"
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &apicompat.AnthropicContentBlock{Type: "thinking"},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
}
}
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &apicompat.AnthropicDelta{Type: "thinking_delta", Thinking: delta},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
}
continue
}
if text, ok := part["text"].(string); ok && text != "" {
if openToolIndex >= 0 {
if closeOpenTool() {
@@ -0,0 +1,320 @@
package service
import (
"bytes"
"encoding/json"
"strconv"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// geminiThinkingParam 是上游模型接受的思考参数形态。
type geminiThinkingParam int
const (
geminiThinkingParamNone geminiThinkingParam = iota
geminiThinkingParamBudget
geminiThinkingParamLevel
)
const (
gemini25ProMaxThinkingBudget = 32768
gemini25ProMinThinkingBudget = 128
gemini25FlashMaxThinkingBudget = 24576
gemini25FlashLiteMinThinkingBudget = 512
)
// geminiThinkingParamForModel 按上游模型名判断思考参数形态:2.5 系列用 thinkingBudget,
// 3 及以上用 thinkingLevel。2.5 之前的版本、无法解析版本号的名字、图像/语音/向量类模型
// 不写思考参数。
func geminiThinkingParamForModel(model string) geminiThinkingParam {
name := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/")
rest, ok := strings.CutPrefix(name, "gemini-")
if !ok {
return geminiThinkingParamNone
}
for _, kind := range []string{"image", "tts", "embedding", "live", "audio"} {
if strings.Contains(rest, kind) {
return geminiThinkingParamNone
}
}
version, _, _ := strings.Cut(rest, "-")
majorText, minorText, _ := strings.Cut(version, ".")
major, err := strconv.Atoi(majorText)
if err != nil {
return geminiThinkingParamNone
}
minor := 0
if minorText != "" {
if minor, err = strconv.Atoi(minorText); err != nil {
return geminiThinkingParamNone
}
}
switch {
case major >= 3:
return geminiThinkingParamLevel
case major == 2 && minor >= 5:
return geminiThinkingParamBudget
default:
return geminiThinkingParamNone
}
}
// geminiModelNameCarriesThinkingTier 判断模型名是否以档位后缀结尾(如 -low/-high),
// 这类名字由上游按名字选档,不再叠加思考档位参数。
func geminiModelNameCarriesThinkingTier(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
for _, suffix := range []string{"-minimal", "-low", "-medium", "-high", "-xhigh", "-max", "-tiered"} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
// claudeThinkingIntent 是 Anthropic 请求体里与思考相关的意图。
type claudeThinkingIntent struct {
thinkingType string // enabled / adaptive / disabled / 空
tier string // minimal / low / medium / high / 空
budgetTokens int // thinking.type=enabled 时的 budget_tokens
}
// parseClaudeThinkingIntent 读取 Anthropic 请求的思考参数。thinking.type=disabled 优先于
// output_config.effort;effort 归一到 minimal/low/medium/high,未识别的取值视为未指定。
func parseClaudeThinkingIntent(claudeBody []byte) claudeThinkingIntent {
intent := claudeThinkingIntent{
thinkingType: strings.ToLower(strings.TrimSpace(gjson.GetBytes(claudeBody, "thinking.type").String())),
}
if intent.thinkingType == "disabled" {
intent.tier = "minimal"
return intent
}
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(claudeBody, "output_config.effort").String())) {
case "none", "minimal":
intent.tier = "minimal"
case "low":
intent.tier = "low"
case "medium":
intent.tier = "medium"
case "high", "xhigh", "max":
intent.tier = "high"
}
if intent.thinkingType == "enabled" {
intent.budgetTokens = int(gjson.GetBytes(claudeBody, "thinking.budget_tokens").Int())
}
return intent
}
// claudeRequestEnablesThinking 判断 Anthropic 请求是否开启了思考输出。
func claudeRequestEnablesThinking(claudeBody []byte) bool {
switch parseClaudeThinkingIntent(claudeBody).thinkingType {
case "enabled", "adaptive":
return true
default:
return false
}
}
// geminiThinkingLevelFor 把思考意图映射为 thinkingLevel;返回空串表示交给模型默认档位。
// minimal 只发给 flash 系列,其余模型用 low;medium 只发给 flash 系列与 Gemini 3.1 Pro,
// 其余模型用 high(Gemini 3 及以上未指定档位时的默认档位)。
func geminiThinkingLevelFor(model string, intent claudeThinkingIntent) string {
tier := intent.tier
if tier == "" && intent.budgetTokens > 0 {
switch {
case intent.budgetTokens <= 1024:
tier = "low"
case intent.budgetTokens <= 8192:
tier = "medium"
default:
tier = "high"
}
}
name := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/")
isFlash := strings.Contains(name, "flash")
if tier == "minimal" && !isFlash {
tier = "low"
}
if tier == "medium" && !isFlash && !strings.HasPrefix(name, "gemini-3.1-pro") {
tier = "high"
}
return tier
}
// geminiThinkingBudgetFor 把思考意图映射为 Gemini 2.5 的 thinkingBudget;ok=false 表示交给
// 模型动态预算。Pro 不能关闭思考(最小 128,最大 32768),Flash 为 0–24576,
// Flash-Lite 的正预算最小 512。
func geminiThinkingBudgetFor(model string, intent claudeThinkingIntent) (budget int, ok bool) {
name := strings.ToLower(model)
isPro := strings.Contains(name, "pro")
maxBudget, minPositive := gemini25FlashMaxThinkingBudget, 1
switch {
case isPro:
maxBudget, minPositive = gemini25ProMaxThinkingBudget, gemini25ProMinThinkingBudget
case strings.Contains(name, "flash-lite"):
minPositive = gemini25FlashLiteMinThinkingBudget
}
switch intent.tier {
case "minimal":
if isPro {
return gemini25ProMinThinkingBudget, true
}
return 0, true
case "low":
budget = 1024
case "medium":
budget = 8192
case "high":
budget = maxBudget
default:
if intent.budgetTokens <= 0 {
return 0, false
}
budget = intent.budgetTokens
}
budget = min(max(budget, minPositive), maxBudget)
return budget, true
}
// applyGeminiCompatThinkingConfig 把兼容入口(Anthropic 形态请求体)的思考意图写进转换后的
// Gemini 请求 generationConfig.thinkingConfig。includeThoughts 为 true 时要求上游返回
// 思考摘要(thought=true 的 part);思考被关闭(预算为 0)时不要求。
func applyGeminiCompatThinkingConfig(geminiReq, claudeBody []byte, upstreamModel string, includeThoughts bool) []byte {
param := geminiThinkingParamForModel(upstreamModel)
if param == geminiThinkingParamNone {
return geminiReq
}
intent := parseClaudeThinkingIntent(claudeBody)
out := geminiReq
thinkingOff := false
if !geminiModelNameCarriesThinkingTier(upstreamModel) {
switch param {
case geminiThinkingParamLevel:
if level := geminiThinkingLevelFor(upstreamModel, intent); level != "" {
out = setGeminiThinkingField(out, "thinkingLevel", level)
}
case geminiThinkingParamBudget:
if budget, ok := geminiThinkingBudgetFor(upstreamModel, intent); ok {
out = setGeminiThinkingField(out, "thinkingBudget", budget)
thinkingOff = budget == 0
}
}
}
if includeThoughts && !thinkingOff {
out = setGeminiThinkingField(out, "includeThoughts", true)
}
return out
}
func setGeminiThinkingField(body []byte, field string, value any) []byte {
updated, err := sjson.SetBytes(body, "generationConfig.thinkingConfig."+field, value)
if err != nil {
return body
}
return updated
}
// isGeminiThoughtPart 判断响应 part 是否为思考摘要。
func isGeminiThoughtPart(part map[string]any) bool {
thought, _ := part["thought"].(bool)
return thought
}
// dropClaudeHistoryThinkingBlocks 删除 Anthropic 请求历史消息里的 thinking/redacted_thinking 块,
// 只含思考块的消息整条删除;与 convertClaudeMessagesToGeminiContents 的取舍一致,供签名降级
// 重试在把思考块改写成正文之前使用。
func dropClaudeHistoryThinkingBlocks(claudeBody []byte) []byte {
var req map[string]any
if err := json.Unmarshal(claudeBody, &req); err != nil {
return claudeBody
}
messages, ok := req["messages"].([]any)
if !ok {
return claudeBody
}
changed := false
kept := make([]any, 0, len(messages))
for _, m := range messages {
mm, ok := m.(map[string]any)
if !ok {
kept = append(kept, m)
continue
}
blocks, ok := mm["content"].([]any)
if !ok {
kept = append(kept, m)
continue
}
filtered := make([]any, 0, len(blocks))
for _, block := range blocks {
if bm, ok := block.(map[string]any); ok {
if bt, _ := bm["type"].(string); bt == "thinking" || bt == "redacted_thinking" {
continue
}
}
filtered = append(filtered, block)
}
if len(filtered) == len(blocks) {
kept = append(kept, m)
continue
}
// 原消息是多块,转换器会过滤空白正文块;删掉思考块后同样过滤,避免剩下的单块空白正文被原样发出。
nonBlank := filtered[:0]
for _, block := range filtered {
if bm, ok := block.(map[string]any); ok {
if bt, _ := bm["type"].(string); bt == "text" {
if text, _ := bm["text"].(string); strings.TrimSpace(text) == "" {
continue
}
}
}
nonBlank = append(nonBlank, block)
}
filtered = nonBlank
changed = true
if len(filtered) == 0 {
continue
}
mm["content"] = filtered
kept = append(kept, mm)
}
if !changed {
return claudeBody
}
req["messages"] = kept
out, err := json.Marshal(req)
if err != nil {
return claudeBody
}
return out
}
// dropChatReasoningContentParts 删除 Chat Completions assistant 消息 content 数组里
// type 为 thinking/reasoning 的部分;字符串形态的 content 原样返回。
func dropChatReasoningContentParts(content json.RawMessage) json.RawMessage {
if !bytes.HasPrefix(bytes.TrimSpace(content), []byte("[")) {
return content
}
var parts []json.RawMessage
if err := json.Unmarshal(content, &parts); err != nil {
return content
}
kept := make([]json.RawMessage, 0, len(parts))
for _, part := range parts {
switch gjson.GetBytes(part, "type").String() {
case "thinking", "reasoning":
continue
}
kept = append(kept, part)
}
if len(kept) == len(parts) {
return content
}
out, err := json.Marshal(kept)
if err != nil {
return content
}
return out
}
@@ -0,0 +1,367 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestApplyGeminiCompatThinkingConfig(t *testing.T) {
budget := func(v int64) *int64 { return &v }
const baseReq = `{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":8192}}`
tests := []struct {
name string
model string
claudeBody string
includeThoughts bool
wantLevel string
wantBudget *int64
wantThoughts bool
wantNoConfig bool
}{
{name: "effort medium on flash", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"medium"},"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "medium"},
{name: "effort low", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
{name: "effort none on flash", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"none"},"thinking":{"type":"enabled","budget_tokens":10240}}`, wantLevel: "minimal"},
{name: "effort minimal on pro", model: "gemini-3.1-pro-preview", claudeBody: `{"output_config":{"effort":"minimal"}}`, wantLevel: "low"},
{name: "effort medium on gemini 3 pro", model: "gemini-3-pro-preview", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "effort medium on gemini 3.1 pro", model: "gemini-3.1-pro-preview", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "medium"},
{name: "effort max", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"max"}}`, wantLevel: "high"},
{name: "effort xhigh", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"xhigh"}}`, wantLevel: "high"},
{name: "thinking disabled wins over effort", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"disabled"},"output_config":{"effort":"high"}}`, wantLevel: "minimal"},
{name: "budget only maps to high", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":31999}}`, wantLevel: "high"},
{name: "budget only maps to medium", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "medium"},
{name: "budget only maps to low", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":1024}}`, wantLevel: "low"},
{name: "adaptive keeps default level", model: "gemini-3.7-flash", claudeBody: `{"thinking":{"type":"adaptive"}}`, includeThoughts: true, wantThoughts: true},
{name: "no thinking params streams thoughts only", model: "gemini-3.7-flash", claudeBody: `{}`, includeThoughts: true, wantThoughts: true},
{name: "unknown effort ignored", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"turbo"}}`, wantNoConfig: true},
{name: "level with thoughts", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantLevel: "high", wantThoughts: true},
{name: "gemini 4 uses level", model: "gemini-4-argon", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
{name: "medium outside verified models becomes high", model: "gemini-4-argon", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "medium budget outside verified models becomes high", model: "gemini-4-argon", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "high"},
{name: "medium on unverified pro becomes high", model: "gemini-3.5-pro", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "medium on flash lite", model: "gemini-3.1-flash-lite", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "medium"},
{name: "tier suffix model keeps name tier", model: "gemini-4-argon-high", claudeBody: `{"output_config":{"effort":"low"}}`, includeThoughts: true, wantThoughts: true},
{name: "tiered suffix model keeps name tier", model: "gemini-3.8-flash-tiered", claudeBody: `{"output_config":{"effort":"low"}}`, includeThoughts: true, wantThoughts: true},
{name: "2.5 flash low", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantBudget: budget(1024)},
{name: "2.5 flash high", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantBudget: budget(24576), wantThoughts: true},
{name: "2.5 flash none disables thinking and thoughts", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"none"}}`, includeThoughts: true, wantBudget: budget(0)},
{name: "2.5 pro cannot disable thinking", model: "gemini-2.5-pro", claudeBody: `{"thinking":{"type":"disabled"}}`, includeThoughts: true, wantBudget: budget(128), wantThoughts: true},
{name: "2.5 pro high", model: "gemini-2.5-pro", claudeBody: `{"output_config":{"effort":"max"}}`, wantBudget: budget(32768)},
{name: "2.5 pro budget clamps up", model: "gemini-2.5-pro", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":50}}`, wantBudget: budget(128)},
{name: "2.5 flash budget clamps down", model: "gemini-2.5-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":31999}}`, wantBudget: budget(24576)},
{name: "2.5 flash lite budget clamps up", model: "gemini-2.5-flash-lite", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":100}}`, wantBudget: budget(512)},
{name: "2.5 adaptive keeps dynamic budget", model: "gemini-2.5-flash", claudeBody: `{"thinking":{"type":"adaptive"}}`, wantNoConfig: true},
{name: "2.0 untouched", model: "gemini-2.0-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "image model untouched", model: "gemini-3-pro-image-preview", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "unparsable model untouched", model: "gemini-flash-latest", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "non gemini model untouched", model: "custom-model", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "models prefix", model: "models/gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := applyGeminiCompatThinkingConfig([]byte(baseReq), []byte(tt.claudeBody), tt.model, tt.includeThoughts)
require.Equal(t, int64(8192), gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int())
require.Equal(t, "hi", gjson.GetBytes(out, "contents.0.parts.0.text").String())
cfg := gjson.GetBytes(out, "generationConfig.thinkingConfig")
if tt.wantNoConfig {
require.False(t, cfg.Exists(), cfg.Raw)
return
}
require.Equal(t, tt.wantLevel, cfg.Get("thinkingLevel").String())
if tt.wantBudget != nil {
require.True(t, cfg.Get("thinkingBudget").Exists(), cfg.Raw)
require.Equal(t, *tt.wantBudget, cfg.Get("thinkingBudget").Int())
} else {
require.False(t, cfg.Get("thinkingBudget").Exists(), cfg.Raw)
}
require.Equal(t, tt.wantThoughts, cfg.Get("includeThoughts").Bool(), cfg.Raw)
require.False(t, cfg.Get("thinkingLevel").Exists() && cfg.Get("thinkingBudget").Exists(), "thinkingLevel and thinkingBudget are mutually exclusive")
})
}
}
func geminiCompatThinkingTestAccount() *Account {
return &Account{
ID: 611,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "gemini-api-key"},
Concurrency: 1,
}
}
func TestGeminiForwardAsChatCompletions_StreamsThoughtsAsReasoningContent(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamBody := `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Weighing options","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"role":"model","parts":[{"text":" carefully.","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Final answer","thoughtSignature":"sig"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":2,"thoughtsTokenCount":7}}` + "\n\n"
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","stream":true,"reasoning_effort":"medium","messages":[{"role":"user","content":"hi"}]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
require.Equal(t, "medium", gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.thinkingLevel").String())
require.True(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.includeThoughts").Bool())
require.Equal(t, "medium", optionalStringValue(result.ReasoningEffort))
require.Equal(t, 9, result.Usage.OutputTokens)
var reasoning, content strings.Builder
firstContentChunk, lastReasoningChunk := -1, -1
for i, line := range strings.Split(recorder.Body.String(), "\n") {
payload, ok := strings.CutPrefix(line, "data: ")
if !ok || payload == "[DONE]" {
continue
}
delta := gjson.Get(payload, "choices.0.delta")
if r := delta.Get("reasoning_content"); r.Exists() {
_, _ = reasoning.WriteString(r.String())
lastReasoningChunk = i
}
if text := delta.Get("content"); text.Exists() && text.String() != "" {
_, _ = content.WriteString(text.String())
if firstContentChunk < 0 {
firstContentChunk = i
}
}
}
require.Equal(t, "Weighing options carefully.", reasoning.String())
require.Equal(t, "Final answer", content.String())
require.Less(t, lastReasoningChunk, firstContentChunk)
}
func TestGeminiForwardAsChatCompletions_NonStreamForwardsEffortWithoutThoughts(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
cfg := gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig")
require.Equal(t, "minimal", cfg.Get("thinkingLevel").String())
require.False(t, cfg.Get("includeThoughts").Exists(), cfg.Raw)
require.Equal(t, "minimal", optionalStringValue(result.ReasoningEffort))
require.Equal(t, "ok", gjson.Get(recorder.Body.String(), "choices.0.message.content").String())
}
func TestGeminiMessagesForward_IncludeThoughtsFollowsClientThinking(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
body string
wantThoughts bool
wantLevel string
}{
{name: "stream with adaptive thinking", body: `{"model":"gemini-3.7-flash","stream":true,"max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"},"messages":[{"role":"user","content":"hi"}]}`, wantThoughts: true, wantLevel: "medium"},
{name: "stream without thinking", body: `{"model":"gemini-3.7-flash","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`},
{name: "non stream with thinking", body: `{"model":"gemini-3.7-flash","max_tokens":1024,"thinking":{"type":"enabled","budget_tokens":31999},"messages":[{"role":"user","content":"hi"}]}`, wantLevel: "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stream := gjson.Get(tt.body, "stream").Bool()
respBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`
contentType := "application/json"
if stream {
respBody = "data: " + respBody + "\n\n"
contentType = "text/event-stream"
}
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(respBody)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/messages", []byte(tt.body))
result, err := svc.Forward(context.Background(), c, geminiCompatThinkingTestAccount(), []byte(tt.body))
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
cfg := gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig")
require.Equal(t, tt.wantThoughts, cfg.Get("includeThoughts").Bool(), cfg.Raw)
require.Equal(t, tt.wantLevel, cfg.Get("thinkingLevel").String(), cfg.Raw)
require.Equal(t, tt.wantLevel, optionalStringValue(result.ReasoningEffort))
})
}
}
func TestGeminiMessagesHandleStreamingResponse_EmitsThinkingBlockBeforeText(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamBody := `data: {"candidates":[{"content":{"parts":[{"text":"Plan the reply","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":1,"thoughtsTokenCount":4}}` + "\n\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
svc := &GeminiMessagesCompatService{}
result, err := svc.handleStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
require.NoError(t, err)
require.NotNil(t, result.firstTokenMs)
events := parseAnthropicContentBlockEvents(t, rec.Body.String())
open := -1
var started []string
for _, ev := range events {
switch ev.event {
case "content_block_start":
require.Equalf(t, -1, open, "block %d opened while block %d was still open", ev.index, open)
open = ev.index
started = append(started, ev.blockType)
case "content_block_stop":
require.Equal(t, open, ev.index)
open = -1
}
}
require.Equal(t, -1, open)
require.Equal(t, []string{"thinking", "text"}, started)
out := rec.Body.String()
require.Contains(t, out, `"content_block":{"signature":"","thinking":"","type":"thinking"}`)
require.Contains(t, out, `"type":"thinking_delta"`)
require.Contains(t, out, `"thinking":"Plan the reply"`)
require.NotContains(t, out, `"text":"Plan the reply"`)
}
func TestConvertClaudeMessagesToGemini_DropsHistoryThinkingBlocks(t *testing.T) {
body := []byte(`{"model":"gemini-3.7-flash","max_tokens":1024,"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"thinking","thinking":"PRIOR_THOUGHT","signature":""},{"type":"text","text":"Hello"}]},
{"role":"user","content":"again"},
{"role":"assistant","content":[{"type":"thinking","thinking":"ONLY_THOUGHT","signature":""},{"type":"redacted_thinking","data":"opaque"}]},
{"role":"user","content":"last"}
]}`)
out, err := convertClaudeMessagesToGeminiGenerateContent(body)
require.NoError(t, err)
require.NotContains(t, string(out), "PRIOR_THOUGHT")
require.NotContains(t, string(out), "ONLY_THOUGHT")
require.NotContains(t, string(out), "opaque")
contents := gjson.GetBytes(out, "contents").Array()
require.Len(t, contents, 4)
require.Equal(t, "model", contents[1].Get("role").String())
require.Len(t, contents[1].Get("parts").Array(), 1)
require.Equal(t, "Hello", contents[1].Get("parts.0.text").String())
for i, content := range contents {
require.NotEmpty(t, content.Get("parts").Array(), "contents[%d] has empty parts", i)
}
}
func TestGeminiForwardAsChatCompletions_DropsHistoryReasoningContent(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":"Hello","reasoning_content":"PRIOR_REASONING"},
{"role":"assistant","content":"Again","reasoning":"ALIAS_REASONING"},
{"role":"assistant","content":[{"type":"thinking","thinking":"PART_THINKING"},{"type":"reasoning","text":"PART_REASONING"},{"type":"text","text":"Third"}]},
{"role":"user","content":"next"}
]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
_, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
sent := string(upstream.requestBodies[0])
require.NotContains(t, sent, "PRIOR_REASONING")
require.NotContains(t, sent, "ALIAS_REASONING")
require.NotContains(t, sent, "PART_THINKING")
require.NotContains(t, sent, "PART_REASONING")
require.NotContains(t, sent, "<thinking>")
require.Contains(t, sent, "Third")
require.Contains(t, sent, "Hello")
require.Contains(t, sent, "Again")
}
func TestGeminiMessagesForward_SignatureRetryKeepsClientThinkingParams(t *testing.T) {
gin.SetMode(gin.TestMode)
okBody := "data: " + `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}` + "\n\n"
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{
{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":400,"message":"Function call is missing a thought_signature","status":"INVALID_ARGUMENT"}}`)),
},
{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":400,"message":"Function call is missing a thought_signature","status":"INVALID_ARGUMENT"}}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(okBody)),
},
}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_THOUGHT","signature":""},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"found"}]},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_ONLY_THOUGHT","signature":""}]},
{"role":"user","content":"next"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_BLANK_THOUGHT","signature":""},{"type":"text","text":""}]},
{"role":"user","content":"again"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_SPACE_THOUGHT","signature":""},{"type":"text","text":" "}]},
{"role":"user","content":"last"}
]}`)
account := geminiCompatThinkingTestAccount()
account.Credentials["model_mapping"] = map[string]any{"claude-sonnet-4-5": "gemini-3.8-flash"}
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/messages", body)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 3)
for i, sent := range upstream.requestBodies {
require.NotContains(t, string(sent), "RETRY_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_ONLY_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_BLANK_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_SPACE_THOUGHT", "request %d", i)
for j, content := range gjson.GetBytes(sent, "contents").Array() {
require.NotEmpty(t, content.Get("parts").Array(), "request %d contents[%d] has empty parts", i, j)
for k, part := range content.Get("parts").Array() {
if text := part.Get("text"); text.Exists() {
require.NotEmpty(t, strings.TrimSpace(text.String()), "request %d contents[%d].parts[%d] is blank text", i, j, k)
}
}
}
cfg := gjson.GetBytes(sent, "generationConfig.thinkingConfig")
require.Equal(t, "low", cfg.Get("thinkingLevel").String(), "request %d: %s", i, cfg.Raw)
require.True(t, cfg.Get("includeThoughts").Bool(), "request %d: %s", i, cfg.Raw)
}
require.Equal(t, "low", optionalStringValue(result.ReasoningEffort))
}
@@ -556,6 +556,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq)
includeThoughts := req.Stream && claudeRequestEnablesThinking(body)
geminiReq = applyGeminiCompatThinkingConfig(geminiReq, body, mappedModel, includeThoughts)
originalClaudeBody := body
proxyURL := ""
@@ -771,25 +773,28 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
var strippedClaudeBody []byte
stageName := ""
// 降级函数会把思考块改写成正文,Gemini 侧先删掉历史思考块再降级。
retrySourceBody := dropClaudeHistoryThinkingBlocks(originalClaudeBody)
// 路径说明:本处上游是 Gemini,但被剥离的 body 是 Anthropic 格式。传 originalModel
// (客户端原 Anthropic model)而非 mappedModel(上游 Gemini model),让剥离逻辑按
// 客户端请求的 Anthropic 子协议族判定(详见 ResolveThinkingProtocol 文档)。
switch signatureRetryStage {
case 0:
// Stage 1: disable thinking + thinking->text
strippedClaudeBody = FilterThinkingBlocksForRetry(originalClaudeBody, originalModel)
strippedClaudeBody = FilterThinkingBlocksForRetry(retrySourceBody, originalModel)
stageName = "thinking-only"
signatureRetryStage = 1
default:
// Stage 2: additionally downgrade tool_use/tool_result blocks to text
strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(originalClaudeBody, originalModel)
strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(retrySourceBody, originalModel)
stageName = "thinking+tools"
signatureRetryStage = 2
}
retryGeminiReq, txErr := convertClaudeMessagesToGeminiGenerateContent(strippedClaudeBody)
if txErr == nil {
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: detected signature-related 400, retrying with downgraded Claude blocks (%s)", account.ID, stageName)
geminiReq = retryGeminiReq
// 降级只改写历史块,思考参数沿用客户端原始请求。
geminiReq = applyGeminiCompatThinkingConfig(retryGeminiReq, originalClaudeBody, mappedModel, includeThoughts)
// Consume one retry budget attempt and continue with the updated request payload.
sleepGeminiBackoff(1)
continue
@@ -1056,6 +1061,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
Stream: req.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ReasoningEffort: extractGeminiReasoningEffortFromBody(geminiReq),
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
@@ -2135,6 +2141,7 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
openBlockIndex := -1
openBlockType := ""
seenText := ""
seenThought := ""
openToolIndex := -1
openToolID := ""
openToolName := ""
@@ -2183,6 +2190,56 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
parts := extractGeminiParts(geminiResp)
for _, part := range parts {
if text, ok := part["text"].(string); ok && isGeminiThoughtPart(part) {
delta, newSeen := computeGeminiTextDelta(seenThought, text)
seenThought = newSeen
if delta == "" {
continue
}
if openToolIndex >= 0 {
writeSSE(c.Writer, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": openToolIndex,
})
openToolIndex = -1
openToolName = ""
seenToolJSON = ""
}
if openBlockType != "thinking" {
if openBlockIndex >= 0 {
writeSSE(c.Writer, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": openBlockIndex,
})
}
openBlockType = "thinking"
openBlockIndex = nextBlockIndex
nextBlockIndex++
writeSSE(c.Writer, "content_block_start", map[string]any{
"type": "content_block_start",
"index": openBlockIndex,
"content_block": map[string]any{
"type": "thinking",
"thinking": "",
"signature": "",
},
})
}
if firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
writeSSE(c.Writer, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": openBlockIndex,
"delta": map[string]any{
"type": "thinking_delta",
"thinking": delta,
},
})
flusher.Flush()
continue
}
if text, ok := part["text"].(string); ok && text != "" {
// Close an open tool_use block before starting text, mirroring
// the functionCall branch (which closes open text blocks) and
@@ -3414,6 +3471,7 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
}
parts := make([]any, 0)
droppedThinking := false
switch content := mm["content"].(type) {
case string:
// 字符串形式的 content,保留所有内容(包括空白)
@@ -3428,6 +3486,11 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
if !ok {
continue
}
// 历史思考块不回传给 Gemini:Gemini 的思考连续性由 thoughtSignature 承载。
if bt, _ := bm["type"].(string); bt == "thinking" || bt == "redacted_thinking" {
droppedThinking = true
continue
}
bt, _ := bm["type"].(string)
switch bt {
case "text":
@@ -3502,6 +3565,10 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
default:
// ignore
}
// 只含思考块的消息去掉思考后为空,整条跳过(Gemini 拒收空 parts)。
if droppedThinking && len(parts) == 0 {
continue
}
out = append(out, map[string]any{
"role": gRole,
@@ -137,7 +137,7 @@ func TestGeminiNativeReasoningPricingUsesExplicitForwardedLevel(t *testing.T) {
}
}
func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T) {
func TestGeminiChatCompatReasoningPricingUsesForwardedEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, stream := range []bool{false, true} {
mode := "buffered"
@@ -168,8 +168,9 @@ func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
require.False(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig").Exists())
require.Nil(t, result.ReasoningEffort)
require.Equal(t, "high", gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.thinkingLevel").String())
require.Equal(t, stream, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.includeThoughts").Bool())
require.Equal(t, "high", optionalStringValue(result.ReasoningEffort))
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{
@@ -184,7 +185,7 @@ func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T)
Resolver: NewModelPricingResolver(nil, billing),
})
require.NoError(t, err)
require.InDelta(t, 1, cost.TotalCost, 1e-12)
require.InDelta(t, 2, cost.TotalCost, 1e-12)
})
}
}
@@ -1901,6 +1901,9 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
responseFailedPending := false
var bareErrorPayload []byte
bareErrorAccountSideEffectsPending := false
// Passthrough leaves upstream events untouched; the tracker only learns the
// upstream created_at so a synthesized response.failed matches the stream.
var createdAtTracker responsesCreatedAtStamper
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
// pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
pendingLines := make([]string, 0, 8)
@@ -1975,7 +1978,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if clientDisconnected || !writePendingLines() {
return
}
if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, createdAtTracker.Value(), bareErrorPayload, failedMessage)); err != nil {
clientDisconnected = true
return
}
@@ -2050,6 +2053,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
}
}
eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType)
createdAtTracker.Observe(dataBytes, eventType)
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
suppressCurrentEvent = true
}
@@ -371,7 +371,7 @@ func TestOpenAIResponseFlush_KeepaliveDoesNotSplitOpenEvent(t *testing.T) {
func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) {
t.Run("failed at EOF", func(t *testing.T) {
body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
"data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n"
"data: {\"type\":\"response.failed\",\"response\":{\"created_at\":1700000000,\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n"
recorder := newOpenAIResponseFlushRecorder()
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
@@ -381,7 +381,7 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T)
require.Equal(t, 3, result.usage.InputTokens)
gotBody, flushes := recorder.snapshot()
expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
"data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"}}}\n"
"data: {\"type\":\"response.failed\",\"response\":{\"created_at\":1700000000,\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"}}}\n"
require.Equal(t, expectedBody, gotBody)
require.Len(t, flushes, 2)
require.Contains(t, flushes[1], "response.failed")
@@ -669,7 +669,7 @@ func (r *hangingOpenAISSEAfterTerminal) Close() error {
}
func TestOpenAIResponseFlush_TerminalEventEndsStreamWithoutEOF(t *testing.T) {
body := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5}}}\n\n"
body := "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1700000000,\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5}}}\n\n"
reader := &hangingOpenAISSEAfterTerminal{payload: []byte(body), release: make(chan struct{})}
recorder := newOpenAIResponseFlushRecorder()
resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1, StreamDataIntervalTimeout: 30})
@@ -341,6 +341,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
needModelReplace := originalModel != mappedModel
var createdAtStamper responsesCreatedAtStamper
streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator()
streamDoneItems := newResponsesStreamOutputItems()
streamImageOutputs := make([]json.RawMessage, 0, 1)
@@ -382,7 +383,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && !clientDisconnected {
applyAttemptResponseHeaders()
if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, createdAtStamper.Value(), bareErrorPayload, failedMessage)); err != nil {
handlePendingWriteError(err)
} else {
failureDelivered = true
@@ -667,6 +668,11 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
data = string(sanitizedData)
line = "data: " + data
}
if stampedData, stamped := createdAtStamper.Stamp(dataBytes, eventType); stamped {
dataBytes = stampedData
data = string(stampedData)
line = "data: " + data
}
// Replace model in response if needed.
if needModelReplace {
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
@@ -1667,6 +1673,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
return nil, fmt.Errorf("restore OpenAI namespace response: %w", err)
}
body = restoreCodexToolNamesFromContext(c, body)
body = ensureOpenAIResponseObjectCreatedAt(body)
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
// Codex 协议要求 /responses/compact JSON 响应携带 x-codex-turn-state
// (codex-api/src/endpoint/compact.rs 从响应头捕获),显式回传。
@@ -1767,7 +1774,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
return nil, fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
}
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
body = ensureOpenAIResponseObjectCreatedAt(restoredBody)
} else {
if originalModel != mappedModel {
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
@@ -1828,7 +1835,10 @@ func extractOpenAISSEErrorMessage(payload []byte) string {
return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload)))
}
func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallbackMessage string) string {
// buildOpenAIResponseFailedSSE synthesizes the response.failed terminal for a
// stream that ended on a bare error. created_at and sequence_number are always
// written: strict clients (grok-build, Codex) reject the frame without them.
func buildOpenAIResponseFailedSSE(responseID, model string, createdAt int64, source []byte, fallbackMessage string) string {
responseID = strings.TrimSpace(responseID)
if responseID == "" {
responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
@@ -1855,23 +1865,31 @@ func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallb
if errorType != "" {
errorBody["type"] = errorType
}
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
sequenceNumber := openAIResponsesFailedSequenceNumber(source)
response := gin.H{
"id": responseID,
"object": "response",
"status": "failed",
"output": []any{},
"error": errorBody,
"id": responseID,
"object": "response",
"created_at": createdAt,
"status": "failed",
"output": []any{},
"error": errorBody,
}
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
payload, err := marshalOpenAIUpstreamJSON(gin.H{
"type": "response.failed",
"response": response,
"type": "response.failed",
"sequence_number": sequenceNumber,
"response": response,
})
if err != nil {
// All values above are JSON primitives, so this is only a defensive fallback.
payload = []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
payload = []byte(`{"type":"response.failed","sequence_number":` + strconv.FormatInt(sequenceNumber, 10) +
`,"response":{"object":"response","created_at":` + strconv.FormatInt(createdAt, 10) +
`,"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return "event: response.failed\ndata: " + string(payload) + "\n\n"
}
@@ -56,7 +56,7 @@ func TestMappedResponseModelForwarding(t *testing.T) {
contentType := "application/json"
body := payload
if kind == "responses" {
payload = `{"type":"response.completed","response":{"id":"resp_1","model":"` + returned + `","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`
payload = `{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"` + returned + `","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`
want = strings.Replace(payload, `"model":"`+returned+`"`, `"model":"`+model+`"`, 1)
}
if kind != "json" {
@@ -0,0 +1,94 @@
package service
import (
"strings"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// Strict Responses clients (Rust serde: grok-build, Codex) declare
// Response.created_at as required — a response object without it fails the
// whole turn with `missing field 'created_at'` (issues #5601, #8118).
// Official upstreams always send it, but vendor gateways in front of Grok
// subscriptions and gateway-synthesized terminal frames may not, so every
// Response object leaving the gateway is stamped when the field is absent.
// responsesCreatedAtStamper keeps one created_at per Responses stream: the
// upstream's own value once seen, otherwise a single timestamp taken on first
// use, so response.created and the terminal event always agree.
type responsesCreatedAtStamper struct {
createdAt int64
}
func (s *responsesCreatedAtStamper) learn(value gjson.Result) {
if s.createdAt == 0 && value.Type == gjson.Number && value.Int() > 0 {
s.createdAt = value.Int()
}
}
// Value returns the stream's created_at, fixing it to now on first use.
func (s *responsesCreatedAtStamper) Value() int64 {
if s.createdAt <= 0 {
s.createdAt = time.Now().Unix()
}
return s.createdAt
}
// Observe records the upstream created_at without modifying the payload.
func (s *responsesCreatedAtStamper) Observe(payload []byte, eventType string) {
if !strings.HasPrefix(strings.TrimSpace(eventType), "response.") {
return
}
s.learn(gjson.GetBytes(payload, "response.created_at"))
}
// Stamp adds response.created_at to a Responses lifecycle event whose
// response object lacks it (absent or null). Any other value is left as-is.
func (s *responsesCreatedAtStamper) Stamp(payload []byte, eventType string) ([]byte, bool) {
if !strings.HasPrefix(strings.TrimSpace(eventType), "response.") {
return payload, false
}
response := gjson.GetBytes(payload, "response")
if !response.IsObject() {
return payload, false
}
createdAt := response.Get("created_at")
if createdAt.Exists() && createdAt.Type != gjson.Null {
s.learn(createdAt)
return payload, false
}
stamped, err := sjson.SetBytes(payload, "response.created_at", s.Value())
if err != nil {
return payload, false
}
return stamped, true
}
// ensureOpenAIResponseObjectCreatedAt stamps created_at onto a non-streaming
// Response object (`"object":"response"`) that lacks it.
func ensureOpenAIResponseObjectCreatedAt(body []byte) []byte {
if gjson.GetBytes(body, "object").String() != "response" {
return body
}
createdAt := gjson.GetBytes(body, "created_at")
if createdAt.Exists() && createdAt.Type != gjson.Null {
return body
}
stamped, err := sjson.SetBytes(body, "created_at", time.Now().Unix())
if err != nil {
return body
}
return stamped
}
// openAIResponsesFailedSequenceNumber reuses the sequence slot of the bare
// error event a synthesized response.failed replaces (that error frame is
// suppressed), keeping the client-visible sequence monotonic; 0 when unknown.
func openAIResponsesFailedSequenceNumber(source []byte) int64 {
if seq := gjson.GetBytes(source, "sequence_number"); seq.Type == gjson.Number && seq.Int() >= 0 {
return seq.Int()
}
return 0
}
@@ -0,0 +1,355 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// issue #8118:grok-build(Rust serde)把 Response.created_at 当必填字段,缺失即
// `missing field 'created_at'` 整轮读不出响应。网关送出的每个 Response 对象都必须带它。
// sseDataPayloads 按顺序取出 SSE 文本里的全部 data 负载(跳过 [DONE])。
func sseDataPayloads(body string) []string {
var payloads []string
for _, line := range strings.Split(body, "\n") {
data, ok := strings.CutPrefix(line, "data: ")
if !ok || strings.TrimSpace(data) == "[DONE]" {
continue
}
payloads = append(payloads, data)
}
return payloads
}
func TestResponsesCreatedAtStamper_StampsMissingAndKeepsStreamStable(t *testing.T) {
var stamper responsesCreatedAtStamper
created, changed := stamper.Stamp([]byte(`{"type":"response.created","response":{"id":"resp_1","status":"in_progress"}}`), "response.created")
require.True(t, changed)
first := gjson.GetBytes(created, "response.created_at")
require.Equal(t, gjson.Number, first.Type)
require.Positive(t, first.Int())
completed, changed := stamper.Stamp([]byte(`{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[]}}`), "response.completed")
require.True(t, changed)
require.Equal(t, first.Int(), gjson.GetBytes(completed, "response.created_at").Int(), "同一条流的 created_at 必须一致")
}
func TestResponsesCreatedAtStamper_PreservesUpstreamValue(t *testing.T) {
var stamper responsesCreatedAtStamper
upstream := []byte(`{"type":"response.created","response":{"id":"resp_1","created_at":1700000000}}`)
out, changed := stamper.Stamp(upstream, "response.created")
require.False(t, changed)
require.Equal(t, string(upstream), string(out), "上游已带 created_at 时字节不变")
completed, changed := stamper.Stamp([]byte(`{"type":"response.completed","response":{"id":"resp_1"}}`), "response.completed")
require.True(t, changed)
require.EqualValues(t, 1700000000, gjson.GetBytes(completed, "response.created_at").Int(), "后续缺失时沿用上游值")
require.EqualValues(t, 1700000000, stamper.Value())
}
func TestResponsesCreatedAtStamper_NullIsTreatedAsMissing(t *testing.T) {
var stamper responsesCreatedAtStamper
out, changed := stamper.Stamp([]byte(`{"type":"response.created","response":{"id":"resp_1","created_at":null}}`), "response.created")
require.True(t, changed)
require.Equal(t, gjson.Number, gjson.GetBytes(out, "response.created_at").Type)
}
func TestResponsesCreatedAtStamper_LeavesOtherEventsUntouched(t *testing.T) {
cases := []struct {
name string
eventType string
payload string
}{
{"delta", "response.output_text.delta", `{"type":"response.output_text.delta","delta":"hi"}`},
{"bare_error", "error", `{"type":"error","error":{"message":"boom"}}`},
{"error_with_response_field", "error", `{"type":"error","response":{"id":"resp_1"}}`},
{"response_not_object", "response.created", `{"type":"response.created","response":"resp_1"}`},
{"non_numeric_created_at", "response.created", `{"type":"response.created","response":{"created_at":"2026-10-16T00:00:00Z"}}`},
{"invalid_json", "response.created", `not json`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stamper responsesCreatedAtStamper
out, changed := stamper.Stamp([]byte(tc.payload), tc.eventType)
require.False(t, changed)
require.Equal(t, tc.payload, string(out))
})
}
}
func TestResponsesCreatedAtStamper_ObserveLearnsWithoutStamping(t *testing.T) {
var stamper responsesCreatedAtStamper
stamper.Observe([]byte(`{"type":"error","response":{"created_at":1600000000}}`), "error")
stamper.Observe([]byte(`{"type":"response.created","response":{"created_at":1700000000}}`), "response.created")
stamper.Observe([]byte(`{"type":"response.in_progress","response":{"created_at":1800000000}}`), "response.in_progress")
require.EqualValues(t, 1700000000, stamper.Value(), "记首个生命周期事件的上游值")
}
func TestEnsureOpenAIResponseObjectCreatedAt(t *testing.T) {
stamped := ensureOpenAIResponseObjectCreatedAt([]byte(`{"id":"resp_1","object":"response","status":"completed"}`))
require.Equal(t, gjson.Number, gjson.GetBytes(stamped, "created_at").Type)
require.Positive(t, gjson.GetBytes(stamped, "created_at").Int())
for _, body := range []string{
`{"id":"resp_1","object":"response","created_at":1700000000}`,
`{"id":"cmp_1","object":"response.compaction","output":[]}`,
`{"id":"resp_1","output":[]}`,
`not json`,
} {
require.Equal(t, body, string(ensureOpenAIResponseObjectCreatedAt([]byte(body))))
}
}
func TestBuildOpenAIResponseFailedSSE_CarriesCreatedAtAndSequenceNumber(t *testing.T) {
frame := buildOpenAIResponseFailedSSE("resp_1", "gpt-5.5", 1700000000,
[]byte(`{"type":"error","sequence_number":7,"error":{"code":"server_is_overloaded","message":"busy"}}`), "")
payloads := sseDataPayloads(frame)
require.Len(t, payloads, 1)
event := payloads[0]
require.Equal(t, "response.failed", gjson.Get(event, "type").String())
require.EqualValues(t, 7, gjson.Get(event, "sequence_number").Int(), "沿用被替换的裸 error 帧序号")
require.EqualValues(t, 1700000000, gjson.Get(event, "response.created_at").Int())
require.Equal(t, "resp_1", gjson.Get(event, "response.id").String())
require.Equal(t, "failed", gjson.Get(event, "response.status").String())
frame = buildOpenAIResponseFailedSSE("", "", 0, []byte(`{"type":"error","error":{"message":"boom"}}`), "")
event = sseDataPayloads(frame)[0]
require.True(t, gjson.Get(event, "sequence_number").Exists(), "sequence_number 未知时也必须写出")
require.EqualValues(t, 0, gjson.Get(event, "sequence_number").Int())
require.Positive(t, gjson.Get(event, "response.created_at").Int(), "未知 created_at 时取当前时间")
}
func TestBuildOpenAIWSHTTPBridgeFailedEvent_CarriesCreatedAtAndSequenceNumber(t *testing.T) {
event := buildOpenAIWSHTTPBridgeFailedEvent("resp_1", "gpt-5.5", 1700000000,
[]byte(`{"type":"error","sequence_number":4,"error":{"message":"boom"}}`), "")
require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String())
require.EqualValues(t, 4, gjson.GetBytes(event, "sequence_number").Int())
require.EqualValues(t, 1700000000, gjson.GetBytes(event, "response.created_at").Int())
event = buildOpenAIWSHTTPBridgeFailedEvent("resp_1", "", 0, []byte(`{"type":"error","error":{"message":"boom"}}`), "")
require.EqualValues(t, 0, gjson.GetBytes(event, "sequence_number").Int())
require.Positive(t, gjson.GetBytes(event, "response.created_at").Int())
}
// Grok 账号挂在不发 created_at 的上游(第三方中转)后面:每个生命周期事件都要补齐,
// 且同一条流取同一个值;增量事件字节不变。
func TestHandleStreamingResponse_GrokStampsMissingCreatedAt(t *testing.T) {
delta := `{"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`
body := "event: response.created\n" +
`data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_grok","object":"response","model":"grok-4.5","status":"in_progress","output":[]}}` + "\n\n" +
"event: response.in_progress\n" +
`data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_grok","object":"response","model":"grok-4.5","status":"in_progress","output":[]}}` + "\n\n" +
"event: response.output_text.delta\n" +
"data: " + delta + "\n\n" +
"event: response.completed\n" +
`data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_grok","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}` + "\n\n"
recorder := newOpenAIResponseFlushRecorder()
account := &Account{ID: 8118, Platform: PlatformGrok, Type: AccountTypeAPIKey}
result, err := runOpenAIResponseFlushTestWithAccount(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}, account)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 3, result.usage.InputTokens)
gotBody, _ := recorder.snapshot()
payloads := sseDataPayloads(gotBody)
require.Len(t, payloads, 4)
createdAt := gjson.Get(payloads[0], "response.created_at")
require.Equal(t, gjson.Number, createdAt.Type)
require.Positive(t, createdAt.Int())
for _, index := range []int{1, 3} {
require.Equal(t, createdAt.Int(), gjson.Get(payloads[index], "response.created_at").Int(), "payload %d", index)
}
require.Equal(t, delta, payloads[2], "非生命周期事件不得改动")
require.Equal(t, "hi", gjson.Get(payloads[3], "response.output.0.content.0.text").String())
}
// 官方上游自带 created_at:网关输出必须与上游逐字节一致(不引入任何改写)。
func TestHandleStreamingResponse_GrokKeepsUpstreamCreatedAtBytes(t *testing.T) {
body := "event: response.created\n" +
`data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_grok","object":"response","created_at":1774274151,"model":"grok-4.5","status":"in_progress","output":[]}}` + "\n\n" +
"event: response.completed\n" +
`data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_grok","object":"response","created_at":1774274151,"model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}` + "\n\n"
recorder := newOpenAIResponseFlushRecorder()
account := &Account{ID: 8118, Platform: PlatformGrok, Type: AccountTypeOAuth}
_, err := runOpenAIResponseFlushTestWithAccount(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}, account)
require.NoError(t, err)
gotBody, _ := recorder.snapshot()
require.Equal(t, body, gotBody)
}
// OpenAI OAuth 流以裸 error 结束:合成的 response.failed 必须带 created_at
// (与本流 response.created 一致)和 sequence_number。
func TestHandleStreamingResponse_BareErrorSynthesizedFailedCarriesCreatedAt(t *testing.T) {
body := `data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_bare","object":"response","created_at":1700000000,"status":"in_progress","output":[]}}` + "\n\n" +
`data: {"type":"response.output_text.delta","sequence_number":1,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"partial"}` + "\n\n" +
`data: {"type":"error","sequence_number":2,"error":{"code":"server_is_overloaded","message":"busy"}}` + "\n\n" +
"data: [DONE]\n\n"
recorder := newOpenAIResponseFlushRecorder()
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
require.Error(t, err)
require.NotNil(t, result)
gotBody, _ := recorder.snapshot()
require.NotContains(t, gotBody, `"type":"error"`)
var failed string
for _, payload := range sseDataPayloads(gotBody) {
if gjson.Get(payload, "type").String() == "response.failed" {
failed = payload
}
}
require.NotEmpty(t, failed, "必须合成 response.failed: %s", gotBody)
require.EqualValues(t, 1700000000, gjson.Get(failed, "response.created_at").Int())
require.EqualValues(t, 2, gjson.Get(failed, "sequence_number").Int())
require.Equal(t, "resp_bare", gjson.Get(failed, "response.id").String())
}
func TestHandleStreamingResponsePassthrough_BareErrorSynthesizedFailedCarriesCreatedAt(t *testing.T) {
upstream := `data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_pt","object":"response","created_at":1700000000,"status":"in_progress","output":[]}}` + "\n\n" +
"event: error\n" +
`data: {"type":"error","sequence_number":1,"error":{"code":"content_policy","message":"request blocked by policy"}}` + "\n\n" +
"data: [DONE]\n\n"
_, recorder, _, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
require.Error(t, err)
var failed string
for _, payload := range sseDataPayloads(recorder.Body.String()) {
if gjson.Get(payload, "type").String() == "response.failed" {
failed = payload
}
}
require.NotEmpty(t, failed, "必须合成 response.failed: %s", recorder.Body.String())
require.EqualValues(t, 1700000000, gjson.Get(failed, "response.created_at").Int())
require.EqualValues(t, 1, gjson.Get(failed, "sequence_number").Int())
}
func TestProxyOpenAIWSHTTPBridgeTurn_GrokStampsMissingCreatedAt(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
`data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_ws","object":"response","model":"grok-4.5","status":"in_progress"}}`,
"",
`data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_ws","object":"response","model":"grok-4.5","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}`,
"",
}, "\n"))),
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
httpUpstream: upstream,
}
account := &Account{ID: 8118, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1}
payload := []byte(`{"type":"response.create","model":"grok-4.5","stream":true,"input":"hi"}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
var events [][]byte
result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
context.Background(), c, account, "access-token", payload, len(payload),
"grok-4.5", "", "", "", "", 1,
func(message []byte) error {
events = append(events, append([]byte(nil), message...))
return nil
},
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, events, 2)
createdAt := gjson.GetBytes(events[0], "response.created_at")
require.Equal(t, gjson.Number, createdAt.Type)
require.Positive(t, createdAt.Int())
require.Equal(t, createdAt.Int(), gjson.GetBytes(events[1], "response.created_at").Int())
}
func TestProxyOpenAIWSHTTPBridgeTurn_BareErrorSynthesizedFailedCarriesCreatedAt(t *testing.T) {
gin.SetMode(gin.TestMode)
body := `data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_eof","created_at":1700000000,"status":"in_progress"}}` + "\n\n" +
`data: {"type":"error","sequence_number":1,"error":{"code":"invalid_request","message":"bad request"}}` + "\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`)
var writes [][]byte
_, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error {
writes = append(writes, append([]byte(nil), message...))
return nil
})
require.EqualError(t, err, "bad request")
require.Len(t, writes, 2)
require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String())
require.EqualValues(t, 1700000000, gjson.GetBytes(writes[1], "response.created_at").Int())
require.EqualValues(t, 1, gjson.GetBytes(writes[1], "sequence_number").Int())
}
func TestHandleNonStreamingResponse_StampsMissingCreatedAt(t *testing.T) {
run := func(t *testing.T, upstreamBody string) string {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
svc := &OpenAIGatewayService{cfg: &config.Config{}, toolCorrector: NewCodexToolCorrector()}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}
result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 8118, Platform: PlatformGrok, Type: AccountTypeAPIKey}, "grok-4.5", "grok-4.5")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 3, result.usage.InputTokens)
return rec.Body.String()
}
missing := run(t, `{"id":"resp_1","object":"response","model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}`)
require.Equal(t, gjson.Number, gjson.Get(missing, "created_at").Type)
require.Positive(t, gjson.Get(missing, "created_at").Int())
present := `{"id":"resp_1","object":"response","created_at":1700000000,"model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}`
require.Equal(t, present, run(t, present))
}
func TestHandleSSEToJSON_StampsMissingCreatedAt(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
svc := &OpenAIGatewayService{cfg: &config.Config{}, toolCorrector: NewCodexToolCorrector()}
body := `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}` + "\n\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(body)),
}
result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 8118, Platform: PlatformGrok, Type: AccountTypeOAuth}, "grok-4.5", "grok-4.5")
require.NoError(t, err)
require.NotNil(t, result)
out := rec.Body.String()
require.Equal(t, "resp_1", gjson.Get(out, "id").String())
require.Positive(t, gjson.Get(out, "created_at").Int())
}
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -369,7 +370,10 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
return body
}
func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte, fallbackMessage string) []byte {
// buildOpenAIWSHTTPBridgeFailedEvent synthesizes response.failed for a turn that
// ended on a bare error. created_at and sequence_number are always written:
// strict clients (grok-build, Codex) reject the frame without them.
func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, createdAt int64, source []byte, fallbackMessage string) []byte {
errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String())
if errorType == "" {
errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String())
@@ -392,16 +396,22 @@ func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte,
if errorType != "" {
errorBody["type"] = errorType
}
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
sequenceNumber := openAIResponsesFailedSequenceNumber(source)
response := map[string]any{
"id": responseID, "object": "response", "status": "failed",
"id": responseID, "object": "response", "created_at": createdAt, "status": "failed",
"output": []any{}, "error": errorBody,
}
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
body, err := json.Marshal(map[string]any{"type": "response.failed", "sequence_number": 0, "response": response})
body, err := json.Marshal(map[string]any{"type": "response.failed", "sequence_number": sequenceNumber, "response": response})
if err != nil {
return []byte(`{"type":"response.failed","sequence_number":0,"response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
return []byte(`{"type":"response.failed","sequence_number":` + strconv.FormatInt(sequenceNumber, 10) +
`,"response":{"object":"response","created_at":` + strconv.FormatInt(createdAt, 10) +
`,"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return body
}
@@ -641,6 +651,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
officialOpenAIResponses := account != nil && account.Platform == PlatformOpenAI
bareErrorPending := false
var bareErrorPayload []byte
var createdAtStamper responsesCreatedAtStamper
bareErrorMessage := ""
failureAccountSideEffectsApplied := false
mappedModel := actualModel
@@ -712,7 +723,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
if clientDisconnected {
return nil
}
clientMessage := buildOpenAIWSHTTPBridgeFailedEvent(responseID, originalModel, bareErrorPayload, bareErrorMessage)
clientMessage := buildOpenAIWSHTTPBridgeFailedEvent(responseID, originalModel, createdAtStamper.Value(), bareErrorPayload, bareErrorMessage)
if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(clientMessage); changed {
clientMessage = rewritten
}
@@ -794,6 +805,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
upstreamMessage = corrected
}
}
if stamped, changed := createdAtStamper.Stamp(upstreamMessage, eventType); changed {
upstreamMessage = stamped
}
replayCollector.AddEvent(eventType, upstreamMessage)
var upstreamEventErr error