fix(relay):修复deepseek协议chat和responses开关控制导致的思考异常(适配ZCode)

This commit is contained in:
chengqian committed 2026-09-18 18:43:23 +08:00
1 parent a1469e5b1b
commit c2cb5592ee
13 files changed
+355 -4

No files matched your search

+79
View File
@@ -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),
}
}
@@ -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": [
@@ -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": [
@@ -12,6 +12,7 @@
"object": "response",
"output": [
{
"encrypted_content": "dGFwaS1yYzE6Q0FQX01BUktFUl9USElOS0lORyB1c2VyIGFza3Mgd2VhdGhlcg==",
"id": "\u003cscrubbed\u003e",
"summary": [
{