fix(relay):修复deepseek协议chat和responses开关控制导致的思考异常(适配ZCode)
This commit is contained in:
1 parent
a1469e5b1b
commit
c2cb5592ee
13 files changed
+355
-4
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 指针),缺失时按零值合成
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 事件——
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 在本层
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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": [
|
||||
|
||||
+2
@@ -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": [
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
"object": "response",
|
||||
"output": [
|
||||
{
|
||||
"encrypted_content": "dGFwaS1yYzE6Q0FQX01BUktFUl9USElOS0lORyB1c2VyIGFza3Mgd2VhdGhlcg==",
|
||||
"id": "\u003cscrubbed\u003e",
|
||||
"summary": [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user