gateway: a Responses reasoning item goes through translation to Responses with its id, summary and encrypted_content, both ways (#1445, XYenon)
What changed: parseResponses keeps a reasoning item that has encrypted_content raw (Part.Sealed), and buildResponses sends it back as it came when it has no reasoning text and DeepSeek replay is off (sealedReasoning). The decoder says where an item begins (KThinkStart with its id) and hands over the done item (KSealed). The encoder streams the summary under the upstream's own id and gives the done item and the completed response's output as the upstream wrote them; renderResponses does the same for a reply that wasn't streamed. Why: the old code made its own rs_ id, dropped encrypted_content and left reasoning out of the next request, so a store:false conversation through a stream-only account or web search lost its reasoning each turn. Reasoning with text in it (another vendor's) is still left out for OpenAI (#1411); withoutBareReasoning and the foreign-reasoning retry still act on the built body. Run: the new tests in reasoning_state_responses_test.go (OpenAI's documented item and stream shapes) fail on the old code and pass now; the seal isn't handed to Messages, Chat or Gemini clients. gofmt -l; go vet -tags nogui on darwin, linux and windows; the full internal/gateway tests in a scratch HOME: ok. Not tried with a real account.
This commit is contained in:
1 parent
64179e7383
commit
13356b9da7
2 files changed
+276
-16
No files matched your search
@@ -0,0 +1,161 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// Reasoning items as OpenAI's reasoning guide gives them, with
|
||||
// include: ["reasoning.encrypted_content"] and store false: one with a
|
||||
// summary, one sealed with none (#1445, XYenon).
|
||||
const (
|
||||
rsSummarized = `{"id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","type":"reasoning","summary":[{"type":"summary_text","text":"**Checking the weather**\n\nThe user wants Paris."}],"encrypted_content":"gAAAAABoIPOHmVpvRSm3xMmc1nW0W1WqGa8AI6kjYvmdqBtbKdpE2qY0VJ0r6oeBbyuh1OWXIHfF41nXR4kaWh6iUxlyVcGmBVLGYzAa6ihHjyUuBaODTsy1ZJ3m1TXGDeT9zCYFx1k8ZDcyThGhw=="}`
|
||||
rsSealed = `{"id":"rs_6820f3b2e9fc8191a5c7d0ab1c8e2f4a0ac5ba57aafcbac7","type":"reasoning","summary":[],"encrypted_content":"gAAAAABoIPOyXq2Ub7WqZbSd8dHXqfVt1pDkAm3VlAk7nM4lQ0pC9yr0eU8iHnJgTkRm5Zs2Wq8pX1cLbVfN6oA3eY7hD4uG2tK9sJ0wQ=="}`
|
||||
)
|
||||
|
||||
// A Responses request's reasoning items go back to a Responses upstream
|
||||
// as they came: id, summary and encrypted_content. The old build left
|
||||
// them out (only DeepSeek's text was replayed).
|
||||
func TestResponsesSealedReasoningGoesBackAsItCame(t *testing.T) {
|
||||
body := `{"model":"gpt-5","store":false,"include":["reasoning.encrypted_content"],"reasoning":{"effort":"medium"},"input":[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"What's the weather in Paris?"}]},
|
||||
` + rsSummarized + `,
|
||||
` + rsSealed + `,
|
||||
{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Paris\"}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"15 degrees"}]}`
|
||||
req, err := parse(provider.Responses, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, host := range []string{"chatgpt.com", "api.openai.com"} {
|
||||
out, err := build(provider.Responses, req, "gpt-5", host, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var q struct {
|
||||
Input []json.RawMessage `json:"input"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []json.RawMessage
|
||||
for _, it := range q.Input {
|
||||
if bytes.Contains(it, []byte(`"type":"reasoning"`)) {
|
||||
got = append(got, it)
|
||||
}
|
||||
}
|
||||
if len(got) != 2 || !jsonIs(t, got[0], rsSummarized) || !jsonIs(t, got[1], rsSealed) {
|
||||
t.Fatalf("%s: reasoning items %s\nin %s", host, got, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning with its text in it (another vendor's, #1411) still isn't
|
||||
// handed to OpenAI, sealed or not.
|
||||
func TestResponsesReasoningTextStillLeftOut(t *testing.T) {
|
||||
body := `{"model":"gpt-5","input":[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"id":"rs_1","type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"DeepSeek's thought"}],"encrypted_content":"deepseek-sealed"},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}]}`
|
||||
req, err := parse(provider.Responses, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := build(provider.Responses, req, "gpt-5", "chatgpt.com", false)
|
||||
if bytes.Contains(out, []byte("deepseek-sealed")) || bytes.Contains(out, []byte(`"reasoning_text"`)) {
|
||||
t.Fatalf("reasoning text sent to OpenAI: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// responsesSealedStream is a Responses stream as OpenAI's streaming
|
||||
// reference gives it: a reasoning item with a summary, one with none,
|
||||
// then the answer.
|
||||
var responsesSealedStream = []string{
|
||||
`{"type":"response.created","sequence_number":0,"response":{"id":"resp_67c9fdcecf488190bdd9a0409de3a1ec07b8b0ad4e5eb654","object":"response","status":"in_progress","model":"gpt-5","output":[]}}`,
|
||||
`{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","type":"reasoning","summary":[]}}`,
|
||||
`{"type":"response.reasoning_summary_part.added","sequence_number":2,"item_id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","sequence_number":3,"item_id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","output_index":0,"summary_index":0,"delta":"**Checking the weather**\n\n"}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","output_index":0,"summary_index":0,"delta":"The user wants Paris."}`,
|
||||
`{"type":"response.reasoning_summary_text.done","sequence_number":5,"item_id":"rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7","output_index":0,"summary_index":0,"text":"**Checking the weather**\n\nThe user wants Paris."}`,
|
||||
`{"type":"response.output_item.done","sequence_number":6,"output_index":0,"item":` + rsSummarized + `}`,
|
||||
`{"type":"response.output_item.added","sequence_number":7,"output_index":1,"item":{"id":"rs_6820f3b2e9fc8191a5c7d0ab1c8e2f4a0ac5ba57aafcbac7","type":"reasoning","summary":[]}}`,
|
||||
`{"type":"response.output_item.done","sequence_number":8,"output_index":1,"item":` + rsSealed + `}`,
|
||||
`{"type":"response.output_item.added","sequence_number":9,"output_index":2,"item":{"id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","type":"message","status":"in_progress","role":"assistant","content":[]}}`,
|
||||
`{"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","output_index":2,"content_index":0,"delta":"It's 15 degrees."}`,
|
||||
`{"type":"response.output_item.done","sequence_number":11,"output_index":2,"item":{"id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"It's 15 degrees.","annotations":[]}]}}`,
|
||||
`{"type":"response.completed","sequence_number":12,"response":{"id":"resp_67c9fdcecf488190bdd9a0409de3a1ec07b8b0ad4e5eb654","object":"response","status":"completed","model":"gpt-5","output":[],"usage":{"input_tokens":10,"output_tokens":30,"total_tokens":40}}}`,
|
||||
}
|
||||
|
||||
// A Responses stream through translation gives the client each reasoning
|
||||
// item as the upstream did it: its id while it streams, and the item
|
||||
// whole when done and in the completed response. The old encoder made an
|
||||
// rs_ id of its own and dropped encrypted_content.
|
||||
func TestResponsesStreamKeepsSealedReasoning(t *testing.T) {
|
||||
evs := translateStream(t, provider.Responses, provider.Responses, responsesSealedStream...)
|
||||
var done []any
|
||||
var completed []any
|
||||
var addedIDs []string
|
||||
for _, ev := range evs {
|
||||
item, _ := ev["item"].(map[string]any)
|
||||
switch ev["type"] {
|
||||
case "response.output_item.added":
|
||||
if item["type"] == "reasoning" {
|
||||
addedIDs = append(addedIDs, item["id"].(string))
|
||||
}
|
||||
case "response.output_item.done":
|
||||
if item["type"] == "reasoning" {
|
||||
done = append(done, item)
|
||||
}
|
||||
case "response.reasoning_summary_text.delta":
|
||||
if ev["item_id"] != "rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7" {
|
||||
t.Fatalf("summary delta for %v", ev["item_id"])
|
||||
}
|
||||
case "response.completed":
|
||||
for _, it := range ev["response"].(map[string]any)["output"].([]any) {
|
||||
if it.(map[string]any)["type"] == "reasoning" {
|
||||
completed = append(completed, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(addedIDs) != 2 || addedIDs[0] != "rs_6820f383d7c08191846711c5df8233bc0ac5ba57aafcbac7" || addedIDs[1] != "rs_6820f3b2e9fc8191a5c7d0ab1c8e2f4a0ac5ba57aafcbac7" {
|
||||
t.Fatalf("added reasoning ids %v", addedIDs)
|
||||
}
|
||||
for name, items := range map[string][]any{"done": done, "completed": completed} {
|
||||
if len(items) != 2 || !jsonIs(t, items[0], rsSummarized) || !jsonIs(t, items[1], rsSealed) {
|
||||
t.Fatalf("%s reasoning items %v", name, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same for a client that didn't stream.
|
||||
func TestResponsesWholeReplyKeepsSealedReasoning(t *testing.T) {
|
||||
out := translateWhole(t, provider.Responses, provider.Responses, responsesSealedStream...)
|
||||
var items []any
|
||||
for _, it := range out["output"].([]any) {
|
||||
if it.(map[string]any)["type"] == "reasoning" {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
if len(items) != 2 || !jsonIs(t, items[0], rsSummarized) || !jsonIs(t, items[1], rsSealed) {
|
||||
t.Fatalf("reasoning items %v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI's sealed reasoning is nothing to another API's client: a
|
||||
// Messages client gets the summary as thinking, never the seal.
|
||||
func TestResponsesSealedReasoningStaysWithResponses(t *testing.T) {
|
||||
for _, from := range []provider.Protocol{provider.Anthropic, provider.Chat, provider.Gemini} {
|
||||
b, _ := json.Marshal(translateWhole(t, provider.Responses, from, responsesSealedStream...))
|
||||
s, _ := json.Marshal(translateStream(t, provider.Responses, from, responsesSealedStream...))
|
||||
if bytes.Contains(b, []byte("gAAAAABo")) || bytes.Contains(s, []byte("gAAAAABo")) {
|
||||
t.Fatalf("%s client handed OpenAI's sealed reasoning:\n%s\n%s", from, b, s)
|
||||
}
|
||||
if !bytes.Contains(b, []byte("Checking the weather")) {
|
||||
t.Fatalf("%s client lost the summary: %s", from, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-16
@@ -279,7 +279,7 @@ func parseResponses(body []byte) (*Request, error) {
|
||||
// written to the vendor's cache again (Anthropic's system comes
|
||||
// first; a Claude subscription's waiting run is found by it) (#502)
|
||||
replied := false
|
||||
var rawItems []json.RawMessage // decoded only for native standalone outputs
|
||||
var rawItems []json.RawMessage // decoded only for native standalone outputs and sealed reasoning
|
||||
for i, it := range items {
|
||||
if it.Role == "assistant" || strings.HasPrefix(it.Type, "function_call") || strings.HasPrefix(it.Type, "custom_tool_call") || strings.HasPrefix(it.Type, "tool_search") || it.Type == "reasoning" {
|
||||
replied = true
|
||||
@@ -362,8 +362,19 @@ func parseResponses(body []byte) (*Request, error) {
|
||||
b.WriteString(s.Text)
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
r.Messages = append(r.Messages, Message{Role: "assistant", Parts: []Part{{Kind: Thinking, Text: b.String()}}})
|
||||
part := Part{Kind: Thinking, Text: b.String()}
|
||||
if it.EncryptedContent != "" {
|
||||
// sealed: the item goes back to a Responses API as it
|
||||
// came, id, summary and all (#1445)
|
||||
if rawItems == nil {
|
||||
_ = json.Unmarshal(q.Input, &rawItems)
|
||||
}
|
||||
if i < len(rawItems) {
|
||||
part.Sealed, part.SealedBy = rawItems[i], sealResponses
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 || part.Sealed != nil {
|
||||
r.Messages = append(r.Messages, Message{Role: "assistant", Parts: []Part{part}})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -739,6 +750,21 @@ func withReasoningText(body []byte) []byte {
|
||||
return nb
|
||||
}
|
||||
|
||||
// sealedReasoning is a reasoning item a Responses API sealed, fit to go
|
||||
// back to one as it came: encrypted_content in it, and no reasoning text
|
||||
// (which OpenAI and the ChatGPT backend refuse in input, #1411; one with
|
||||
// text goes as before).
|
||||
func sealedReasoning(p Part) bool {
|
||||
if !p.sealedBy(sealResponses) {
|
||||
return false
|
||||
}
|
||||
var it struct {
|
||||
Enc string `json:"encrypted_content"`
|
||||
Content []json.RawMessage `json:"content"`
|
||||
}
|
||||
return json.Unmarshal(p.Sealed, &it) == nil && it.Enc != "" && len(it.Content) == 0
|
||||
}
|
||||
|
||||
// buildResponses renders a request for a Responses upstream.
|
||||
func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
// A turn's reasoning goes back as a reasoning item, as a model that
|
||||
@@ -747,7 +773,7 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
// gets it: OpenAI's and those in front of it read only their own
|
||||
// sealed reasoning, and may refuse an item without it.
|
||||
replay := replaysReasoning(model, host)
|
||||
var input []map[string]any
|
||||
var input []any
|
||||
for _, m := range r.Messages {
|
||||
var content []map[string]any
|
||||
flushMsg := func() {
|
||||
@@ -760,6 +786,9 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
for _, p := range m.Parts {
|
||||
switch p.Kind {
|
||||
case Text:
|
||||
if p.Text == "" && p.Signature != "" {
|
||||
continue // only Gemini's signature, which is Gemini's
|
||||
}
|
||||
t := "input_text"
|
||||
if m.Role == "assistant" {
|
||||
t = "output_text"
|
||||
@@ -776,7 +805,12 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
content = append(content, map[string]any{"type": "input_image", "image_url": dataURL(p)})
|
||||
}
|
||||
case Thinking:
|
||||
if replay && p.Text != "" {
|
||||
if !replay && sealedReasoning(p) {
|
||||
// a Responses API's own sealed reasoning goes back as
|
||||
// it came: id, summary and encrypted_content (#1445)
|
||||
flushMsg()
|
||||
input = append(input, p.Sealed)
|
||||
} else if replay && p.Text != "" {
|
||||
flushMsg()
|
||||
input = append(input, map[string]any{"type": "reasoning", "summary": []any{},
|
||||
"content": []map[string]any{{"type": "reasoning_text", "text": p.Text}}})
|
||||
@@ -812,7 +846,7 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
flushMsg()
|
||||
}
|
||||
if input == nil {
|
||||
input = []map[string]any{}
|
||||
input = []any{}
|
||||
}
|
||||
out := map[string]any{"model": model, "input": input, "stream": r.Stream, "store": false}
|
||||
if len(r.ClientMetadata) > 0 {
|
||||
@@ -862,9 +896,10 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte {
|
||||
// the client's include goes on as the request would have without
|
||||
// magpie. Sealed reasoning is asked for only with reasoning, as Codex
|
||||
// asks for it: OpenAI refuses it of a model that doesn't reason. What
|
||||
// comes back sealed goes no further than magpie, and the input's sealed
|
||||
// reasoning isn't sent on this way, so no account or vendor is handed
|
||||
// another's to refuse (withoutRefused, sealedKinds, on a relay).
|
||||
// comes back sealed goes to a Responses client as it came, and the
|
||||
// input's goes back to a Responses upstream, as it would without magpie
|
||||
// (#1445); an account or vendor that can't read another's turns it away,
|
||||
// and is asked again without it (foreignReasoning, withoutRefused).
|
||||
var include []string
|
||||
for _, v := range r.Include {
|
||||
if v == "" || slices.Contains(include, v) || (v == "reasoning.encrypted_content" && out["reasoning"] == nil) {
|
||||
@@ -1023,6 +1058,10 @@ func (d *responsesDecoder) decode(data string, emit func(Event)) error {
|
||||
d.called, d.calling = true, true
|
||||
emit(Event{Kind: KToolStart, ID: ev.Item.CallID, Name: ev.Item.Name})
|
||||
}
|
||||
if ev.Item.Type == "reasoning" {
|
||||
// an item of its own, by the id the upstream gave it (#1445)
|
||||
emit(Event{Kind: KThinkStart, ID: ev.Item.ID})
|
||||
}
|
||||
case "response.output_text.delta":
|
||||
d.endCall(emit)
|
||||
emit(Event{Kind: KText, Text: ev.Delta})
|
||||
@@ -1046,6 +1085,16 @@ func (d *responsesDecoder) decode(data string, emit func(Event)) error {
|
||||
}
|
||||
d.endCall(emit)
|
||||
}
|
||||
if ev.Item.Type == "reasoning" && ev.Item.EncryptedContent != "" {
|
||||
// sealed: the item as the upstream wrote it, for a Responses
|
||||
// client and for its next request (#1445)
|
||||
var whole struct {
|
||||
Item json.RawMessage `json:"item"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &whole) == nil && len(whole.Item) > 0 {
|
||||
emit(Event{Kind: KSealed, Name: sealResponses, ID: ev.Item.ID, Text: string(whole.Item)})
|
||||
}
|
||||
}
|
||||
if a := ev.Item.Action; ev.Item.Type == "web_search_call" && a != nil && a.Query != "" {
|
||||
var hits []Hit
|
||||
for _, src := range a.Sources {
|
||||
@@ -1103,9 +1152,13 @@ type responsesEncoder struct {
|
||||
open Kind // kind of the open item
|
||||
itemID string // id of the open item
|
||||
text strings.Builder // text of the open message / reasoning
|
||||
output []map[string]any
|
||||
col collector
|
||||
named map[string]nsTool // the request's namespaced tools
|
||||
// a reasoning item began upstream (KThinkStart): the next thinking
|
||||
// opens an item of its own, by thinkID when the upstream named it
|
||||
thinkNew bool
|
||||
thinkID string
|
||||
output []any
|
||||
col collector
|
||||
named map[string]nsTool // the request's namespaced tools
|
||||
}
|
||||
|
||||
// callTo names the tool a function_call item is to as the client knows it:
|
||||
@@ -1164,7 +1217,7 @@ func (e *responsesEncoder) response(status string, extra map[string]any) map[str
|
||||
// response.created has output null (its nil slice in an any isn't nil)
|
||||
output := e.output
|
||||
if output == nil {
|
||||
output = []map[string]any{}
|
||||
output = []any{}
|
||||
}
|
||||
out := map[string]any{"id": e.id, "object": "response", "created_at": e.created, "status": status,
|
||||
"model": e.model, "output": output, "parallel_tool_calls": true, "tool_choice": "auto", "tools": []any{}}
|
||||
@@ -1194,10 +1247,16 @@ func (e *responsesEncoder) start(ev Event) {
|
||||
}
|
||||
|
||||
func (e *responsesEncoder) closeItem() {
|
||||
e.closeAs(nil)
|
||||
}
|
||||
|
||||
// closeAs closes the open item, as sealed when the upstream wrote it
|
||||
// whole (a reasoning item it sealed).
|
||||
func (e *responsesEncoder) closeAs(sealed json.RawMessage) {
|
||||
if e.open == "" {
|
||||
return
|
||||
}
|
||||
var item map[string]any
|
||||
var item any
|
||||
switch e.open {
|
||||
case Text:
|
||||
t := e.text.String()
|
||||
@@ -1211,6 +1270,9 @@ func (e *responsesEncoder) closeItem() {
|
||||
part := map[string]any{"type": "summary_text", "text": t}
|
||||
e.send("response.reasoning_summary_part.done", map[string]any{"item_id": e.itemID, "output_index": e.item, "summary_index": 0, "part": part})
|
||||
item = map[string]any{"id": e.itemID, "type": "reasoning", "status": "completed", "summary": []map[string]any{part}}
|
||||
if sealed != nil {
|
||||
item = sealed
|
||||
}
|
||||
case ToolCall:
|
||||
args := strings.TrimSpace(e.text.String())
|
||||
if args == "" {
|
||||
@@ -1231,6 +1293,10 @@ func (e *responsesEncoder) openItem(k Kind, prefix string, item map[string]any)
|
||||
e.closeItem()
|
||||
e.item++
|
||||
e.open, e.itemID = k, prefix+newID()
|
||||
if k == Thinking && e.thinkID != "" {
|
||||
e.itemID = e.thinkID
|
||||
}
|
||||
e.thinkNew, e.thinkID = false, ""
|
||||
item["id"] = e.itemID
|
||||
item["status"] = "in_progress"
|
||||
e.send("response.output_item.added", map[string]any{"output_index": e.item, "item": item})
|
||||
@@ -1258,13 +1324,35 @@ func (e *responsesEncoder) event(ev Event) {
|
||||
if ev.Text == "" {
|
||||
return
|
||||
}
|
||||
if e.open != Thinking {
|
||||
if e.open != Thinking || e.thinkNew {
|
||||
e.openItem(Thinking, "rs_", map[string]any{"type": "reasoning", "summary": []any{}})
|
||||
e.send("response.reasoning_summary_part.added", map[string]any{"item_id": e.itemID, "output_index": e.item, "summary_index": 0,
|
||||
"part": map[string]any{"type": "summary_text", "text": ""}})
|
||||
}
|
||||
e.text.WriteString(ev.Text)
|
||||
e.send("response.reasoning_summary_text.delta", map[string]any{"item_id": e.itemID, "output_index": e.item, "summary_index": 0, "delta": ev.Text})
|
||||
case KThinkStart:
|
||||
e.thinkNew, e.thinkID = true, ""
|
||||
if strings.HasPrefix(ev.ID, "rs_") {
|
||||
e.thinkID = ev.ID
|
||||
}
|
||||
case KSealed:
|
||||
if ev.Name != sealResponses {
|
||||
break // another API's, which a Responses client can't read
|
||||
}
|
||||
raw := json.RawMessage(ev.Text)
|
||||
if e.open == Thinking && !e.thinkNew && (ev.ID == "" || ev.ID == e.itemID) {
|
||||
// the item streamed so far, done as the upstream wrote it
|
||||
e.closeAs(raw)
|
||||
break
|
||||
}
|
||||
// none of it streamed (no summary): added and done whole
|
||||
e.closeItem()
|
||||
e.item++
|
||||
e.thinkNew, e.thinkID = false, ""
|
||||
e.send("response.output_item.added", map[string]any{"output_index": e.item, "item": raw})
|
||||
e.send("response.output_item.done", map[string]any{"output_index": e.item, "item": raw})
|
||||
e.output = append(e.output, raw)
|
||||
case KToolStart:
|
||||
if ev.ID == "" {
|
||||
ev.ID = "call_" + newID()
|
||||
@@ -1326,13 +1414,24 @@ func (e *responsesEncoder) finish() {
|
||||
|
||||
// renderResponses is the non-streaming reply.
|
||||
func renderResponses(res Result, model string, named map[string]nsTool) []byte {
|
||||
output := []map[string]any{}
|
||||
output := []any{}
|
||||
for _, p := range res.Parts {
|
||||
switch p.Kind {
|
||||
case Text:
|
||||
if p.Text == "" && p.Signature != "" {
|
||||
continue // only Gemini's signature, which is Gemini's
|
||||
}
|
||||
output = append(output, map[string]any{"id": "msg_" + newID(), "type": "message", "role": "assistant", "status": "completed",
|
||||
"content": []map[string]any{{"type": "output_text", "text": p.Text, "annotations": []any{}}}})
|
||||
case Thinking:
|
||||
if p.sealedBy(sealResponses) {
|
||||
// as the upstream wrote it: id, summary, encrypted_content
|
||||
output = append(output, p.Sealed)
|
||||
continue
|
||||
}
|
||||
if p.Text == "" {
|
||||
continue // another API's sealed thinking, or only its signature
|
||||
}
|
||||
output = append(output, map[string]any{"id": "rs_" + newID(), "type": "reasoning", "status": "completed",
|
||||
"summary": []map[string]any{{"type": "summary_text", "text": p.Text}}})
|
||||
case ToolCall:
|
||||
|
||||
Reference in new issue
Block a user