fix(openai-compat): Responses→Chat 桥接按 reasoning item id 缓存回注 reasoning_content

修复 #5520:Codex 经 force_chat_completions 桥接到 DeepSeek thinking 上游时,
历史中的 encrypted-only reasoning item(summary 为空 + 不透明 encrypted_content,
远程 compaction / 跨会话恢复后常见)取不出明文,后续 assistant 消息缺
reasoning_content,DeepSeek 400 "The reasoning_content in the thinking mode
must be passed back to the API",客户端仅看到通用 502。

reasoning item 的 id 一定会被客户端回传,以其为 key 做服务端缓存:

- GatewayCache 新增 Set/GetReasoningContent(Redis,默认 TTL 7 天)
- 响应侧:流式扫 response.output_item.done、非流式扫 output,把 reasoning
  全文按 item id 写缓存;客户端断连后 drain 期间用 detached ctx 仍会写完
- 请求侧:apicompat 新增 ResponsesToChatCompletionsRequestWithOptions 与
  ReasoningContentByID 钩子,encrypted-only item 查缓存补回 pendingReasoning;
  缓存 miss/出错一律 fail-open 维持原行为
- 自愈:历史里带明文 summary 的 reasoning item 顺手刷新缓存,覆盖 Redis
  flush / 跨实例漂移

测试:apicompat(命中恢复/miss 保持原样/明文优先)、service 端到端(流式
写缓存、请求侧回注+自愈)、repository miniredis 存取。
This commit is contained in:
lbyxiaolizi
2026-08-17 16:48:36 +08:00
parent e330c243a8
commit 612436a5a7
15 changed files with 582 additions and 6 deletions
@@ -17,15 +17,37 @@ const (
type toolOutputMediaByCallID map[string][]ChatContentPart
// ResponsesToChatOptions carries optional hooks for
// ResponsesToChatCompletionsRequestWithOptions. All fields are optional; a nil
// *ResponsesToChatOptions behaves exactly like ResponsesToChatCompletionsRequest.
type ResponsesToChatOptions struct {
// ReasoningContentByID looks up the cached reasoning text for a reasoning
// item id. Codex histories may carry reasoning items with no plaintext
// summary (empty summary + opaque encrypted_content, e.g. after remote
// compaction); DeepSeek's thinking mode rejects such histories with 400
// "The `reasoning_content` in the thinking mode must be passed back to the
// API". The gateway caches the reasoning text it streamed under the item
// id, so the lookup restores the reasoning_content the client can no
// longer provide. Return "" on a miss. A nil lookup keeps the original
// behavior.
ReasoningContentByID func(itemID string) string
}
// ResponsesToChatCompletionsRequest converts a Responses API request into a
// Chat Completions request for upstreams that only implement
// /v1/chat/completions.
func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsRequest, error) {
return ResponsesToChatCompletionsRequestWithOptions(req, nil)
}
// ResponsesToChatCompletionsRequestWithOptions is ResponsesToChatCompletionsRequest
// with optional hooks (see ResponsesToChatOptions).
func ResponsesToChatCompletionsRequestWithOptions(req *ResponsesRequest, opts *ResponsesToChatOptions) (*ChatCompletionsRequest, error) {
if req == nil {
return nil, fmt.Errorf("responses request is nil")
}
messages, err := responsesInputToChatMessages(req.Instructions, req.Input)
messages, err := responsesInputToChatMessagesWithOptions(req.Instructions, req.Input, opts)
if err != nil {
return nil, err
}
@@ -202,6 +224,12 @@ func HasToolSearchTool(tools []ResponsesTool) bool {
// scattered across per-item cases, and makes unknown future codex item types
// fail safe instead of leaking into the upstream request.
func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) ([]ChatMessage, error) {
return responsesInputToChatMessagesWithOptions(instructions, inputRaw, nil)
}
// responsesInputToChatMessagesWithOptions is responsesInputToChatMessages with
// optional hooks (see ResponsesToChatOptions).
func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, error) {
var messages []ChatMessage
if strings.TrimSpace(instructions) != "" {
content, _ := json.Marshal(instructions)
@@ -226,7 +254,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage)
return nil, fmt.Errorf("parse responses input: %w", err)
}
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems)
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems, opts)
if err != nil {
return nil, err
}
@@ -235,7 +263,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage)
// buildChatMessagesFromItems walks the Responses input items and appends the
// corresponding Chat messages.
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, toolOutputMediaByCallID, error) {
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, toolOutputMediaByCallID, error) {
// pendingReasoning holds the reasoning text from a reasoning item until the
// assistant message it belongs to is emitted. DeepSeek's thinking mode
// requires the reasoning_content that produced a tool call to be passed back
@@ -269,6 +297,15 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
case "reasoning":
if txt := extractResponsesReasoningText(item); txt != "" {
pendingReasoning = txt
} else if opts != nil && opts.ReasoningContentByID != nil {
// No plaintext summary (encrypted-only reasoning, e.g. after codex
// remote compaction): fall back to the gateway-side cache keyed
// by the reasoning item id, which always round-trips in history.
if id := rawString(item["id"]); id != "" {
if cached := opts.ReasoningContentByID(id); cached != "" {
pendingReasoning = cached
}
}
}
continue
case "function_call":
@@ -687,6 +724,26 @@ func extractResponsesReasoningText(item map[string]json.RawMessage) string {
return strings.Join(parts, "\n")
}
// ExtractResponsesReasoningItem parses a raw Responses input item and, when it
// is a reasoning item, returns its id and extractable plaintext (summary
// preferred, content fallback). ok is false for non-reasoning items. It exists
// for the gateway-side reasoning cache: items with plaintext get (re)cached so
// later encrypted-only replicas of the same item id can be restored.
func ExtractResponsesReasoningItem(raw json.RawMessage) (id string, text string, ok bool) {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return "", "", false
}
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
return "", "", false
}
if rawString(item["type"]) != "reasoning" {
return "", "", false
}
return rawString(item["id"]), extractResponsesReasoningText(item), true
}
func chatCompletionsBridgeRole(role string) string {
trimmed := strings.TrimSpace(role)
if trimmed == "" {
@@ -0,0 +1,115 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// Encrypted-only reasoning items (empty summary + opaque encrypted_content,
// e.g. after codex remote compaction) carry no plaintext the bridge can map to
// reasoning_content. The gateway-side cache keyed by reasoning item id restores
// it; without the restore, DeepSeek thinking mode rejects the history with 400
// "The `reasoning_content` in the thinking mode must be passed back to the API".
func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(itemID string) string {
if itemID == "item_enc1" {
return "cached thinking"
}
return ""
},
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Equal(t, "assistant", out.Messages[0].Role)
require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent)
require.Len(t, out.Messages[0].ToolCalls, 1)
require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID)
require.Equal(t, "tool", out.Messages[1].Role)
require.Equal(t, "user", out.Messages[2].Role)
}
// A cache miss keeps the original behavior: no reasoning_content, no error.
func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(string) string { return "" },
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Empty(t, out.Messages[0].ReasoningContent)
// Nil options (legacy path) behaves identically.
legacy, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Equal(t, out.Messages, legacy.Messages)
}
// Plaintext summary wins and the cache lookup is not consulted.
func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
lookupCalled := false
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(string) string {
lookupCalled = true
return "cached thinking"
},
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent)
require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run")
}
func TestExtractResponsesReasoningItem(t *testing.T) {
id, text, ok := ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`))
require.True(t, ok)
require.Equal(t, "item_a", id)
require.Equal(t, "think", text)
// Encrypted-only item: ok with id but empty text.
id, text, ok = ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`))
require.True(t, ok)
require.Equal(t, "item_b", id)
require.Empty(t, text)
// Non-reasoning items are skipped.
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`))
require.False(t, ok)
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`))
require.False(t, ok)
}
@@ -131,6 +131,48 @@ func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) e
var _ service.CyberSessionBlockStore = (*gatewayCache)(nil)
var _ service.LiveCallStore = (*gatewayCache)(nil)
const reasoningContentPrefix = "reasoning_content:"
// reasoningContentDefaultTTL 是 reasoning 缓存的默认过期时间。Codex 会话可能
// 跨多天恢复,取 7 天;调用方传入非正 TTL 时兜底。
const reasoningContentDefaultTTL = 7 * 24 * time.Hour
// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文。
// itemID 或 content 为空时直接返回 nil(无可缓存内容,属正常情况而非错误)。
func (c *gatewayCache) SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error {
if c == nil || c.rdb == nil {
return errors.New("gateway cache unavailable")
}
itemID = strings.TrimSpace(itemID)
if itemID == "" || content == "" {
return nil
}
if ttl <= 0 {
ttl = reasoningContentDefaultTTL
}
return c.rdb.Set(ctx, reasoningContentPrefix+itemID, content, ttl).Err()
}
// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回
// service.ErrReasoningContentNotFound。
func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (string, error) {
if c == nil || c.rdb == nil {
return "", errors.New("gateway cache unavailable")
}
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return "", service.ErrReasoningContentNotFound
}
val, err := c.rdb.Get(ctx, reasoningContentPrefix+itemID).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", service.ErrReasoningContentNotFound
}
return "", err
}
return val, nil
}
const cyberSessionBlockPrefix = "cyber_session_block:"
// SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。
@@ -0,0 +1,41 @@
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestGatewayCacheReasoningContent(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
cache := NewGatewayCache(client)
ctx := context.Background()
// 未命中返回哨兵错误,区别于真实读取失败。
_, err := cache.GetReasoningContent(ctx, "item_missing")
require.ErrorIs(t, err, service.ErrReasoningContentNotFound)
// 写入后可读回。
require.NoError(t, cache.SetReasoningContent(ctx, "item_abc", "think hard", time.Minute))
got, err := cache.GetReasoningContent(ctx, "item_abc")
require.NoError(t, err)
require.Equal(t, "think hard", got)
// ttl<=0 时兜底为默认 7 天。
require.NoError(t, cache.SetReasoningContent(ctx, "item_ttl", "x", 0))
ttl := mr.TTL(reasoningContentPrefix + "item_ttl")
require.Greater(t, ttl, 6*24*time.Hour)
require.LessOrEqual(t, ttl, reasoningContentDefaultTTL)
// 空 itemID / 空 content 是 no-op(无可缓存内容不算错误)。
require.NoError(t, cache.SetReasoningContent(ctx, "", "x", time.Minute))
require.NoError(t, cache.SetReasoningContent(ctx, "item_empty", "", time.Minute))
_, err = cache.GetReasoningContent(ctx, "item_empty")
require.ErrorIs(t, err, service.ErrReasoningContentNotFound)
}
@@ -158,6 +158,13 @@ func (s *stickyGatewayCacheHotpathStub) ReleaseGrokVideoBilled(_ context.Context
return nil
}
func (s *stickyGatewayCacheHotpathStub) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (s *stickyGatewayCacheHotpathStub) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
s.listByGroupCalls.Add(1)
if s.err != nil {
@@ -291,6 +291,13 @@ func (m *mockGatewayCacheForPlatform) ReleaseGrokVideoBilled(_ context.Context,
return nil
}
func (m *mockGatewayCacheForPlatform) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (m *mockGatewayCacheForPlatform) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
type mockGroupRepoForGateway struct {
groups map[int64]*Group
getByIDCalls int
@@ -452,6 +452,10 @@ var allowedHeaders = map[string]bool{
// cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound.
var ErrStickySessionNotFound = errors.New("sticky session not found")
// ErrReasoningContentNotFound is returned by GatewayCache.GetReasoningContent
// when no cached reasoning content exists for the reasoning item ID.
var ErrReasoningContentNotFound = errors.New("reasoning content not found")
// GatewayCache 定义网关服务的缓存操作接口。
// 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。
//
@@ -486,6 +490,16 @@ type GatewayCache interface {
ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error)
// ReleaseGrokVideoBilled clears a claim so a failed RecordUsage can retry billing.
ReleaseGrokVideoBilled(ctx context.Context, key string) error
// Reasoning content cache (Responses→Chat Completions 桥接)。
// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文,供后续请求
// 在客户端不回传明文 summary 时回注 reasoning_contentDeepSeek thinking
// mode 要求回传,否则 400)。
SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error
// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回
// ErrReasoningContentNotFound,使 service 层无需依赖具体缓存实现即可
// 区分"未缓存"与真实读取失败。
GetReasoningContent(ctx context.Context, itemID string) (string, error)
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -319,6 +319,13 @@ func (m *mockGatewayCacheForGemini) ReleaseGrokVideoBilled(_ context.Context, _
return nil
}
func (m *mockGatewayCacheForGemini) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (m *mockGatewayCacheForGemini) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
ctx := context.Background()
@@ -196,6 +196,13 @@ func (c *schedulerTestGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _
return nil
}
func (c *schedulerTestGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (c *schedulerTestGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
func newSchedulerTestOpenAIWSV2Config() *config.Config {
cfg := &config.Config{}
cfg.Gateway.OpenAIWS.Enabled = true
@@ -148,6 +148,13 @@ func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string)
return nil
}
func (c *comboCacheAndStore) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
return c.store.SetCyberSessionBlocked(ctx, key, ttl)
}
@@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -52,7 +53,13 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
toolSearch := apicompat.HasToolSearchTool(effectiveTools)
namespaceTools := apicompat.NamespaceToolNames(effectiveTools)
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq)
// 自愈回写:历史里带明文 summary 的 reasoning item 刷新进缓存,覆盖 Redis
// 被 flush / 跨实例漂移后同 id 的 encrypted-only 副本无法再取明文的情况。
s.recacheReasoningItemsFromInput(responsesReq.Input)
chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(&responsesReq, &apicompat.ResponsesToChatOptions{
ReasoningContentByID: s.reasoningContentByID,
})
if err != nil {
writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, fmt.Errorf("convert responses to chat completions: %w", err)
@@ -136,6 +143,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
return nil, err
}
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools)
s.cacheReasoningItemsFromOutput(responsesResp.Output)
if s.responseHeaderFilter != nil {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
@@ -204,7 +212,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
}
scan := s.scanCCStream(resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) {
writeEvents(apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state))
events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state)
s.cacheReasoningItemsFromEvents(events)
writeEvents(events)
})
if scan.Err != nil {
@@ -222,7 +232,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
}, fmt.Errorf("stream usage incomplete: %w", scan.Err)
}
writeEvents(apicompat.FinalizeChatCompletionsResponsesStream(state))
finalEvents := apicompat.FinalizeChatCompletionsResponsesStream(state)
s.cacheReasoningItemsFromEvents(finalEvents)
writeEvents(finalEvents)
if !clientDisconnected {
writeStreamHeaders()
if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
@@ -261,3 +273,100 @@ func chatChunkStartsResponsesOutput(chunk *apicompat.ChatCompletionsChunk) bool
}
return false
}
// responsesReasoningCacheTTL 是 reasoning 缓存(按 reasoning item id)的过期时间。
// Codex 会话可能跨多天恢复历史,取 7 天。
const responsesReasoningCacheTTL = 7 * 24 * time.Hour
// reasoningContentByID 按 reasoning item id 回查缓存的 reasoning 全文,供
// Responses→CC 桥接在客户端不回传明文 summaryencrypted-only reasoning
// item)时回注 reasoning_content。任何失败都 fail-open 返回 ""(维持桥接原
// 行为),因为缓存只是优化而非正确性前提。
func (s *OpenAIGatewayService) reasoningContentByID(itemID string) string {
if s == nil || s.cache == nil {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
content, err := s.cache.GetReasoningContent(ctx, itemID)
if err != nil {
return ""
}
return content
}
// recacheReasoningItemsFromInput 把请求历史里带明文 summary 的 reasoning item
// 重新写入缓存(best-effort)。Codex 多数时候会原样回传明文 summary,借机
// 刷新 TTL 并自愈 Redis 被 flush / 跨实例漂移造成的缓存缺失。
func (s *OpenAIGatewayService) recacheReasoningItemsFromInput(inputRaw json.RawMessage) {
if s == nil || s.cache == nil {
return
}
inputRaw = bytes.TrimSpace(inputRaw)
if len(inputRaw) == 0 || inputRaw[0] != '[' {
return
}
var items []json.RawMessage
if err := json.Unmarshal(inputRaw, &items); err != nil {
return
}
for _, raw := range items {
id, text, ok := apicompat.ExtractResponsesReasoningItem(raw)
if !ok || id == "" || text == "" {
continue
}
s.setReasoningContent(id, text)
}
}
// cacheReasoningItemsFromEvents 从 Responses 流事件里提取完成的 reasoning
// item 写入缓存(覆盖一个流中的多个 reasoning item)。
func (s *OpenAIGatewayService) cacheReasoningItemsFromEvents(events []apicompat.ResponsesStreamEvent) {
for _, event := range events {
if event.Type != "response.output_item.done" || event.Item == nil {
continue
}
s.cacheReasoningItem(event.Item)
}
}
// cacheReasoningItemsFromOutput 从非流式 Responses 响应的 output 里提取
// reasoning item 写入缓存。
func (s *OpenAIGatewayService) cacheReasoningItemsFromOutput(output []apicompat.ResponsesOutput) {
for i := range output {
s.cacheReasoningItem(&output[i])
}
}
func (s *OpenAIGatewayService) cacheReasoningItem(item *apicompat.ResponsesOutput) {
if item == nil || item.Type != "reasoning" || item.ID == "" {
return
}
var parts []string
for _, sum := range item.Summary {
if t := strings.TrimSpace(sum.Text); t != "" {
parts = append(parts, t)
}
}
if len(parts) == 0 {
return
}
s.setReasoningContent(item.ID, strings.Join(parts, "\n"))
}
// setReasoningContent 写入缓存,使用 detached ctx:客户端断连后仍在 drain
// 上游流(计费需要),此时的 reasoning 也是后续轮次回注所依赖的,不能随
// 请求 ctx 一起取消。失败仅记日志,不影响转发。
func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) {
if s == nil || s.cache == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.cache.SetReasoningContent(ctx, itemID, content, responsesReasoningCacheTTL); err != nil {
logger.L().Warn("openai responses chat fallback: cache reasoning content failed",
zap.Error(err),
zap.String("item_id", itemID),
)
}
}
@@ -9,7 +9,9 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
@@ -227,3 +229,143 @@ func forceChatResponsesFallbackAccount() *Account {
}
return account
}
// reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。
type reasoningRecordingCache struct {
stubGatewayCache
mu sync.Mutex
sets map[string]string
getResp map[string]string
}
func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.sets == nil {
c.sets = make(map[string]string)
}
c.sets[itemID] = content
return nil
}
func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
if v, ok := c.getResp[itemID]; ok {
return v, nil
}
return "", ErrReasoningContentNotFound
}
func (c *reasoningRecordingCache) snapshotSets() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]string, len(c.sets))
for k, v := range c.sets {
out[k] = v
}
return out
}
// 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次
// 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。
func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
"",
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`,
"",
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`,
"",
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`,
"",
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning_cache_stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
cache := &reasoningRecordingCache{}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
cache: cache,
}
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
sets := cache.snapshotSets()
require.Len(t, sets, 1, "应恰好缓存一个 reasoning item")
for itemID, content := range sets {
require.NotEmpty(t, itemID)
require.Equal(t, "think first", content)
}
}
// 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回
// reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。
func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{
"model":"deepseek-reasoner",
"stream":false,
"input":[
{"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
{"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_0","output":"ok"},
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]
}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
)),
}}
cache := &reasoningRecordingCache{
getResp: map[string]string{"item_enc1": "cached thinking"},
}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
cache: cache,
}
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
// 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。
require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String())
// encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。
require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String())
require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String())
require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String())
// 明文 summary 的 item 被回写进缓存(自愈)。
require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"])
}
@@ -710,6 +710,13 @@ func (c *stubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) e
return nil
}
func (c *stubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (c *stubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
now := time.Now()
resetAt := now.Add(10 * time.Minute)
@@ -207,6 +207,13 @@ func (c *openAIWSStateStoreTimeoutProbeCache) ReleaseGrokVideoBilled(_ context.C
return nil
}
func (c *openAIWSStateStoreTimeoutProbeCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (c *openAIWSStateStoreTimeoutProbeCache) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", ErrReasoningContentNotFound
}
func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) {
probe := &openAIWSStateStoreTimeoutProbeCache{}
store := NewOpenAIWSStateStore(probe)
+7
View File
@@ -118,6 +118,13 @@ func (c StubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) er
return nil
}
func (c StubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
return nil
}
func (c StubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) {
return "", service.ErrReasoningContentNotFound
}
// ============================================================
// StubSessionLimitCache — service.SessionLimitCache 的空实现
// ============================================================