Add debug stats

This commit is contained in:
dwrz
2026-06-27 00:13:16 +00:00
parent bcdbd9092a
commit f2edd39160
8 changed files with 553 additions and 28 deletions

View File

@@ -457,6 +457,10 @@ type Response struct {
Provider string `json:"used_provider,omitempty"`
// Model is the LLM model used for the response.
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.
@@ -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,
)
if err != nil {
@@ -548,7 +552,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
http.Error(w, "LLM error", http.StatusInternalServerError)
return
}
if len(msgs) == 0 {
if len(res.Messages) == 0 {
http.Error(
w,
"no response from LLM",
@@ -556,7 +560,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
)
return
}
final := msgs[len(msgs)-1]
final := res.Messages[len(res.Messages)-1]
log.DebugContext(
ctx,
"LLM response",
@@ -567,9 +571,11 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Response{
Messages: msgs,
Messages: res.Messages,
Provider: provider,
Model: model,
Usage: res.Usage,
Timings: res.Timings,
}); err != nil {
log.ErrorContext(
ctx,
@@ -593,6 +599,10 @@ type StreamMessage struct {
Provider string `json:"provider,omitempty"`
// Model is the LLM model used for the response.
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.
@@ -719,6 +729,12 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
if evt.Message.Role != "" {
msg.Message = &evt.Message
}
if evt.Usage != nil {
msg.Usage = evt.Usage
}
if evt.Timings != nil {
msg.Timings = evt.Timings
}
send(evt.Type, msg)
}
if err := <-errs; err != nil {

View File

@@ -1,9 +1,13 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.chimeric.al/chimerical/odidere/internal/llm"
)
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)
}
}

View File

@@ -1519,10 +1519,11 @@ class Odidere {
// Stash assistant messages with tool_calls until their results arrive.
let pendingTools = null;
let streamingMessage = null;
const streamingMeta = () => {
const streamingMeta = (usage, timings) => {
const meta = {};
if (voice) meta.voice = voice;
if (usage) meta.usage = usage;
if (timings) meta.timings = timings;
return meta;
};
@@ -1593,7 +1594,7 @@ class Odidere {
role: 'assistant',
content: [{ type: ContentTypeText, text: '' }],
};
const meta = streamingMeta();
const meta = streamingMeta(event.usage, event.timings);
const appended = this.#appendHistory([message], meta);
const $el = this.#renderStreamingAssistantMessage(
appended[0],
@@ -1626,7 +1627,7 @@ class Odidere {
if (eventType === StreamEventDone) {
if (message.role !== 'assistant') continue;
const meta = streamingMeta();
const meta = streamingMeta(event.usage, event.timings);
let finalMessage = message;
let $streaming = null;
if (streamingMessage) {
@@ -2351,6 +2352,49 @@ class Odidere {
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
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', '');
// Bind action buttons.
@@ -2450,6 +2494,49 @@ class Odidere {
// Populate debug panel.
const $dl = $msg.querySelector('.message__debug-list');
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', '');
// Bind action buttons.