Add debug stats
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user