149 lines
5.4 KiB
Go
149 lines
5.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestStreamDeliversFirstChunkBeforeCompletion(t *testing.T) {
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
if body["stream"] != true {
|
|
t.Error("stream=true was not sent")
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
|
t.Error("missing stream credential")
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
fmt.Fprint(w, ": heartbeat\r\nevent: message\r\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"你好\"}}]}\r\n\r\n")
|
|
w.(http.Flusher).Flush()
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
fmt.Fprint(w, "data: {\"choices\":[\ndata: {\"index\":0,\"delta\":{\"content\":\",世界🙂\"}}]}\n\n")
|
|
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]")
|
|
}))
|
|
defer server.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
chunks := make(chan string, 4)
|
|
finished := make(chan error, 1)
|
|
go func() {
|
|
text, reason, err := completeStream(ctx, testConfig(server.URL), "test-key", nil, func(delta string) { chunks <- delta })
|
|
if err == nil && (text != "你好,世界🙂" || reason != "stop") {
|
|
err = fmt.Errorf("wrong completion: %q %q", text, reason)
|
|
}
|
|
finished <- err
|
|
}()
|
|
select {
|
|
case first := <-chunks:
|
|
if first != "你好" {
|
|
t.Fatalf("first chunk: %q", first)
|
|
}
|
|
case err := <-finished:
|
|
t.Fatalf("returned before releasing stream: %v", err)
|
|
case <-ctx.Done():
|
|
t.Fatal("first chunk was buffered until stream ended")
|
|
}
|
|
close(release)
|
|
if err := <-finished; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second := <-chunks; second != ",世界🙂" {
|
|
t.Fatalf("Unicode/multiline chunk damaged: %q", second)
|
|
}
|
|
}
|
|
|
|
func TestStreamCancellationClosesHTTPAndDoesNotCommitHistory(t *testing.T) {
|
|
disconnected := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"}}]}\n\n")
|
|
w.(http.Flusher).Flush()
|
|
<-r.Context().Done()
|
|
close(disconnected)
|
|
}))
|
|
defer server.Close()
|
|
s, _ := testService(t)
|
|
if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s.emitDelta = func(delta ChatDelta) {
|
|
if delta.RequestID != "request-1" || delta.Delta != "partial" {
|
|
t.Errorf("wrong event: %+v", delta)
|
|
}
|
|
cancel()
|
|
}
|
|
_, err := s.ChatStream(ctx, "request-1", "phone", "hello")
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancellation lost: %v", err)
|
|
}
|
|
if len(s.history["phone"]) != 0 {
|
|
t.Fatal("cancelled answer committed to model context")
|
|
}
|
|
select {
|
|
case <-disconnected:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("cancel did not close HTTP request")
|
|
}
|
|
}
|
|
|
|
func TestStreamCompletionAndFailureMarkers(t *testing.T) {
|
|
cases := []struct {
|
|
name, input, text, reason string
|
|
wantError bool
|
|
}{
|
|
{"done", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "ok", "", false},
|
|
{"length", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"},\"finish_reason\":\"length\"}]}\n\n", "part", "length", false},
|
|
{"interrupted", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"}}]}\n\n", "part", "", true},
|
|
{"empty", "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: [DONE]\n\n", "", "", true},
|
|
{"malformed", "data: {oops}\n\n", "", "", true},
|
|
{"provider-error", "data: {\"error\":{\"message\":\"rejected secret-key\"}}\n\n", "", "", true},
|
|
{"filtered", "data: {\"choices\":[{\"delta\":{\"content\":\"part\"},\"finish_reason\":\"content_filter\"}]}\n\n", "part", "content_filter", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var received strings.Builder
|
|
text, reason, err := readCompletionStream(strings.NewReader(tc.input), "secret-key", func(delta string) { received.WriteString(delta) })
|
|
if (err != nil) != tc.wantError || text != tc.text || reason != tc.reason || received.String() != text {
|
|
t.Fatalf("got %q %q %v, callback %q", text, reason, err, received.String())
|
|
}
|
|
if err != nil && strings.Contains(err.Error(), "secret-key") {
|
|
t.Fatal("provider error exposed credential")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSuccessfulStreamCommitsHistory(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: {\"choices\":[],\"usage\":{}}\n\ndata: [DONE]\n\n")
|
|
}))
|
|
defer server.Close()
|
|
s, _ := testService(t)
|
|
if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := s.ChatStream(context.Background(), "request-1", "phone", "question")
|
|
if err != nil || result["content"] != "hello" {
|
|
t.Fatalf("completion: %v %v", result, err)
|
|
}
|
|
if len(s.history["phone"]) != 2 || s.history["phone"][1].Content != "hello" {
|
|
t.Fatal("completed stream did not enter history")
|
|
}
|
|
}
|