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( context.Background(), "test-model", "", 0, []Message{{ Role: RoleUser, ContentParts: []ContentPart{{ Type: ContentTypeText, Text: "hello", }}, }}, events, ) }() 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( context.Background(), "test-model", "", 0, []Message{{ Role: RoleUser, ContentParts: []ContentPart{{ Type: ContentTypeText, Text: "hi", }}, }}, ) 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( context.Background(), "test-model", "", 0, []Message{{ Role: RoleUser, ContentParts: []ContentPart{{ Type: ContentTypeText, Text: "hi", }}, }}, events, ) }() 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) } }