Files
Agent-Native/runtime/llm/client_test.go
T
agent-exec-transport 5b21325da7
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 49s
agent-exec transport ed24e191762d6f6e3b41589f552e7744/cd1765c54231
2026-10-19 02:53:01 +00:00

399 lines
16 KiB
Go

package llm
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
)
type terminalRoundTripFunc func(*http.Request) (*http.Response, error)
func (f terminalRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestClientTerminalObservationHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\ndata: [DONE]\n\n"))
}))
defer server.Close()
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
var end Event
for ev := range client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.HTTPStatus != http.StatusOK || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "done_marker" || !end.StreamComplete || end.Usage.TotalTokens != 3 {
t.Fatalf("successful HTTP terminal lost transport evidence: %#v", end)
}
}
func TestClientTerminalObservationProviderAndTransportFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PRIVATE_PROVIDER_BODY_R05", http.StatusTooManyRequests)
}))
defer server.Close()
tests := []struct {
name string
client *Client
status int
outcome string
}{
{"provider-http", &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}, http.StatusTooManyRequests, "provider_error"},
{"transport", &Client{BaseURL: "http://test.invalid", APIKey: "fixture", HTTPClient: &http.Client{Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("PRIVATE_TRANSPORT_ERROR_R05")
})}}, 0, "transport_error"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var end Event
for ev := range tc.client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
end = ev
}
if end.Kind != KindError || end.HTTPStatus != tc.status || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != tc.outcome {
t.Fatalf("provider/transport failure was not classified: %#v", end)
}
})
}
}
// bodyBoundaryToolFrames is a complete tool call plus Usage without [DONE].
const bodyBoundaryToolFrames = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_PRIVATE_R06\",\"function\":{\"name\":\"read_PRIVATE_R06\",\"arguments\":\"{\\\"path\\\":\\\"PRIVATE_R06.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n" +
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n"
const bodyBoundaryDone = "data: [DONE]\n\n"
func streamBodyBoundaryEnd(t *testing.T, ctx context.Context, client *Client) Event {
t.Helper()
var end Event
terminals := 0
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
terminals++
} else if ev.TerminalObservation != nil {
t.Fatalf("non-terminal event carried an observation: %#v", ev)
}
}
if terminals != 1 {
t.Fatalf("terminal events = %d, want 1", terminals)
}
if end.TerminalObservation != nil {
raw, err := json.Marshal(end.TerminalObservation)
if err != nil || strings.Contains(string(raw), "PRIVATE") || strings.Contains(string(raw), "127.0.0.1") {
t.Fatalf("unsafe terminal observation: %s err=%v", raw, err)
}
}
return end
}
type bodyBoundaryWant struct {
protocol, framing, relation string
declared, eof, uncompressed bool
bytes int64
}
func requireBodyBoundary(t *testing.T, end Event, want bodyBoundaryWant) {
t.Helper()
if end.TerminalObservation == nil || end.TerminalObservation.BodyBoundary == nil {
t.Fatalf("missing body boundary observation: %#v", end)
}
body := end.TerminalObservation.BodyBoundary
if body.ProtocolClass != want.protocol || body.FramingClass != want.framing ||
body.LengthRelation != want.relation || body.DeclaredLengthKnown != want.declared ||
body.EOFSeen != want.eof || body.Uncompressed != want.uncompressed || body.BytesSaturated {
t.Fatalf("wrong body boundary: %+v want %+v", body, want)
}
if want.bytes >= 0 && body.BytesRead != want.bytes {
t.Fatalf("bytes read = %d, want %d", body.BytesRead, want.bytes)
}
}
func requireUnreleasedTools(t *testing.T, end Event) {
t.Helper()
if end.Kind != KindMessageEnd || end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 ||
end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK {
t.Fatalf("unfinished tool stream was released or lost usage: %#v", end)
}
}
func fixtureResponseClient(resp func() *http.Response) *Client {
return &Client{BaseURL: "http://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) { return resp(), nil }),
}}
}
func TestClientBodyBoundaryShortRead(t *testing.T) {
t.Run("declared-longer-than-body", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
if end.TerminalObservation.ReadOutcome != "read_error" {
t.Fatalf("short body was not a read error: %+v", end.TerminalObservation)
}
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
t.Run("declared-length-mismatch", func(t *testing.T) {
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
ContentLength: int64(len(bodyBoundaryToolFrames) + 64), Header: http.Header{},
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "mismatch",
declared: true, eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundaryUncompressed(t *testing.T) {
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, _ = io.WriteString(zw, bodyBoundaryToolFrames)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Length", strconv.Itoa(compressed.Len()))
_, _ = w.Write(compressed.Bytes())
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "unknown", relation: "unknown",
eof: true, uncompressed: true, bytes: int64(len(bodyBoundaryToolFrames))})
}
// doneThenErrorBody returns the whole stream without io.EOF; any later read fails.
type doneThenErrorBody struct {
data *strings.Reader
reads int
}
func (b *doneThenErrorBody) Read(p []byte) (int, error) {
if b.data.Len() > 0 {
return b.data.Read(p)
}
b.reads++
return 0, errors.New("PRIVATE_READ_AFTER_DONE_R06")
}
func (b *doneThenErrorBody) Close() error { return nil }
func TestClientDoneDoesNotNeedBodyEOF(t *testing.T) {
stream := bodyBoundaryToolFrames + bodyBoundaryDone
body := &doneThenErrorBody{data: strings.NewReader(stream)}
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1, ContentLength: -1,
TransferEncoding: []string{"chunked"}, Header: http.Header{}, Body: body}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
if end.Kind != KindMessageEnd || end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 ||
end.Message.StopReason != StopToolCalls || end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK ||
end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
}
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown",
bytes: int64(len(stream))})
if body.reads != 0 {
t.Fatalf("parser read past [DONE] %d times", body.reads)
}
var standalone Event
for ev := range ParseSSE(context.Background(), strings.NewReader(stream)) {
standalone = ev
}
if standalone.TerminalObservation == nil || standalone.TerminalObservation.BodyBoundary != nil ||
len(standalone.Message.ToolCalls) != 1 || !standalone.StreamComplete {
t.Fatalf("standalone ParseSSE changed: %#v", standalone)
}
}
func TestClientBodyBoundaryChunkedAndHTTP2(t *testing.T) {
flushed := func(stream string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, stream)
w.(http.Flusher).Flush()
}
}
tests := []struct {
name string
server func() *httptest.Server
wantDone bool
want bodyBoundaryWant
}{
{"known-length", func() *httptest.Server {
stream := bodyBoundaryToolFrames + bodyBoundaryDone
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
_, _ = io.WriteString(w, stream)
}))
}, true, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "match", declared: true, eof: true,
bytes: int64(len(bodyBoundaryToolFrames + bodyBoundaryDone))}},
{"chunked", func() *httptest.Server {
return httptest.NewServer(flushed(bodyBoundaryToolFrames))
}, false, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown", eof: true,
bytes: int64(len(bodyBoundaryToolFrames))}},
{"http2", func() *httptest.Server {
server := httptest.NewUnstartedServer(flushed(bodyBoundaryToolFrames))
server.EnableHTTP2 = true
server.StartTLS()
return server
}, false, bodyBoundaryWant{protocol: "http2", framing: "http2_stream", relation: "unknown", eof: true,
bytes: int64(len(bodyBoundaryToolFrames))}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := tc.server()
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireBodyBoundary(t, end, tc.want)
if !tc.wantDone {
requireUnreleasedTools(t, end)
return
}
if end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 || end.Usage.TotalTokens != 7 {
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
}
})
}
}
func TestClientBodyBoundaryCloseDelimitedUnknown(t *testing.T) {
t.Run("close-delimited", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "identity")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
w.(http.Flusher).Flush()
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "close_delimited", relation: "unknown",
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
t.Run("undeterminable", func(t *testing.T) {
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "other", framing: "unknown", relation: "unknown",
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundaryFailuresDoNotClaimEOF(t *testing.T) {
t.Run("cancelled", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"PRIVATE_R06\"}}]}\n\n")
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
var end Event
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
cancel()
}
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
}
}
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "cancelled" {
t.Fatalf("cancelled stream was not aborted: %#v", end)
}
if body := end.TerminalObservation.BodyBoundary; body == nil || body.EOFSeen || body.LengthRelation != "unknown" {
t.Fatalf("cancelled stream claimed body EOF: %+v", body)
}
})
t.Run("provider-4xx", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PRIVATE_PROVIDER_BODY_R06", http.StatusBadRequest)
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
if end.Kind != KindError || end.HTTPStatus != http.StatusBadRequest || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "provider_error" || end.TerminalObservation.BodyBoundary != nil {
t.Fatalf("provider error carried a body boundary: %#v", end)
}
})
t.Run("redirect-then-short-read", func(t *testing.T) {
var finalHits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/final/chat/completions")
w.WriteHeader(http.StatusTemporaryRedirect)
_, _ = io.WriteString(w, "PRIVATE_REDIRECT_BODY_R06")
})
mux.HandleFunc("/final/chat/completions", func(w http.ResponseWriter, r *http.Request) {
finalHits.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
})
server := httptest.NewServer(mux)
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
if hits := finalHits.Load(); hits != 1 {
t.Fatalf("redirect target hits = %d, want 1", hits)
}
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundarySaturatesCount(t *testing.T) {
size := int64(maxObservedBodyBytes + 4096)
observer := newBodyObserver(&http.Response{ProtoMajor: 1, ContentLength: size,
Body: io.NopCloser(io.LimitReader(zeroReader{}, size))})
if n, err := io.Copy(io.Discard, observer); err != nil || n != size {
t.Fatalf("observer changed the body: n=%d err=%v", n, err)
}
body := observer.snapshot()
if body.BytesRead != maxObservedBodyBytes || !body.BytesSaturated || !body.EOFSeen ||
body.LengthRelation != "unknown" || !body.DeclaredLengthKnown {
t.Fatalf("saturated body boundary: %+v", body)
}
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}