2026-06-27 00:13:16 +00:00
|
|
|
package llm
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestCompletionsStreamStreamsReasoningDeltas(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/chat/completions" {
|
|
|
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
for _, data := range []string{
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"}}]}`,
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"reasoning_content":" more"}}]}`,
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"content":"answer"}}]}`,
|
|
|
|
|
} {
|
|
|
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewClient: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
events := make(chan StreamEvent)
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
errCh <- client.CompletionsStream(
|
2026-07-07 15:14:07 +00:00
|
|
|
context.Background(), CompletionsParams{
|
|
|
|
|
Model: "test-model",
|
|
|
|
|
SystemMessage: "",
|
|
|
|
|
MaxIterations: 0,
|
|
|
|
|
Messages: []Message{{
|
|
|
|
|
Role: RoleUser,
|
|
|
|
|
ContentParts: []ContentPart{{
|
|
|
|
|
Type: ContentTypeText,
|
|
|
|
|
Text: "hello",
|
|
|
|
|
}},
|
2026-06-27 00:13:16 +00:00
|
|
|
}},
|
2026-07-07 15:14:07 +00:00
|
|
|
}, events,
|
2026-06-27 00:13:16 +00:00
|
|
|
)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
var got []StreamEvent
|
|
|
|
|
for evt := range events {
|
|
|
|
|
got = append(got, evt)
|
|
|
|
|
}
|
|
|
|
|
if err := <-errCh; err != nil {
|
|
|
|
|
t.Fatalf("CompletionsStream: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wantTypes := []string{
|
|
|
|
|
streamEventReasoningDelta,
|
|
|
|
|
streamEventReasoningDelta,
|
|
|
|
|
streamEventDelta,
|
|
|
|
|
streamEventDone,
|
|
|
|
|
}
|
|
|
|
|
if len(got) != len(wantTypes) {
|
|
|
|
|
t.Fatalf("got %d events, want %d: %#v", len(got), len(wantTypes), got)
|
|
|
|
|
}
|
|
|
|
|
for i, want := range wantTypes {
|
|
|
|
|
if got[i].Type != want {
|
|
|
|
|
t.Fatalf("event %d type = %q, want %q", i, got[i].Type, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got[0].ReasoningDelta != "thinking" {
|
|
|
|
|
t.Errorf("first reasoning delta = %q", got[0].ReasoningDelta)
|
|
|
|
|
}
|
|
|
|
|
if got[1].ReasoningDelta != " more" {
|
|
|
|
|
t.Errorf("second reasoning delta = %q", got[1].ReasoningDelta)
|
|
|
|
|
}
|
|
|
|
|
if got[2].Delta != "answer" {
|
|
|
|
|
t.Errorf("content delta = %q", got[2].Delta)
|
|
|
|
|
}
|
|
|
|
|
final := got[3].Message
|
|
|
|
|
if final.ReasoningContent != "thinking more" {
|
|
|
|
|
t.Errorf("final reasoning = %q", final.ReasoningContent)
|
|
|
|
|
}
|
|
|
|
|
if final.Text() != "answer" {
|
|
|
|
|
t.Errorf("final text = %q", final.Text())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCompletionsReturnsUsageAndTimings(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/chat/completions" {
|
|
|
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
fmt.Fprint(w, `{
|
|
|
|
|
"id": "test-123",
|
|
|
|
|
"model": "test-model",
|
|
|
|
|
"choices": [{
|
|
|
|
|
"index": 0,
|
|
|
|
|
"message": {"role": "assistant", "content": "hello"},
|
|
|
|
|
"finish_reason": "stop"
|
|
|
|
|
}],
|
|
|
|
|
"usage": {
|
|
|
|
|
"prompt_tokens": 10,
|
|
|
|
|
"completion_tokens": 5,
|
|
|
|
|
"total_tokens": 15,
|
|
|
|
|
"prompt_tokens_details": {"cached_tokens": 3},
|
|
|
|
|
"completion_tokens_details": {"reasoning_tokens": 2}
|
|
|
|
|
},
|
|
|
|
|
"timings": {
|
|
|
|
|
"prompt_n": 10,
|
|
|
|
|
"prompt_ms": 50.5,
|
|
|
|
|
"prompt_per_token_ms": 5.05,
|
|
|
|
|
"prompt_per_second": 198.0,
|
|
|
|
|
"predicted_n": 5,
|
|
|
|
|
"predicted_ms": 100.0,
|
|
|
|
|
"predicted_per_token_ms": 20.0,
|
|
|
|
|
"predicted_per_second": 50.0
|
|
|
|
|
}
|
|
|
|
|
}`)
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewClient: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := client.Completions(
|
2026-07-07 15:14:07 +00:00
|
|
|
context.Background(), CompletionsParams{
|
|
|
|
|
Model: "test-model",
|
|
|
|
|
SystemMessage: "",
|
|
|
|
|
MaxIterations: 0,
|
|
|
|
|
Messages: []Message{{
|
|
|
|
|
Role: RoleUser,
|
|
|
|
|
ContentParts: []ContentPart{{
|
|
|
|
|
Type: ContentTypeText,
|
|
|
|
|
Text: "hi",
|
|
|
|
|
}},
|
2026-06-27 00:13:16 +00:00
|
|
|
}},
|
2026-07-07 15:14:07 +00:00
|
|
|
},
|
2026-06-27 00:13:16 +00:00
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Completions: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(res.Messages) != 1 {
|
|
|
|
|
t.Fatalf("got %d messages, want 1", len(res.Messages))
|
|
|
|
|
}
|
|
|
|
|
if res.Usage == nil {
|
|
|
|
|
t.Fatal("usage is nil")
|
|
|
|
|
}
|
|
|
|
|
if res.Usage.PromptTokens != 10 {
|
|
|
|
|
t.Errorf("prompt_tokens = %d, want 10", res.Usage.PromptTokens)
|
|
|
|
|
}
|
|
|
|
|
if res.Usage.CompletionTokens != 5 {
|
|
|
|
|
t.Errorf("completion_tokens = %d, want 5", res.Usage.CompletionTokens)
|
|
|
|
|
}
|
|
|
|
|
if res.Usage.TotalTokens != 15 {
|
|
|
|
|
t.Errorf("total_tokens = %d, want 15", res.Usage.TotalTokens)
|
|
|
|
|
}
|
|
|
|
|
if res.Usage.PromptTokensDetails == nil || res.Usage.PromptTokensDetails.CachedTokens != 3 {
|
|
|
|
|
t.Errorf("cached_tokens unexpected: %#v", res.Usage.PromptTokensDetails)
|
|
|
|
|
}
|
|
|
|
|
if res.Usage.CompletionTokensDetails == nil || res.Usage.CompletionTokensDetails.ReasoningTokens != 2 {
|
|
|
|
|
t.Errorf("reasoning_tokens unexpected: %#v", res.Usage.CompletionTokensDetails)
|
|
|
|
|
}
|
|
|
|
|
if res.Timings == nil {
|
|
|
|
|
t.Fatal("timings is nil")
|
|
|
|
|
}
|
|
|
|
|
if res.Timings.PromptMS != 50.5 {
|
|
|
|
|
t.Errorf("prompt_ms = %f, want 50.5", res.Timings.PromptMS)
|
|
|
|
|
}
|
|
|
|
|
if res.Timings.PredictedMS != 100.0 {
|
|
|
|
|
t.Errorf("predicted_ms = %f, want 100.0", res.Timings.PredictedMS)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCompletionsStreamEmitsUsageEvent(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/chat/completions" {
|
|
|
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
// Stream some content
|
|
|
|
|
fmt.Fprintf(w, "data: %s\n\n",
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`)
|
|
|
|
|
fmt.Fprintf(w, "data: %s\n\n",
|
|
|
|
|
`{"choices":[{"index":0,"delta":{"content":"hello"}}]}`)
|
|
|
|
|
// Final chunk with usage
|
|
|
|
|
fmt.Fprintf(w, "data: %s\n\n",
|
|
|
|
|
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
|
|
|
|
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewClient: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
events := make(chan StreamEvent)
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
errCh <- client.CompletionsStream(
|
2026-07-07 15:14:07 +00:00
|
|
|
context.Background(), CompletionsParams{
|
|
|
|
|
Model: "test-model",
|
|
|
|
|
SystemMessage: "",
|
|
|
|
|
MaxIterations: 0,
|
|
|
|
|
Messages: []Message{{
|
|
|
|
|
Role: RoleUser,
|
|
|
|
|
ContentParts: []ContentPart{{
|
|
|
|
|
Type: ContentTypeText,
|
|
|
|
|
Text: "hi",
|
|
|
|
|
}},
|
2026-06-27 00:13:16 +00:00
|
|
|
}},
|
2026-07-07 15:14:07 +00:00
|
|
|
}, events,
|
2026-06-27 00:13:16 +00:00
|
|
|
)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
var got []StreamEvent
|
|
|
|
|
for evt := range events {
|
|
|
|
|
got = append(got, evt)
|
|
|
|
|
}
|
|
|
|
|
if err := <-errCh; err != nil {
|
|
|
|
|
t.Fatalf("CompletionsStream: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Expect: delta, done events (usage attached to done event)
|
|
|
|
|
wantTypes := []string{
|
|
|
|
|
streamEventDelta,
|
|
|
|
|
streamEventDone,
|
|
|
|
|
}
|
|
|
|
|
if len(got) != len(wantTypes) {
|
|
|
|
|
t.Fatalf("got %d events, want %d: %#v", len(got), len(wantTypes), got)
|
|
|
|
|
}
|
|
|
|
|
for i, want := range wantTypes {
|
|
|
|
|
if got[i].Type != want {
|
|
|
|
|
t.Fatalf("event %d type = %q, want %q", i, got[i].Type, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Verify usage is on the done event
|
|
|
|
|
doneEvt := got[1]
|
|
|
|
|
if doneEvt.Usage == nil {
|
|
|
|
|
t.Fatal("done event has nil Usage")
|
|
|
|
|
}
|
|
|
|
|
if doneEvt.Usage.PromptTokens != 10 {
|
|
|
|
|
t.Errorf("prompt_tokens = %d, want 10", doneEvt.Usage.PromptTokens)
|
|
|
|
|
}
|
|
|
|
|
if doneEvt.Usage.TotalTokens != 15 {
|
|
|
|
|
t.Errorf("total_tokens = %d, want 15", doneEvt.Usage.TotalTokens)
|
|
|
|
|
}
|
|
|
|
|
}
|