Files
sub2api/backend/internal/pkg/apicompat/responses_stream_event_wire_test.go
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

187 lines
6.7 KiB
Go

package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// marshalEvent marshals through the custom MarshalJSON and returns the decoded
// object plus the set of top-level keys.
func marshalEvent(t *testing.T, e ResponsesStreamEvent) map[string]any {
t.Helper()
b, err := json.Marshal(e)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
return m
}
// TestWire_IndexFieldsPresentAtZero guards the omitempty trap: output_index/
// content_index/summary_index must serialize even when 0.
func TestWire_IndexFieldsPresentAtZero(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1", Delta: "hi",
})
require.Contains(t, m, "output_index")
require.Contains(t, m, "content_index")
require.EqualValues(t, 0, m["output_index"])
r := marshalEvent(t, ResponsesStreamEvent{
Type: "response.reasoning_summary_text.delta", OutputIndex: 0, SummaryIndex: 0, ItemID: "rs_1", Delta: "think",
})
require.Contains(t, r, "output_index")
require.Contains(t, r, "summary_index")
}
// TestWire_FunctionCallItemAlwaysComplete guards that a function_call item
// always carries call_id/name/arguments, including arguments:"" on .added.
func TestWire_FunctionCallItemAlwaysComplete(t *testing.T) {
added := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", ID: "fc_1", CallID: "call_a", Name: "exec", Status: "in_progress"},
})
item, ok := added["item"].(map[string]any)
require.True(t, ok, "item must be an object")
for _, k := range []string{"call_id", "name", "arguments"} {
require.Containsf(t, item, k, "function_call item missing %q", k)
}
require.Equal(t, "", item["arguments"])
}
// TestWire_MessageItemContentAlwaysArray guards content:[] presence.
func TestWire_MessageItemContentAlwaysArray(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "message", ID: "msg_1", Role: "assistant", Status: "in_progress"},
})
item, ok := m["item"].(map[string]any)
require.True(t, ok, "item must be an object")
require.Contains(t, item, "content")
_, ok = item["content"].([]any)
require.True(t, ok, "content must be an array")
}
// TestWire_ReasoningItemSummaryAlwaysArray guards summary:[] presence.
func TestWire_ReasoningItemSummaryAlwaysArray(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "reasoning", ID: "rs_1", Status: "in_progress"},
})
item, ok := m["item"].(map[string]any)
require.True(t, ok, "item must be an object")
require.Contains(t, item, "summary")
_, ok = item["summary"].([]any)
require.True(t, ok, "summary must be an array")
}
// TestWire_ContentPartCarriesAnnotationsLogprobs guards the output_text part shape.
func TestWire_ContentPartCarriesAnnotationsLogprobs(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.content_part.added", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1",
Part: &ResponsesContentPart{Type: "output_text", Text: ""},
})
part, ok := m["part"].(map[string]any)
require.True(t, ok, "part must be an object")
require.Equal(t, "output_text", part["type"])
require.Contains(t, part, "text")
require.Contains(t, part, "annotations")
require.Contains(t, part, "logprobs")
}
// TestWire_ArgumentsDonePresentEvenEmpty guards arguments presence on done.
func TestWire_ArgumentsDonePresentEvenEmpty(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.function_call_arguments.done", OutputIndex: 1, ItemID: "fc_1", CallID: "call_a", Name: "exec", Arguments: "",
})
require.Contains(t, m, "arguments")
require.Equal(t, "", m["arguments"])
}
// TestWire_CustomToolCallInputIndexPresentAtZero guards the omitempty trap for
// custom_tool_call_input.delta/done: output_index must serialize even when 0
// (custom tool call as the first output item).
func TestWire_CustomToolCallInputIndexPresentAtZero(t *testing.T) {
d := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.delta", OutputIndex: 0, ItemID: "ct_1", Delta: "dir",
})
require.Contains(t, d, "output_index")
require.EqualValues(t, 0, d["output_index"])
require.Equal(t, "dir", d["delta"])
done := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.done", OutputIndex: 0, ItemID: "ct_1", CallID: "call_1", Name: "exec", Input: "dir",
})
require.Contains(t, done, "output_index")
require.EqualValues(t, 0, done["output_index"])
require.Equal(t, "dir", done["input"])
require.NotContains(t, done, "delta")
}
// TestWire_UnknownEventFallsBackToDefault ensures non-streamed event types keep
// default marshalling (the response object is preserved).
func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "completed"},
})
require.Contains(t, m, "response")
}
func TestResponsesOutputUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var item ResponsesOutput
require.NoError(t, json.Unmarshal([]byte(`{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"execution":"client",
"arguments":{"query":"gmail","limit":2}
}`), &item))
require.Equal(t, "tool_search_call", item.Type)
require.Equal(t, `{"query":"gmail","limit":2}`, item.Arguments)
wire, err := json.Marshal(item)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(wire, &decoded))
args, ok := decoded["arguments"].(map[string]any)
require.True(t, ok, "tool_search_call arguments must remain an object")
require.Equal(t, "gmail", args["query"])
}
func TestResponsesResponseUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var response ResponsesResponse
require.NoError(t, json.Unmarshal([]byte(`{
"id":"response_1",
"object":"response",
"status":"completed",
"output":[{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"arguments":{"query":"gmail"}
}]
}`), &response))
require.Len(t, response.Output, 1)
require.Equal(t, `{"query":"gmail"}`, response.Output[0].Arguments)
}
func TestResponsesStreamEventUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var event ResponsesStreamEvent
require.NoError(t, json.Unmarshal([]byte(`{
"type":"response.output_item.done",
"item":{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"arguments":{"query":"gmail"}
}
}`), &event))
require.NotNil(t, event.Item)
require.Equal(t, `{"query":"gmail"}`, event.Item.Arguments)
}