Add debug stats
This commit is contained in:
@@ -156,7 +156,7 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := j.llm.Completions(
|
res, err := j.llm.Completions(
|
||||||
ctx,
|
ctx,
|
||||||
j.model,
|
j.model,
|
||||||
j.SystemMessage(),
|
j.SystemMessage(),
|
||||||
@@ -178,7 +178,7 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
Error: fmt.Errorf("job %q: %w", j.Name(), err),
|
Error: fmt.Errorf("job %q: %w", j.Name(), err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(res.Messages) == 0 {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx,
|
ctx,
|
||||||
"job returned no messages",
|
"job returned no messages",
|
||||||
@@ -197,11 +197,11 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
ctx,
|
ctx,
|
||||||
"job completed",
|
"job completed",
|
||||||
slog.Duration("duration", duration),
|
slog.Duration("duration", duration),
|
||||||
slog.Int("messages", len(msgs)),
|
slog.Int("messages", len(res.Messages)),
|
||||||
)
|
)
|
||||||
return Result{
|
return Result{
|
||||||
Name: j.Name(),
|
Name: j.Name(),
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
Messages: msgs,
|
Messages: res.Messages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,27 @@ import (
|
|||||||
// maximum number of iterations.
|
// maximum number of iterations.
|
||||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||||
|
|
||||||
|
// CompletionsResult bundles the messages generated during a completions
|
||||||
|
// request along with usage and timings from the final API response.
|
||||||
|
type CompletionsResult struct {
|
||||||
|
Messages []Message
|
||||||
|
Usage *Usage
|
||||||
|
Timings *Timings
|
||||||
|
}
|
||||||
|
|
||||||
// Completions sends messages to the LLM using the specified model.
|
// Completions sends messages to the LLM using the specified model.
|
||||||
// If systemMessage is non-empty, it overrides the configured system message.
|
// If systemMessage is non-empty, it overrides the configured system message.
|
||||||
// maxIterations caps the number of agent loop iterations.
|
// maxIterations caps the number of agent loop iterations.
|
||||||
// A value of 0 means unlimited (no cap).
|
// A value of 0 means unlimited (no cap).
|
||||||
// Returns all messages generated during the completions request, including
|
// Returns all messages generated during the completions request, including
|
||||||
// tool calls and tool results.
|
// tool calls and tool results, along with usage and timings from the final response.
|
||||||
func (c *Client) Completions(
|
func (c *Client) Completions(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
model string,
|
model string,
|
||||||
systemMessage string,
|
systemMessage string,
|
||||||
maxIterations int,
|
maxIterations int,
|
||||||
messages []Message,
|
messages []Message,
|
||||||
) ([]Message, error) {
|
) (*CompletionsResult, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -57,16 +65,29 @@ func (c *Client) Completions(
|
|||||||
// generated during this call (the suffix of the conversation history).
|
// generated during this call (the suffix of the conversation history).
|
||||||
start := len(messages)
|
start := len(messages)
|
||||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||||
|
var finalUsage *Usage
|
||||||
|
var finalTimings *Timings
|
||||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||||
cr, err := c.completions(ctx, model, messages)
|
cr, err := c.completions(ctx, model, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("completions: %w", err)
|
return nil, fmt.Errorf("completions: %w", err)
|
||||||
}
|
}
|
||||||
|
// Capture usage and timings from this iteration.
|
||||||
|
if cr.Usage != nil {
|
||||||
|
finalUsage = cr.Usage
|
||||||
|
}
|
||||||
|
if cr.Timings != nil {
|
||||||
|
finalTimings = cr.Timings
|
||||||
|
}
|
||||||
// Use first choice for tool call loop.
|
// Use first choice for tool call loop.
|
||||||
message := &cr.Choices[0].Message
|
message := &cr.Choices[0].Message
|
||||||
messages = append(messages, *message)
|
messages = append(messages, *message)
|
||||||
if len(message.ToolCalls) == 0 {
|
if len(message.ToolCalls) == 0 {
|
||||||
return messages[start:], nil
|
return &CompletionsResult{
|
||||||
|
Messages: messages[start:],
|
||||||
|
Usage: finalUsage,
|
||||||
|
Timings: finalTimings,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
toolResults, err := c.callTools(ctx, *message)
|
toolResults, err := c.callTools(ctx, *message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +96,11 @@ func (c *Client) Completions(
|
|||||||
messages = append(messages, toolResults...)
|
messages = append(messages, toolResults...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages[start:], ErrMaxIterations
|
return &CompletionsResult{
|
||||||
|
Messages: messages[start:],
|
||||||
|
Usage: finalUsage,
|
||||||
|
Timings: finalTimings,
|
||||||
|
}, ErrMaxIterations
|
||||||
}
|
}
|
||||||
|
|
||||||
// completions sends a single non-streaming request and returns the
|
// completions sends a single non-streaming request and returns the
|
||||||
@@ -185,6 +210,10 @@ type StreamEvent struct {
|
|||||||
ReasoningDelta string
|
ReasoningDelta string
|
||||||
// Message is the complete chat completion message.
|
// Message is the complete chat completion message.
|
||||||
Message Message
|
Message Message
|
||||||
|
// Usage is token usage info from the final chunk, sent on the done event.
|
||||||
|
Usage *Usage
|
||||||
|
// Timings is llama.cpp timing info from the final chunk, sent on the done event.
|
||||||
|
Timings *Timings
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -273,13 +302,15 @@ type streamAccum struct {
|
|||||||
|
|
||||||
// completionsStream sends a single streaming request, accumulates the
|
// completionsStream sends a single streaming request, accumulates the
|
||||||
// response, and returns all assistant messages from the response choices.
|
// response, and returns all assistant messages from the response choices.
|
||||||
// Sends each message to the events channel.
|
// Sends each message to the events channel. Usage and timings are included
|
||||||
|
// on the done event for the last message.
|
||||||
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, events chan<- StreamEvent) ([]Message, error) {
|
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, events chan<- StreamEvent) ([]Message, error) {
|
||||||
body, err := json.Marshal(ChatRequest{
|
body, err := json.Marshal(ChatRequest{
|
||||||
Model: model,
|
Model: model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
Tools: c.tools,
|
Tools: c.tools,
|
||||||
|
StreamOptions: &StreamOptions{IncludeUsage: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -361,6 +392,8 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
|
|||||||
|
|
||||||
// Accumulate the streamed response per choice index.
|
// Accumulate the streamed response per choice index.
|
||||||
accums := make(map[int]*streamAccum)
|
accums := make(map[int]*streamAccum)
|
||||||
|
var usage *Usage
|
||||||
|
var timings *Timings
|
||||||
|
|
||||||
scanner := bufio.NewScanner(res.Body)
|
scanner := bufio.NewScanner(res.Body)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -437,6 +470,13 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Capture usage from the final chunk (has null choices).
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
usage = chunk.Usage
|
||||||
|
}
|
||||||
|
if chunk.Timings != nil {
|
||||||
|
timings = chunk.Timings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.Body.Close()
|
res.Body.Close()
|
||||||
|
|
||||||
@@ -454,7 +494,7 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
|
|||||||
slices.Sort(indices)
|
slices.Sort(indices)
|
||||||
|
|
||||||
msgs := make([]Message, 0, len(accums))
|
msgs := make([]Message, 0, len(accums))
|
||||||
for _, idx := range indices {
|
for i, idx := range indices {
|
||||||
acc := accums[idx]
|
acc := accums[idx]
|
||||||
|
|
||||||
// Content and refusal are mutually exclusive in the API.
|
// Content and refusal are mutually exclusive in the API.
|
||||||
@@ -478,9 +518,19 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
|
|||||||
ReasoningContent: acc.reasoning.String(),
|
ReasoningContent: acc.reasoning.String(),
|
||||||
ToolCalls: acc.toolCalls,
|
ToolCalls: acc.toolCalls,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach usage and timings to the done event of the last message.
|
||||||
|
var evUsage *Usage
|
||||||
|
var evTimings *Timings
|
||||||
|
if i == len(indices)-1 {
|
||||||
|
evUsage = usage
|
||||||
|
evTimings = timings
|
||||||
|
}
|
||||||
events <- StreamEvent{
|
events <- StreamEvent{
|
||||||
Type: streamEventDone,
|
Type: streamEventDone,
|
||||||
Message: message,
|
Message: message,
|
||||||
|
Usage: evUsage,
|
||||||
|
Timings: evTimings,
|
||||||
}
|
}
|
||||||
msgs = append(msgs, message)
|
msgs = append(msgs, message)
|
||||||
}
|
}
|
||||||
|
|||||||
255
internal/llm/client_completions_test.go
Normal file
255
internal/llm/client_completions_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,11 +59,41 @@ type ChatRequest struct {
|
|||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage represents token usage from the API response.
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
PromptTokensDetails *TokenDetails `json:"prompt_tokens_details,omitempty"`
|
||||||
|
CompletionTokensDetails *TokenDetails `json:"completion_tokens_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenDetails breaks down token usage by category.
|
||||||
|
type TokenDetails struct {
|
||||||
|
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
|
||||||
|
CachedTokens int `json:"cached_tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timings is a llama.cpp-specific extension to the chat completion
|
||||||
|
// response. It is not part of the OpenAI API spec.
|
||||||
|
type Timings struct {
|
||||||
|
PromptN int `json:"prompt_n"`
|
||||||
|
PromptMS float64 `json:"prompt_ms"`
|
||||||
|
PromptPerTokenMS float64 `json:"prompt_per_token_ms"`
|
||||||
|
PromptPerSecond float64 `json:"prompt_per_second"`
|
||||||
|
PredictedN int `json:"predicted_n"`
|
||||||
|
PredictedMS float64 `json:"predicted_ms"`
|
||||||
|
PredictedPerTokenMS float64 `json:"predicted_per_token_ms"`
|
||||||
|
PredictedPerSecond float64 `json:"predicted_per_second"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatResponse is the non-streaming response from the API.
|
// ChatResponse is the non-streaming response from the API.
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Choices []Choice `json:"choices"`
|
Choices []Choice `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
Timings *Timings `json:"timings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choice is a single choice in a ChatResponse.
|
// Choice is a single choice in a ChatResponse.
|
||||||
@@ -246,6 +276,8 @@ type StreamChunk struct {
|
|||||||
Delta Delta `json:"delta"`
|
Delta Delta `json:"delta"`
|
||||||
FinishReason string `json:"finish_reason,omitempty"`
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
Timings *Timings `json:"timings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamOptions controls streaming behavior.
|
// StreamOptions controls streaming behavior.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestIntegration_Completions(t *testing.T) {
|
|||||||
t.Fatalf("NewClient: %v", err)
|
t.Fatalf("NewClient: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := client.Completions(
|
res, err := client.Completions(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
testModel(t),
|
testModel(t),
|
||||||
"",
|
"",
|
||||||
@@ -92,10 +92,10 @@ func TestIntegration_Completions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Completions: %v", err)
|
t.Fatalf("Completions: %v", err)
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(res.Messages) == 0 {
|
||||||
t.Fatal("expected at least one message")
|
t.Fatal("expected at least one message")
|
||||||
}
|
}
|
||||||
text := msgs[len(msgs)-1].Text()
|
text := res.Messages[len(res.Messages)-1].Text()
|
||||||
if text == "" {
|
if text == "" {
|
||||||
t.Error("final message has no text")
|
t.Error("final message has no text")
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func TestIntegration_ToolCalling(t *testing.T) {
|
|||||||
t.Fatalf("NewClient: %v", err)
|
t.Fatalf("NewClient: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := client.Completions(
|
res, err := client.Completions(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
testModel(t),
|
testModel(t),
|
||||||
"",
|
"",
|
||||||
@@ -197,11 +197,11 @@ func TestIntegration_ToolCalling(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Completions: %v", err)
|
t.Fatalf("Completions: %v", err)
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(res.Messages) == 0 {
|
||||||
t.Fatal("expected at least one message")
|
t.Fatal("expected at least one message")
|
||||||
}
|
}
|
||||||
// The final message should be a non-tool-call assistant message.
|
// The final message should be a non-tool-call assistant message.
|
||||||
final := msgs[len(msgs)-1]
|
final := res.Messages[len(res.Messages)-1]
|
||||||
if len(final.ToolCalls) > 0 {
|
if len(final.ToolCalls) > 0 {
|
||||||
t.Error("final message still has tool calls")
|
t.Error("final message still has tool calls")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,10 @@ type Response struct {
|
|||||||
Provider string `json:"used_provider,omitempty"`
|
Provider string `json:"used_provider,omitempty"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"used_model,omitempty"`
|
Model string `json:"used_model,omitempty"`
|
||||||
|
// Usage is token usage from the API response.
|
||||||
|
Usage *llm.Usage `json:"usage,omitempty"`
|
||||||
|
// Timings is llama.cpp timing info from the API response.
|
||||||
|
Timings *llm.Timings `json:"timings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// chat processes text chat requests.
|
// chat processes text chat requests.
|
||||||
@@ -536,7 +540,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := llmc.Completions(
|
res, err := llmc.Completions(
|
||||||
ctx, model, req.SystemMessage, 0, req.Messages,
|
ctx, model, req.SystemMessage, 0, req.Messages,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -548,7 +552,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "LLM error", http.StatusInternalServerError)
|
http.Error(w, "LLM error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(res.Messages) == 0 {
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
"no response from LLM",
|
"no response from LLM",
|
||||||
@@ -556,7 +560,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
final := msgs[len(msgs)-1]
|
final := res.Messages[len(res.Messages)-1]
|
||||||
log.DebugContext(
|
log.DebugContext(
|
||||||
ctx,
|
ctx,
|
||||||
"LLM response",
|
"LLM response",
|
||||||
@@ -567,9 +571,11 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(Response{
|
if err := json.NewEncoder(w).Encode(Response{
|
||||||
Messages: msgs,
|
Messages: res.Messages,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Usage: res.Usage,
|
||||||
|
Timings: res.Timings,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -593,6 +599,10 @@ type StreamMessage struct {
|
|||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
|
// Usage is token usage from the API response.
|
||||||
|
Usage *llm.Usage `json:"usage,omitempty"`
|
||||||
|
// Timings is llama.cpp timing info from the API response.
|
||||||
|
Timings *llm.Timings `json:"timings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatStream processes chat requests with streaming SSE output.
|
// chatStream processes chat requests with streaming SSE output.
|
||||||
@@ -719,6 +729,12 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if evt.Message.Role != "" {
|
if evt.Message.Role != "" {
|
||||||
msg.Message = &evt.Message
|
msg.Message = &evt.Message
|
||||||
}
|
}
|
||||||
|
if evt.Usage != nil {
|
||||||
|
msg.Usage = evt.Usage
|
||||||
|
}
|
||||||
|
if evt.Timings != nil {
|
||||||
|
msg.Timings = evt.Timings
|
||||||
|
}
|
||||||
send(evt.Type, msg)
|
send(evt.Type, msg)
|
||||||
}
|
}
|
||||||
if err := <-errs; err != nil {
|
if err := <-errs; err != nil {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStatusHandler(t *testing.T) {
|
func TestStatusHandler(t *testing.T) {
|
||||||
@@ -22,3 +26,84 @@ func TestStatusHandler(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseIncludesStatsInJSON(t *testing.T) {
|
||||||
|
resp := Response{
|
||||||
|
Messages: []llm.Message{
|
||||||
|
{Role: llm.RoleAssistant, ContentParts: []llm.ContentPart{{
|
||||||
|
Type: llm.ContentTypeText,
|
||||||
|
Text: "hello",
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
Provider: "test-provider",
|
||||||
|
Model: "test-model",
|
||||||
|
Usage: &llm.Usage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 5,
|
||||||
|
TotalTokens: 15,
|
||||||
|
PromptTokensDetails: &llm.TokenDetails{
|
||||||
|
CachedTokens: 3,
|
||||||
|
},
|
||||||
|
CompletionTokensDetails: &llm.TokenDetails{
|
||||||
|
ReasoningTokens: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Timings: &llm.Timings{
|
||||||
|
PromptMS: 50.5,
|
||||||
|
PromptPerSecond: 198.0,
|
||||||
|
PredictedMS: 100.0,
|
||||||
|
PredictedPerSecond: 50.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON encoding includes stats fields
|
||||||
|
var buf strings.Builder
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
if err := enc.Encode(resp); err != nil {
|
||||||
|
t.Fatalf("encode Response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := buf.String()
|
||||||
|
if !strings.Contains(jsonStr, `"prompt_tokens":10`) {
|
||||||
|
t.Errorf("JSON missing prompt_tokens: %s", jsonStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonStr, `"completion_tokens":5`) {
|
||||||
|
t.Errorf("JSON missing completion_tokens: %s", jsonStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonStr, `"total_tokens":15`) {
|
||||||
|
t.Errorf("JSON missing total_tokens: %s", jsonStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonStr, `"prompt_ms":50.5`) {
|
||||||
|
t.Errorf("JSON missing prompt_ms: %s", jsonStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonStr, `"predicted_ms":100`) {
|
||||||
|
t.Errorf("JSON missing predicted_ms: %s", jsonStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMessageIncludesUsageInJSON(t *testing.T) {
|
||||||
|
msg := StreamMessage{
|
||||||
|
Delta: "hello",
|
||||||
|
Provider: "test-provider",
|
||||||
|
Model: "test-model",
|
||||||
|
Usage: &llm.Usage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 5,
|
||||||
|
TotalTokens: 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
if err := enc.Encode(msg); err != nil {
|
||||||
|
t.Fatalf("encode StreamMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := buf.String()
|
||||||
|
if !strings.Contains(jsonStr, `"prompt_tokens":10`) {
|
||||||
|
t.Errorf("JSON missing prompt_tokens: %s", jsonStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonStr, `"total_tokens":15`) {
|
||||||
|
t.Errorf("JSON missing total_tokens: %s", jsonStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1519,10 +1519,11 @@ class Odidere {
|
|||||||
// Stash assistant messages with tool_calls until their results arrive.
|
// Stash assistant messages with tool_calls until their results arrive.
|
||||||
let pendingTools = null;
|
let pendingTools = null;
|
||||||
let streamingMessage = null;
|
let streamingMessage = null;
|
||||||
|
const streamingMeta = (usage, timings) => {
|
||||||
const streamingMeta = () => {
|
|
||||||
const meta = {};
|
const meta = {};
|
||||||
if (voice) meta.voice = voice;
|
if (voice) meta.voice = voice;
|
||||||
|
if (usage) meta.usage = usage;
|
||||||
|
if (timings) meta.timings = timings;
|
||||||
return meta;
|
return meta;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1593,7 +1594,7 @@ class Odidere {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [{ type: ContentTypeText, text: '' }],
|
content: [{ type: ContentTypeText, text: '' }],
|
||||||
};
|
};
|
||||||
const meta = streamingMeta();
|
const meta = streamingMeta(event.usage, event.timings);
|
||||||
const appended = this.#appendHistory([message], meta);
|
const appended = this.#appendHistory([message], meta);
|
||||||
const $el = this.#renderStreamingAssistantMessage(
|
const $el = this.#renderStreamingAssistantMessage(
|
||||||
appended[0],
|
appended[0],
|
||||||
@@ -1626,7 +1627,7 @@ class Odidere {
|
|||||||
if (eventType === StreamEventDone) {
|
if (eventType === StreamEventDone) {
|
||||||
if (message.role !== 'assistant') continue;
|
if (message.role !== 'assistant') continue;
|
||||||
|
|
||||||
const meta = streamingMeta();
|
const meta = streamingMeta(event.usage, event.timings);
|
||||||
let finalMessage = message;
|
let finalMessage = message;
|
||||||
let $streaming = null;
|
let $streaming = null;
|
||||||
if (streamingMessage) {
|
if (streamingMessage) {
|
||||||
@@ -2351,6 +2352,49 @@ class Odidere {
|
|||||||
// Populate debug panel.
|
// Populate debug panel.
|
||||||
const $dl = $msg.querySelector('.message__debug-list');
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if (meta?.usage) {
|
||||||
|
const u = meta.usage;
|
||||||
|
this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Completion tokens',
|
||||||
|
String(u.completion_tokens),
|
||||||
|
);
|
||||||
|
this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
|
||||||
|
if (u.completion_tokens_details?.reasoning_tokens > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Reasoning tokens',
|
||||||
|
String(u.completion_tokens_details.reasoning_tokens),
|
||||||
|
);
|
||||||
|
if (u.prompt_tokens_details?.cached_tokens > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Cached tokens',
|
||||||
|
String(u.prompt_tokens_details.cached_tokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (meta?.timings) {
|
||||||
|
const t = meta.timings;
|
||||||
|
if (t.prompt_ms > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Prompt eval',
|
||||||
|
t.prompt_ms.toFixed(1) +
|
||||||
|
' ms (' +
|
||||||
|
t.prompt_per_second.toFixed(0) +
|
||||||
|
' tok/s)',
|
||||||
|
);
|
||||||
|
if (t.predicted_ms > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Predicted eval',
|
||||||
|
t.predicted_ms.toFixed(1) +
|
||||||
|
' ms (' +
|
||||||
|
t.predicted_per_second.toFixed(0) +
|
||||||
|
' tok/s)',
|
||||||
|
);
|
||||||
|
}
|
||||||
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
// Bind action buttons.
|
// Bind action buttons.
|
||||||
@@ -2450,6 +2494,49 @@ class Odidere {
|
|||||||
// Populate debug panel.
|
// Populate debug panel.
|
||||||
const $dl = $msg.querySelector('.message__debug-list');
|
const $dl = $msg.querySelector('.message__debug-list');
|
||||||
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
|
||||||
|
if (meta?.usage) {
|
||||||
|
const u = meta.usage;
|
||||||
|
this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Completion tokens',
|
||||||
|
String(u.completion_tokens),
|
||||||
|
);
|
||||||
|
this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
|
||||||
|
if (u.completion_tokens_details?.reasoning_tokens > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Reasoning tokens',
|
||||||
|
String(u.completion_tokens_details.reasoning_tokens),
|
||||||
|
);
|
||||||
|
if (u.prompt_tokens_details?.cached_tokens > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Cached tokens',
|
||||||
|
String(u.prompt_tokens_details.cached_tokens),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (meta?.timings) {
|
||||||
|
const t = meta.timings;
|
||||||
|
if (t.prompt_ms > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Prompt eval',
|
||||||
|
t.prompt_ms.toFixed(1) +
|
||||||
|
' ms (' +
|
||||||
|
t.prompt_per_second.toFixed(0) +
|
||||||
|
' tok/s)',
|
||||||
|
);
|
||||||
|
if (t.predicted_ms > 0)
|
||||||
|
this.#appendDebugRow(
|
||||||
|
$dl,
|
||||||
|
'Predicted eval',
|
||||||
|
t.predicted_ms.toFixed(1) +
|
||||||
|
' ms (' +
|
||||||
|
t.predicted_per_second.toFixed(0) +
|
||||||
|
' tok/s)',
|
||||||
|
);
|
||||||
|
}
|
||||||
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
|
||||||
|
|
||||||
// Bind action buttons.
|
// Bind action buttons.
|
||||||
|
|||||||
Reference in New Issue
Block a user