Files
sub2api/backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go
T
李建琦 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

188 lines
7.3 KiB
Go

package apicompat
import "testing"
// TestAnthropicEventToResponses_TextEmitsContentPart pins that a message text
// stream emits response.content_part.added, and that it precedes the first
// output_text.delta for that part.
//
// Why: the OpenAI SDK's accumulating stream helper (client.responses.stream)
// only appends a content part to the message item when it sees
// content_part.added. The item is added with content: [], so a missing event
// makes the following output_text.delta index output.content[content_index] and
// raise IndexError. Raw event iteration does not accumulate, so a regression
// here is easy to miss.
func TestAnthropicEventToResponses_TextEmitsContentPart(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var types []string
feed := func(evt *AnthropicStreamEvent) {
for _, out := range AnthropicEventToResponsesEvents(evt, state) {
types = append(types, out.Type)
}
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1", Model: "claude-sonnet-4-5"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hel"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "lo"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
posOf := func(target string) int {
for i, ty := range types {
if ty == target {
return i
}
}
return -1
}
partAdded := posOf("response.content_part.added")
firstDelta := posOf("response.output_text.delta")
if partAdded < 0 {
t.Fatalf("response.content_part.added was not emitted; got %v", types)
}
if firstDelta < 0 {
t.Fatalf("response.output_text.delta was not emitted; got %v", types)
}
if partAdded > firstDelta {
t.Errorf("content_part.added must precede the first output_text.delta; got %v", types)
}
if posOf("response.content_part.done") < 0 {
t.Errorf("response.content_part.done was not emitted; got %v", types)
}
}
// TestAnthropicEventToResponses_DoneEventsCarryFullText pins that done events
// carry the part's full text (deltas carry increments only).
func TestAnthropicEventToResponses_DoneEventsCarryFullText(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hello "}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "world"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
const want = "Hello world"
var sawTextDone, sawPartDone bool
for _, e := range events {
switch e.Type {
case "response.output_text.done":
sawTextDone = true
if e.Text != want {
t.Errorf("output_text.done text = %q, want %q", e.Text, want)
}
case "response.content_part.done":
sawPartDone = true
if e.Part == nil || e.Part.Text != want {
t.Errorf("content_part.done part = %+v, want text %q", e.Part, want)
}
}
}
if !sawTextDone || !sawPartDone {
t.Errorf("missing done events: output_text.done=%v content_part.done=%v", sawTextDone, sawPartDone)
}
}
// TestAnthropicEventToResponses_CompletedCarriesOutput pins that
// response.completed carries the full output list. The SDK's
// get_final_response() and tracing integrations parse the terminal event's
// response directly; an empty output leaves them with nothing (the text still
// renders from deltas, which is why this is invisible when only watching the
// stream).
func TestAnthropicEventToResponses_CompletedCarriesOutput(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "4826"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" {
completed = &events[i]
}
}
if completed == nil || completed.Response == nil {
t.Fatalf("response.completed was not emitted")
}
if len(completed.Response.Output) == 0 {
t.Fatalf("response.completed carries an empty output; clients would see no result")
}
msg := completed.Response.Output[0]
if msg.Type != "message" || len(msg.Content) == 0 {
t.Fatalf("output[0] = %+v, want a message with content", msg)
}
if msg.Content[0].Text != "4826" {
t.Errorf("output[0].content[0].text = %q, want %q", msg.Content[0].Text, "4826")
}
}
// TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments pins that a
// function call's accumulated arguments survive into output_item.done and
// response.completed.
func TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{
Type: "tool_use", ID: "toolu_1", Name: "get_weather",
}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
Type: "input_json_delta", PartialJSON: `{"city":`,
}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
Type: "input_json_delta", PartialJSON: `"SH"}`,
}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" {
completed = &events[i]
}
}
if completed == nil || completed.Response == nil || len(completed.Response.Output) == 0 {
t.Fatalf("response.completed carries no output")
}
fc := completed.Response.Output[0]
if fc.Type != "function_call" {
t.Fatalf("output[0].type = %q, want function_call", fc.Type)
}
if fc.Arguments != `{"city":"SH"}` {
t.Errorf("arguments = %q, want %q", fc.Arguments, `{"city":"SH"}`)
}
if fc.Name != "get_weather" {
t.Errorf("name = %q, want get_weather", fc.Name)
}
}