Files
2026-10-23 18:35:44 +08:00

268 lines
9.0 KiB
Go

package protocol
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"time"
)
func TestRequestMatrix(t *testing.T) {
chat := []byte(`{"model":"old","max_tokens":100,"stream":true,"messages":[{"role":"system","content":"help"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"data:image/png;base64,YQ=="}}]},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{\"path\":\"a\"}"}}]},{"role":"tool","tool_call_id":"c1","content":"ok"},{"role":"user","content":"continue"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}],"tool_choice":"auto"}`)
for _, from := range []Kind{Chat, Responses, Anthropic} {
src := chat
var e error
if from != Chat {
src, e = ConvertRequest(Chat, from, chat, "new")
if e != nil {
t.Fatal(e)
}
}
for _, to := range []Kind{Chat, Responses, Anthropic} {
t.Run(string(from)+"/"+string(to), func(t *testing.T) {
b, e := ConvertRequest(from, to, src, "target")
if e != nil {
t.Fatal(e)
}
m, e := decode(b)
if e != nil || m["model"] != "target" {
t.Fatalf("%s %v", b, e)
}
r, e := readRequest(to, m)
if e != nil {
t.Fatal(e)
}
var calls, results, images int
for _, m := range r.messages {
for _, p := range m.parts {
switch p.typ {
case "call":
calls++
if p.id != "c1" {
t.Fatal(p)
}
case "result":
results++
case "image":
images++
}
}
}
if calls != 1 || results != 1 || images != 1 {
t.Fatalf("lost content: %s", b)
}
})
}
}
}
func TestStrictRejection(t *testing.T) {
cases := []struct {
k Kind
b string
to Kind
}{
{Chat, `{"messages":[],"response_format":{"type":"json_object"}}`, Responses},
{Chat, `{"messages":[]}`, Anthropic},
{Responses, `{"input":[],"previous_response_id":"r"}`, Chat},
{Anthropic, `{"max_tokens":10,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"x"}]}]}`, Chat},
{Chat, `{"messages":[],"n":2}`, Responses},
{Chat, `{"messages":[],"stop":["x"]}`, Responses},
{Chat, `{"max_tokens":10,"messages":[],"tools":[{"type":"function","function":{"name":"x","parameters":{},"strict":true}}]}`, Anthropic},
}
for _, c := range cases {
if b, e := ConvertRequest(c.k, c.to, []byte(c.b), "m"); e == nil {
t.Fatalf("accepted %s => %s", c.b, b)
}
}
}
func TestResponseMatrix(t *testing.T) {
chat := []byte(`{"id":"r","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"working","tool_calls":[{"id":"c","type":"function","function":{"name":"read","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3}}}`)
for _, from := range []Kind{Chat, Responses, Anthropic} {
src, e := ConvertResponse(Chat, from, chat)
if e != nil {
t.Fatal(e)
}
for _, to := range []Kind{Chat, Responses, Anthropic} {
b, e := ConvertResponse(from, to, src)
if e != nil {
t.Fatal(from, to, e)
}
m, _ := decode(b)
r, e := readResponse(to, m)
if e != nil || len(r.parts) != 2 || r.finish != "tool" {
t.Fatalf("%s %v", b, e)
}
u := ExtractUsage(to, b)
if u.Input != 12 || u.Output != 4 || u.CacheRead != 3 || !u.Known {
t.Fatal(u)
}
}
}
}
type flushBuffer struct {
bytes.Buffer
flushes int
}
func (b *flushBuffer) Flush() { b.flushes++ }
func chatStream() string {
return strings.Join([]string{
`data: {"id":"r","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`,
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"read","arguments":"{\"x\":"}},{"index":1,"id":"c2","type":"function","function":{"name":"write","arguments":"{"}}]}}]}`,
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}},{"index":0,"function":{"arguments":"1}"}}]}}]}`,
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
`data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":7}}`,
`data: [DONE]`,
}, "\n\n") + "\n\n"
}
func TestStreamMatrix(t *testing.T) {
for _, from := range []Kind{Chat, Responses, Anthropic} {
src := chatStream()
if from != Chat {
var b bytes.Buffer
if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, from, "m"); e != nil {
t.Fatal(e)
}
src = b.String()
}
for _, to := range []Kind{Chat, Responses, Anthropic} {
t.Run(string(from)+"/"+string(to), func(t *testing.T) {
var b flushBuffer
u, e := Stream(context.Background(), &b, strings.NewReader(src), from, to, "m")
if e != nil {
t.Fatal(e)
}
if u.Input != 10 || u.Output != 7 || !u.Known {
t.Fatal(u)
}
if b.flushes < 3 {
t.Fatal("not flushed")
}
if from == to && b.String() != src {
t.Fatal("passthrough changed bytes")
}
if to == Responses {
for _, event := range []string{"response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done", "response.completed"} {
if !strings.Contains(b.String(), event) {
t.Fatal("missing", event)
}
}
}
if to == Anthropic && !strings.Contains(b.String(), "message_stop") {
t.Fatal("missing stop")
}
})
}
}
}
func TestUsageCumulative(t *testing.T) {
src := "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"output_tokens\":1,\"cache_read_input_tokens\":8,\"cache_creation_input_tokens\":2}}}\n\n" + "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":9}}\n\n"
var b bytes.Buffer
u, e := Stream(context.Background(), &b, strings.NewReader(src), Anthropic, Anthropic, "")
if e != nil {
t.Fatal(e)
}
if u.Input != 15 || u.Output != 9 || u.CacheRead != 8 || u.CacheWrite != 2 {
t.Fatal(u)
}
raw, e := decode(u.Raw)
if e != nil || num(raw["input_tokens"]) != 5 || num(raw["output_tokens"]) != 9 || num(raw["cache_read_input_tokens"]) != 8 {
t.Fatal(string(u.Raw), e)
}
}
func TestRealtimeBeforeEOF(t *testing.T) {
r, w := io.Pipe()
defer r.Close()
defer w.Close()
done := make(chan error, 1)
out := &signalWriter{signal: make(chan struct{}, 1)}
go func() { _, e := Stream(context.Background(), out, r, Chat, Responses, "m"); done <- e }()
first, rest, _ := strings.Cut(chatStream(), "\n\n")
if _, e := io.WriteString(w, first+"\n\n"); e != nil {
t.Fatal(e)
}
select {
case <-out.signal:
case <-time.After(time.Second):
t.Fatal("first delta buffered until EOF")
}
if _, e := io.WriteString(w, rest); e != nil {
t.Fatal(e)
}
select {
case e := <-done:
if e != nil {
t.Fatal(e)
}
case <-time.After(time.Second):
t.Fatal("terminal marker waits for EOF")
}
}
type signalWriter struct{ signal chan struct{} }
func (w *signalWriter) Write(p []byte) (int, error) {
select {
case w.signal <- struct{}{}:
default:
}
return len(p), nil
}
func TestUnknownUsageAndUniqueID(t *testing.T) {
src := "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
var previous string
for i := 0; i < 2; i++ {
var b bytes.Buffer
u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Anthropic, "m")
if e != nil || u.Known {
t.Fatal(u, e)
}
line := strings.Split(b.String(), "\n")[1]
m, e := decode([]byte(strings.TrimPrefix(line, "data: ")))
if e != nil {
t.Fatal(e)
}
msg := obj(m["message"])
if obj(msg["usage"]) == nil {
t.Fatal("missing initial usage")
}
id := str(msg["id"])
if id == "" || id == previous {
t.Fatal("nonunique id")
}
previous = id
}
}
func TestStreamErrors(t *testing.T) {
for _, src := range []string{"data: nope\n\n", "data: {\"error\":{\"message\":\"bad\"}}\n\n", strings.Replace(chatStream(), "data: [DONE]\n\n", "", 1), strings.Replace(chatStream(), `"arguments":"1}"`, `"arguments":"oops}"`, 1)} {
var b bytes.Buffer
if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Responses, "m"); e == nil {
t.Fatal("accepted broken stream", src)
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, e := Stream(ctx, io.Discard, strings.NewReader(chatStream()), Chat, Chat, ""); !errors.Is(e, context.Canceled) {
t.Fatal(e)
}
if _, e := Stream(context.Background(), badWriter{}, strings.NewReader(chatStream()), Chat, Anthropic, ""); !errors.Is(e, io.ErrClosedPipe) {
t.Fatal(e)
}
}
type badWriter struct{}
func (badWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestSSEMultilineAndCRLF(t *testing.T) {
src := ": comment\r\n\r\ndata: {\r\ndata: \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3}}\r\n\r\ndata: [DONE]\r\n\r\n"
var b flushBuffer
u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Chat, "")
if e != nil || u.Input != 2 || u.Output != 3 || b.String() != src {
t.Fatal(u, e, b.String())
}
}