diff --git a/relay/common/reasoning_carry.go b/relay/common/reasoning_carry.go new file mode 100644 index 0000000..34f6160 --- /dev/null +++ b/relay/common/reasoning_carry.go @@ -0,0 +1,79 @@ +package common + +import ( + "sync" + "time" + + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// 思考文本跨轮携带的进程内 TTL 存储(convmeta.ReasoningCarry 的宿主实现)。 +// +// 为什么需要它:DeepSeek 等 thinking 上游要求多轮工具调用把 reasoning_content 传回, +// 而 ai-sdk 系 responses 客户端重建历史时会剥掉 reasoning 项(连 encrypted_content +// 一起丢)。网关在响应侧按 call_id 存本轮思考文本,下一轮请求按 function_call 的 +// call_id 捞回。工具调用 call_id 全局唯一(上游生成),可安全作键。 +// +// 局限:进程内存态。多实例部署时同一会话的两轮请求落在不同实例则携带失效—— +// 客户端若正常回传 reasoning 项(encrypted_content 通道)不依赖本机制。 +const ( + reasoningCarryTTL = 30 * time.Minute + // reasoningCarrySweepAt 超过该条数时顺带清扫过期项(懒清扫,避免常驻 goroutine) + reasoningCarrySweepAt = 4096 +) + +type reasoningCarryEntry struct { + text string + expireAt time.Time +} + +var ( + reasoningCarryMu sync.Mutex + reasoningCarryByCallID = make(map[string]reasoningCarryEntry) +) + +// StoreReasoningForCalls 把本轮思考文本按工具调用 call_id 存入进程内缓存。 +func (info *RelayInfo) StoreReasoningForCalls(callIDs []string, reasoning string) { + if reasoning == "" || len(callIDs) == 0 { + return + } + now := time.Now() + reasoningCarryMu.Lock() + defer reasoningCarryMu.Unlock() + if len(reasoningCarryByCallID) >= reasoningCarrySweepAt { + for k, v := range reasoningCarryByCallID { + if now.After(v.expireAt) { + delete(reasoningCarryByCallID, k) + } + } + } + entry := reasoningCarryEntry{text: reasoning, expireAt: now.Add(reasoningCarryTTL)} + for _, id := range callIDs { + if id != "" { + reasoningCarryByCallID[id] = entry + } + } +} + +// LookupReasoningForCalls 按 call_id 查思考文本,命中任一即返回。 +// 不删除命中项:同一轮请求可能因重试被多次转换,读操作保持幂等,过期由 TTL 收敛。 +func (info *RelayInfo) LookupReasoningForCalls(callIDs []string) string { + now := time.Now() + reasoningCarryMu.Lock() + defer reasoningCarryMu.Unlock() + for _, id := range callIDs { + if id == "" { + continue + } + if entry, ok := reasoningCarryByCallID[id]; ok { + if now.After(entry.expireAt) { + delete(reasoningCarryByCallID, id) + continue + } + return entry.text + } + } + return "" +} + +var _ convmeta.ReasoningCarry = (*RelayInfo)(nil) diff --git a/relaykit/relayconvert/convmeta/reasoning_carry.go b/relaykit/relayconvert/convmeta/reasoning_carry.go new file mode 100644 index 0000000..3bda367 --- /dev/null +++ b/relaykit/relayconvert/convmeta/reasoning_carry.go @@ -0,0 +1,19 @@ +package convmeta + +// ReasoningCarry 宿主可选能力:思考文本跨轮携带(进程内状态)。 +// +// 背景:DeepSeek 等 thinking 上游要求多轮工具调用把上一轮的 reasoning_content +// 随 assistant 历史传回,否则 400("must be passed back to the API")。标准回传 +// 通道是 Responses 的 reasoning 项 encrypted_content(网关自编自解),但部分 +// responses 客户端(ai-sdk 系)重建历史时把 reasoning 项整个剥掉,回传链在客户端 +// 断裂。兜底:网关在响应侧按本轮工具调用 call_id 存下思考文本,下一轮请求转换 +// 按 function_call 的 call_id 捞回。 +// +// 转换器对 Meta 做类型断言获取本接口;未实现时静默跳过(仅靠 encrypted_content 回传)。 +// 宿主端由 *relaycommon.RelayInfo 实现(进程内 TTL 缓存)。 +type ReasoningCarry interface { + // StoreReasoningForCalls 把本轮思考文本按工具调用 call_id 存入(每个 id 一份副本)。 + StoreReasoningForCalls(callIDs []string, reasoning string) + // LookupReasoningForCalls 按 call_id 查思考文本,命中任一即返回;无命中返回空串。 + LookupReasoningForCalls(callIDs []string) string +} diff --git a/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go b/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go index 1c1fce7..bc8ec8a 100644 --- a/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go @@ -12,6 +12,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" "github.com/qianfree/team-api/relaykit/types" ) @@ -125,6 +126,11 @@ func (c *ClaudeToResponsesStreamConverter) ConvertStreamResponse( "text": text, }}, } + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 网关自编自解,供下一轮还原思考文本(与 chat→responses 桥接口径一致) + if enc := shared.EncodeReasoningEncryptedContent(text); enc != "" { + item["encrypted_content"] = enc + } if err := emitEvent("response.output_item.done", map[string]any{ "type": "response.output_item.done", "output_index": reasoningItemIdx, diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go index c3377fe..c1ce69f 100644 --- a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go @@ -142,6 +142,18 @@ func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, inf Status: "completed", }) } + + // 思考文本跨轮携带:按本轮工具调用 call_id 存进宿主缓存(与流式路径同口径), + // 供客户端剥掉 reasoning 项时下一轮按 call_id 还原 reasoning_content + if carry, ok := info.(convmeta.ReasoningCarry); ok && + choice.Message.ReasoningContent != nil && *choice.Message.ReasoningContent != "" && + len(choice.Message.ToolCalls) > 0 { + callIDs := make([]string, 0, len(choice.Message.ToolCalls)) + for _, tc := range choice.Message.ToolCalls { + callIDs = append(callIDs, tc.ID) + } + carry.StoreReasoningForCalls(callIDs, *choice.Message.ReasoningContent) + } } // usage 明细:上游 chat 响应可能不携带 details(nil 指针),缺失时按零值合成 diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go index 965167c..4668017 100644 --- a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go @@ -13,6 +13,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" "github.com/qianfree/team-api/relaykit/types" ) @@ -86,6 +87,7 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse( reasoningID := "" reasoningItemIdx := -1 reasoningItems := make([]map[string]any, 0) // 已收口的 reasoning 项,completed 输出数组用 + var allReasoning strings.Builder // 全部思考文本累计(跨轮携带 stash 用,不随收口重置) // closeReasoningItem 收口当前 reasoning 项并重置状态。必须幂等:它在工具调用开始与 // finish_reason 两处被调,重复对同一 item 发 done 事件会使客户端状态机崩溃 //(item 已被移出进行中集合,再查为 undefined —— zcode 报 "reading 'summaryParts'")。 @@ -124,6 +126,11 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse( "text": text, }}, } + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 缺失时 DeepSeek 等 thinking 上游下一轮 400(reasoning_content must be passed back) + if enc := shared.EncodeReasoningEncryptedContent(text); enc != "" { + item["encrypted_content"] = enc + } if err := emit("response.output_item.done", map[string]any{ "type": "response.output_item.done", "output_index": reasoningItemIdx, @@ -132,6 +139,7 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse( return err } reasoningItems = append(reasoningItems, item) + allReasoning.WriteString(text) reasoningItemIdx = -1 reasoningID = "" reasoningText.Reset() @@ -450,6 +458,17 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse( return err } + // 思考文本跨轮携带:按本轮工具调用 call_id 存进宿主缓存,下一轮请求转换时 + // 若客户端剥掉了 reasoning 项(ai-sdk 系不回传 encrypted_content),按 call_id 捞回 + // 挂回 assistant.reasoning_content(DeepSeek 等 thinking 上游的多轮硬性要求) + if carry, ok := info.(convmeta.ReasoningCarry); ok && allReasoning.Len() > 0 && len(toolCallIndexByID) > 0 { + callIDs := make([]string, 0, len(toolCallIndexByID)) + for id := range toolCallIndexByID { + callIDs = append(callIDs, id) + } + carry.StoreReasoningForCalls(callIDs, allReasoning.String()) + } + // 构建 response.completed 的 output 数组(包含文本消息 + reasoning + 所有 tool call) finalOutput := make([]map[string]any, 0) if !sentTextDone || contentBuilder.Len() > 0 { diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go index c5b1ffe..10bf146 100644 --- a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go @@ -255,6 +255,28 @@ func TestOpenAIToResponsesStream_ReasoningDelta(t *testing.T) { } } +// TestOpenAIToResponsesStream_ReasoningStashedByCallID 响应侧把本轮思考文本按工具调用 +// call_id 存进宿主缓存(ReasoningCarry),供客户端剥掉 reasoning 项时下一轮请求捞回。 +func TestOpenAIToResponsesStream_ReasoningStashedByCallID(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"想一下"}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_s1","type":"function","function":{"name":"shell","arguments":"{}"}}]}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + meta := newCarryMeta() + if _, err := runChatToResponsesStream(t, meta, sse); err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + if got := meta.LookupReasoningForCalls([]string{"call_s1"}); got != "想一下" { + t.Errorf("stash 的思考文本 = %q, want 想一下", got) + } +} + // TestOpenAIToResponsesStream_ReasoningClosedOnce 复现线上 bug(chn_debug_logs #330): // reasoning + 工具调用 + finish_reason 的流中,closeReasoningItem 在工具调用开始与 // finish_reason 两处被调,防重失效时同一 reasoning 项会发两份 done 事件—— diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go index cdb8ec0..02b0034 100644 --- a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go @@ -79,7 +79,7 @@ func (c *ResponsesToOpenAIRequestConverter) ConvertRequest( messages = append(messages, dto.Message{Role: "system", Content: instructions}) } } - inputMessages, err := r2cConvertInputToMessages(req.Input) + inputMessages, err := r2cConvertInputToMessages(req.Input, reasoningCarryOf(info)) if err != nil { return nil, fmt.Errorf("convert input to messages: %w", err) } @@ -172,6 +172,9 @@ type r2cInputItem struct { Arguments string `json:"arguments,omitempty"` // reasoning 项的思考文本(OpenAI 官方形态在 summary[],部分聚合器直连输出放 content[]) Summary []r2cTextPart `json:"summary,omitempty"` + // reasoning 项的 encrypted_content:网关自产黑盒(见 shared.EncodeReasoningEncryptedContent), + // ai-sdk 等客户端只回传带该字段的 reasoning 项,summary 可能被剥掉,此为主要回传载体 + EncryptedContent string `json:"encrypted_content,omitempty"` } // r2cTextPart Responses 输出项中文本部件的最小读取形态。 @@ -179,7 +182,9 @@ type r2cTextPart struct { Text string `json:"text"` } -// r2cReasoningText 提取 reasoning 项的思考文本,summary 与 content 两种形态按序拼接。 +// r2cReasoningText 提取 reasoning 项的思考文本,summary 与 content 两种形态按序拼接; +// 两者皆空时解码网关自产的 encrypted_content(客户端只回传带该字段的项, +// summary 常被剥掉,它是跨轮回传思考文本的主要载体)。 func r2cReasoningText(item r2cInputItem) string { var parts []string for _, s := range item.Summary { @@ -197,6 +202,11 @@ func r2cReasoningText(item r2cInputItem) string { } } } + if len(parts) == 0 { + if text, ok := shared.DecodeReasoningEncryptedContent(item.EncryptedContent); ok { + return text + } + } return strings.Join(parts, "\n") } @@ -220,7 +230,15 @@ type r2cInputAudio struct { Format string `json:"format,omitempty"` } -func r2cConvertInputToMessages(input json.RawMessage) ([]dto.Message, error) { +// reasoningCarryOf 提取宿主的思考文本跨轮携带能力,未实现返回 nil。 +func reasoningCarryOf(info convmeta.Meta) convmeta.ReasoningCarry { + if carry, ok := info.(convmeta.ReasoningCarry); ok { + return carry + } + return nil +} + +func r2cConvertInputToMessages(input json.RawMessage, carry convmeta.ReasoningCarry) ([]dto.Message, error) { if len(input) == 0 { return nil, nil } @@ -255,11 +273,37 @@ func r2cConvertInputToMessages(input json.RawMessage) ([]dto.Message, error) { if len(pendingToolCalls) == 0 { return } + rc := takeReasoning() + if rc == nil && carry != nil { + // 客户端剥掉了 reasoning 项(ai-sdk 系):按 call_id 从宿主缓存捞回响应侧 + // 存下的思考文本(DeepSeek 等 thinking 上游要求 tool_calls 消息带回 reasoning_content) + callIDs := make([]string, 0, len(pendingToolCalls)) + for _, tc := range pendingToolCalls { + callIDs = append(callIDs, tc.ID) + } + if text := carry.LookupReasoningForCalls(callIDs); text != "" { + rc = &text + } + } + // Responses 把同一助手轮拆成 message(文本)+ function_call 两类项,chat 协议的 + // 规范形态是单条 assistant 消息同时带 content + tool_calls。拆成两条连续 assistant + // 消息时,DeepSeek thinking 模式会校验当前轮每条 assistant 消息的 reasoning_content, + // 纯文本那条无处挂 → 400(chn_debug_logs #389),因此紧跟文本消息的工具调用合并进该消息 + if n := len(messages); n > 0 && messages[n-1].Role == "assistant" && + len(messages[n-1].ToolCalls) == 0 && messages[n-1].ToolCallID == "" { + last := &messages[n-1] + last.ToolCalls = pendingToolCalls + if last.ReasoningContent == nil { + last.ReasoningContent = rc + } + pendingToolCalls = nil + return + } messages = append(messages, dto.Message{ Role: "assistant", Content: nil, ToolCalls: pendingToolCalls, - ReasoningContent: takeReasoning(), + ReasoningContent: rc, }) pendingToolCalls = nil } diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go index 16a91d5..dc52035 100644 --- a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go @@ -9,6 +9,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" ) // convertResponsesBody 解析 Responses 请求 JSON、执行转换并把结果 chat 请求重新序列化为 map @@ -382,6 +383,87 @@ func TestResponsesToOpenAIRequest_ReasoningPassedBackAsReasoningContent(t *testi t.Errorf("无可读思考文本时不应携带 reasoning_content: %v", assistant) } }) + + t.Run("网关自产 encrypted_content 往返回传", func(t *testing.T) { + // 线上链路(chn_debug_logs #374):ai-sdk 只回传带 encrypted_content 的 + // reasoning 项(summary 被剥掉),网关自产黑盒必须能解回 reasoning_content, + // 否则 DeepSeek thinking 模式多轮工具调用 400 + enc := shared.EncodeReasoningEncryptedContent("先查天气") + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"天气"}]},`+ + `{"type":"reasoning","encrypted_content":"`+enc+`"},`+ + `{"type":"function_call","call_id":"call_w","name":"get_weather","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_w","output":"晴"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %v, want 3", msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["reasoning_content"] != "先查天气" { + t.Errorf("assistant.reasoning_content = %v, want 先查天气", assistant["reasoning_content"]) + } + }) + + t.Run("客户端剥掉 reasoning 项时按 call_id 从宿主缓存捞回", func(t *testing.T) { // 线上链路(chn_debug_logs #377):ai-sdk 重建历史时把 reasoning 项整个剥掉 + //(连 encrypted_content 一起丢),唯一兜底是响应侧按 call_id 存进宿主缓存、 + // 请求侧按 function_call 的 call_id 捞回 + meta := newCarryMeta() + meta.StoreReasoningForCalls([]string{"call_x"}, "缓存的思考") + m := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"ls"}]},`+ + `{"type":"function_call","call_id":"call_x","name":"shell","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_x","output":"a.go"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %v, want 3", msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["reasoning_content"] != "缓存的思考" { + t.Errorf("assistant.reasoning_content = %v, want 缓存的思考", assistant["reasoning_content"]) + } + // input 自带 reasoning 项时优先用客户端回传的,不覆盖 + m2 := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"reasoning","summary":[{"type":"summary_text","text":"客户端回传的思考"}]},`+ + `{"type":"function_call","call_id":"call_x","name":"shell","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_x","output":"a.go"}`+ + `]}`) + msgs2, _ := m2["messages"].([]any) + assistant2 := msgs2[0].(map[string]any) + if assistant2["reasoning_content"] != "客户端回传的思考" { + t.Errorf("客户端回传优先: reasoning_content = %v", assistant2["reasoning_content"]) + } + }) + + t.Run("同轮文本消息与工具调用合并为单条 assistant 消息", func(t *testing.T) { + // 线上 chn_debug_logs #389:Responses 把同一助手轮拆成 message + function_call + // 两类项,转成两条连续 assistant 消息时文本那条无 reasoning_content, + // DeepSeek thinking 校验当前轮每条 assistant 消息 → 400。 + // chat 规范形态是单条 assistant 消息带 content + tool_calls + reasoning_content。 + meta := newCarryMeta() + meta.StoreReasoningForCalls([]string{"call_m"}, "合并的思考") + m := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"查一下"}]},`+ + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我来查"}]},`+ + `{"type":"function_call","call_id":"call_m","name":"search","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_m","output":"结果"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %d 条, want 3(user + 合并 assistant + tool): %v", len(msgs), msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["content"] != "我来查" { + t.Errorf("合并后 content = %v, want 我来查", assistant["content"]) + } + if _, ok := assistant["tool_calls"]; !ok { + t.Error("合并后应带 tool_calls") + } + if assistant["reasoning_content"] != "合并的思考" { + t.Errorf("合并后 reasoning_content = %v, want 合并的思考", assistant["reasoning_content"]) + } + }) } // TestResponsesToOpenAIRequest_WebSearchOptionsAlwaysEmitted web_search_options 在本层 diff --git a/relaykit/relayconvert/internal/oai_responses/testmeta_test.go b/relaykit/relayconvert/internal/oai_responses/testmeta_test.go index b0671b6..4c5b44d 100644 --- a/relaykit/relayconvert/internal/oai_responses/testmeta_test.go +++ b/relaykit/relayconvert/internal/oai_responses/testmeta_test.go @@ -27,3 +27,28 @@ func (m *stashMeta) StashResponsesRequest(req *dto.OpenAIResponsesRequest) { func (m *stashMeta) StashedResponsesRequest() *dto.OpenAIResponsesRequest { return m.stashed } + +// carryMeta 测试用 Meta 实现:附带 ReasoningCarry 能力(对应宿主 RelayInfo 的进程内 TTL 缓存)。 +type carryMeta struct { + *convmeta.Values + store map[string]string +} + +func newCarryMeta() *carryMeta { + return &carryMeta{Values: &convmeta.Values{OriginModelName: "deepseek-v4-flash"}, store: map[string]string{}} +} + +func (m *carryMeta) StoreReasoningForCalls(callIDs []string, reasoning string) { + for _, id := range callIDs { + m.store[id] = reasoning + } +} + +func (m *carryMeta) LookupReasoningForCalls(callIDs []string) string { + for _, id := range callIDs { + if text, ok := m.store[id]; ok { + return text + } + } + return "" +} diff --git a/relaykit/relayconvert/internal/shared/reasoning_responses.go b/relaykit/relayconvert/internal/shared/reasoning_responses.go index 6924d8a..ad59da2 100644 --- a/relaykit/relayconvert/internal/shared/reasoning_responses.go +++ b/relaykit/relayconvert/internal/shared/reasoning_responses.go @@ -1,6 +1,7 @@ package shared import ( + "encoding/base64" "fmt" "strings" @@ -27,6 +28,40 @@ const responsesReasoningItemType = "reasoning" // responsesSummaryTextType reasoning.summary 数组元素的 type 值。 const responsesSummaryTextType = "summary_text" +// reasoningEncryptedPrefix 网关自产 encrypted_content 的明文前缀标记。 +// 解码时校验它,避免把真正的 OpenAI 官方加密黑盒(同为 base64、解出是密文)误当思考文本。 +const reasoningEncryptedPrefix = "tapi-rc1:" + +// EncodeReasoningEncryptedContent 把思考文本编码进 reasoning 项的 encrypted_content。 +// +// 为什么需要它:OpenAI 无状态多轮的设计里,客户端 SDK(ai-sdk/codex 等)只回传带 +// encrypted_content 的 reasoning 项——只有 summary 的项会被丢弃。而 DeepSeek 等 +// thinking 上游要求多轮历史必须带回 reasoning_content,链路断在客户端不回传这一环。 +// 该字段对客户端是不透明黑盒,正好由网关自编自解完成回传。 +func EncodeReasoningEncryptedContent(thinking string) string { + if thinking == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(reasoningEncryptedPrefix + thinking)) +} + +// DecodeReasoningEncryptedContent 解出网关自产 encrypted_content 中的思考文本。 +// 非网关自产(解码失败或无前缀标记,如 OpenAI 官方密文)返回 ok=false。 +func DecodeReasoningEncryptedContent(enc string) (string, bool) { + if enc == "" { + return "", false + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", false + } + s := string(raw) + if !strings.HasPrefix(s, reasoningEncryptedPrefix) { + return "", false + } + return strings.TrimPrefix(s, reasoningEncryptedPrefix), true +} + // BuildResponsesReasoningOutput 把一段思考文本构造为 Responses 的 reasoning 输出项。 // thinking 为空时返回 nil(调用方据此不追加输出项,避免产出空 reasoning 壳)。 // @@ -43,6 +78,9 @@ func BuildResponsesReasoningOutput(idSeed, thinking string) *dto.ResponsesOutput Type: responsesSummaryTextType, Text: thinking, }}, + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 网关据此在下一轮把思考文本还原成 chat 的 reasoning_content + EncryptedContent: EncodeReasoningEncryptedContent(thinking), } } diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json index 4e70250..d7f050a 100644 --- a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json @@ -136,6 +136,7 @@ { "data": { "item": { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", "id": "\u003cscrubbed\u003e", "status": "completed", "summary": [ @@ -223,6 +224,7 @@ "type": "message" }, { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", "id": "\u003cscrubbed\u003e", "status": "completed", "summary": [ diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json index 60b5018..d6cad57 100644 --- a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json @@ -136,6 +136,7 @@ { "data": { "item": { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", "id": "\u003cscrubbed\u003e", "status": "completed", "summary": [ @@ -223,6 +224,7 @@ "type": "message" }, { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", "id": "\u003cscrubbed\u003e", "status": "completed", "summary": [ diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json index 4a016f5..c068088 100644 --- a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json @@ -12,6 +12,7 @@ "object": "response", "output": [ { + "encrypted_content": "dGFwaS1yYzE6Q0FQX01BUktFUl9USElOS0lORyB1c2VyIGFza3Mgd2VhdGhlcg==", "id": "\u003cscrubbed\u003e", "summary": [ {