mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e6b6a971 | ||
|
|
bdfe215cbb | ||
|
|
455d317d6a | ||
|
|
a431e53f9e | ||
|
|
b8cfb78840 | ||
|
|
64211de8de |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user