Skip to content

Commit be529e2

Browse files
committed
v0.5.2 — Turn statistics (latency + token counts)
Each iteration header now shows real API-reported token usage and wall-clock latency inline: [1,247 in · 342 out · 4.1s] - parseResponse: extract usage.prompt_tokens + .completion_tokens - CallResult: new InputTokens, OutputTokens fields - Iteration: accepts latency + token stats, renders compact suffix - Stats suppressed when API doesn't report usage (zero values -> hidden) - No breaking changes to Run() or public API
1 parent 49a104f commit be529e2

5 files changed

Lines changed: 186 additions & 14 deletions

File tree

internal/llm/client.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ type ThinkingConfig struct {
8787

8888
// CallResult is the parsed response from /chat/completions.
8989
type CallResult struct {
90-
Content string // assistant text
91-
ToolCalls []ToolCall // tool calls requested by the model
90+
Content string // assistant text
91+
ToolCalls []ToolCall // tool calls requested by the model
92+
InputTokens int // prompt_tokens from API usage (0 = not reported)
93+
OutputTokens int // completion_tokens from API usage (0 = not reported)
9294
}
9395

9496
// toolChoiceNone forces the model to not call tools.
@@ -155,6 +157,10 @@ func parseResponse(data []byte) (*CallResult, error) {
155157
} `json:"tool_calls"`
156158
} `json:"message"`
157159
} `json:"choices"`
160+
Usage *struct {
161+
PromptTokens int `json:"prompt_tokens"`
162+
CompletionTokens int `json:"completion_tokens"`
163+
} `json:"usage"`
158164
}
159165
if err := json.Unmarshal(data, &raw); err != nil {
160166
return nil, fmt.Errorf("llm: parse response: %w (body: %s)", err, string(data))
@@ -167,6 +173,10 @@ func parseResponse(data []byte) (*CallResult, error) {
167173
result := &CallResult{
168174
Content: msg.Content,
169175
}
176+
if raw.Usage != nil {
177+
result.InputTokens = raw.Usage.PromptTokens
178+
result.OutputTokens = raw.Usage.CompletionTokens
179+
}
170180
for _, tc := range msg.ToolCalls {
171181
result.ToolCalls = append(result.ToolCalls, ToolCall{
172182
ID: tc.ID,

internal/llm/client_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,3 +521,90 @@ func TestClient_Call_InvalidJSONResponse(t *testing.T) {
521521
t.Fatal("expected error for invalid JSON response")
522522
}
523523
}
524+
525+
func TestParseResponse_WithUsage(t *testing.T) {
526+
raw := `{
527+
"choices": [{"message": {"content": "Hello"}}],
528+
"usage": {"prompt_tokens": 452, "completion_tokens": 128, "total_tokens": 580}
529+
}`
530+
531+
result, err := parseResponse([]byte(raw))
532+
if err != nil {
533+
t.Fatal(err)
534+
}
535+
if result.InputTokens != 452 {
536+
t.Errorf("InputTokens = %d, want 452", result.InputTokens)
537+
}
538+
if result.OutputTokens != 128 {
539+
t.Errorf("OutputTokens = %d, want 128", result.OutputTokens)
540+
}
541+
if result.Content != "Hello" {
542+
t.Errorf("Content = %q, want %q", result.Content, "Hello")
543+
}
544+
}
545+
546+
func TestParseResponse_WithoutUsage(t *testing.T) {
547+
raw := `{
548+
"choices": [{"message": {"content": "No usage"}}]
549+
}`
550+
551+
result, err := parseResponse([]byte(raw))
552+
if err != nil {
553+
t.Fatal(err)
554+
}
555+
if result.InputTokens != 0 {
556+
t.Errorf("InputTokens = %d, want 0", result.InputTokens)
557+
}
558+
if result.OutputTokens != 0 {
559+
t.Errorf("OutputTokens = %d, want 0", result.OutputTokens)
560+
}
561+
}
562+
563+
func TestParseResponse_UsageWithToolCalls(t *testing.T) {
564+
raw := `{
565+
"choices": [{
566+
"message": {
567+
"content": "Let me check.",
568+
"tool_calls": [{
569+
"id": "call_1",
570+
"function": {"name": "shell", "arguments": "{\"cmd\":\"ls\"}"}
571+
}]
572+
}
573+
}],
574+
"usage": {"prompt_tokens": 1000, "completion_tokens": 50, "total_tokens": 1050}
575+
}`
576+
577+
result, err := parseResponse([]byte(raw))
578+
if err != nil {
579+
t.Fatal(err)
580+
}
581+
if result.InputTokens != 1000 {
582+
t.Errorf("InputTokens = %d, want 1000", result.InputTokens)
583+
}
584+
if result.OutputTokens != 50 {
585+
t.Errorf("OutputTokens = %d, want 50", result.OutputTokens)
586+
}
587+
if len(result.ToolCalls) != 1 {
588+
t.Errorf("expected 1 tool call, got %d", len(result.ToolCalls))
589+
}
590+
}
591+
592+
func TestClient_Call_ReturnsUsage(t *testing.T) {
593+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
594+
w.Header().Set("Content-Type", "application/json")
595+
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60}}`))
596+
}))
597+
defer server.Close()
598+
599+
c := New(server.URL, "sk-test", "test-model", "", 0)
600+
result, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
601+
if err != nil {
602+
t.Fatal(err)
603+
}
604+
if result.InputTokens != 50 {
605+
t.Errorf("InputTokens = %d, want 50", result.InputTokens)
606+
}
607+
if result.OutputTokens != 10 {
608+
t.Errorf("OutputTokens = %d, want 10", result.OutputTokens)
609+
}
610+
}

internal/loop/loop.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"fmt"
77
"strings"
8+
"time"
89

910
"github.com/BackendStack21/kode/internal/llm"
1011
"github.com/BackendStack21/kode/internal/render"
@@ -165,18 +166,25 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
165166

166167
// Render iteration header (1-indexed for humans)
167168
if e.renderer != nil {
168-
e.renderer.Iteration(i+1, e.maxIter)
169+
e.renderer.Iteration(i+1, e.maxIter, 0, 0, 0)
169170
}
170171

171172
// Trim context to stay within model's context window
172173
messages = e.trimContext(messages, tools)
173174

174-
// THINK
175+
// THINK (timed)
176+
start := time.Now()
175177
result, err := e.client.Call(ctx, messages, tools)
178+
latency := time.Since(start)
176179
if err != nil {
177180
return "", fmt.Errorf("iteration %d: %w", i, err)
178181
}
179182

183+
// Render turn statistics (re-draw iteration header with stats)
184+
if e.renderer != nil {
185+
e.renderer.Iteration(i+1, e.maxIter, latency, result.InputTokens, result.OutputTokens)
186+
}
187+
180188
// No tool calls = final answer
181189
if len(result.ToolCalls) == 0 {
182190
if e.renderer != nil {

internal/render/render.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"io"
2222
"os"
2323
"strings"
24+
"time"
2425
)
2526

2627
// ── Events ────────────────────────────────────────────────────────────
@@ -129,8 +130,10 @@ func (r *Renderer) Start(task string) {
129130
fmt.Fprintln(r.w)
130131
}
131132

132-
// Iteration prints the cycle header.
133-
func (r *Renderer) Iteration(n, maxN int) {
133+
// Iteration prints the cycle header with optional turn statistics.
134+
// When latency > 0 or tokens are reported, a compact stats suffix
135+
// appears on the same line: [1,247 in · 342 out · 4.1s]
136+
func (r *Renderer) Iteration(n, maxN int, latency time.Duration, inTokens, outTokens int) {
134137
if r.disable() {
135138
return
136139
}
@@ -140,9 +143,14 @@ func (r *Renderer) Iteration(n, maxN int) {
140143
} else {
141144
prefix = fmt.Sprintf("Iter %d/%d", n, maxN)
142145
}
146+
// Build stats suffix only when data is available
147+
stats := ""
148+
if inTokens > 0 || outTokens > 0 || latency > 0 {
149+
stats = fmt.Sprintf(" [%d in · %d out · %.1fs]", inTokens, outTokens, latency.Seconds())
150+
}
143151
// Double-line rule framing
144152
rule := strings.Repeat("═", 3)
145-
line := fmt.Sprintf("%s %s %s", rule, prefix, rule)
153+
line := fmt.Sprintf("%s %s %s%s", rule, prefix, rule, stats)
146154
fmt.Fprintln(r.w)
147155
fmt.Fprintln(r.w, r.style(blue, line))
148156
}

internal/render/render_test.go

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"strings"
77
"testing"
8+
"time"
89
)
910

1011
func TestRenderer_Start(t *testing.T) {
@@ -43,7 +44,7 @@ func TestRenderer_Iteration(t *testing.T) {
4344
var buf bytes.Buffer
4445
r := New(&buf, true).WithModel("deepseek-chat")
4546

46-
r.Iteration(3, 90)
47+
r.Iteration(3, 90, 0, 0, 0)
4748

4849
out := buf.String()
4950
if !strings.Contains(out, "Iter 3/90") {
@@ -58,7 +59,7 @@ func TestRenderer_Iteration_NoModel(t *testing.T) {
5859
var buf bytes.Buffer
5960
r := New(&buf, true)
6061

61-
r.Iteration(1, 10)
62+
r.Iteration(1, 10, 0, 0, 0)
6263

6364
out := buf.String()
6465
if !strings.Contains(out, "Iter 1/10") {
@@ -260,7 +261,7 @@ func TestRenderer_NoColor(t *testing.T) {
260261
var buf bytes.Buffer
261262
r := New(&buf, false)
262263

263-
r.Iteration(1, 5)
264+
r.Iteration(1, 5, 0, 0, 0)
264265

265266
out := buf.String()
266267
if strings.Contains(out, "\033[") {
@@ -276,7 +277,7 @@ func TestRenderer_NilWriter(t *testing.T) {
276277

277278
// None of these should panic
278279
r.Start("task")
279-
r.Iteration(1, 5)
280+
r.Iteration(1, 5, 0, 0, 0)
280281
r.Thinking("hello")
281282
r.ToolCall("shell", "{}")
282283
r.ToolResult("output")
@@ -289,7 +290,7 @@ func TestRenderer_NilRenderer(t *testing.T) {
289290

290291
// None of these should panic on nil receiver
291292
r.Start("task")
292-
r.Iteration(1, 5)
293+
r.Iteration(1, 5, 0, 0, 0)
293294
r.Thinking("hello")
294295
r.ToolCall("shell", "{}")
295296
r.ToolResult("output")
@@ -303,11 +304,11 @@ func TestRenderer_FullCycle(t *testing.T) {
303304

304305
// Simulate one full session
305306
r.Start("what files are here?")
306-
r.Iteration(1, 90)
307+
r.Iteration(1, 90, 0, 0, 0)
307308
r.Thinking("I need to read the file to understand its contents.")
308309
r.ToolCall("shell", `{"command": "cat main.go"}`)
309310
r.ToolResult("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}")
310-
r.Iteration(2, 90)
311+
r.Iteration(2, 90, 0, 0, 0)
311312
r.FinalAnswer("The file contains a simple Go program that prints 'hello'.")
312313

313314
out := buf.String()
@@ -354,3 +355,61 @@ func TestEvent_String(t *testing.T) {
354355
}
355356
}
356357
}
358+
359+
func TestRenderer_Iteration_WithStats(t *testing.T) {
360+
var buf bytes.Buffer
361+
r := New(&buf, true).WithModel("deepseek-v4-pro")
362+
363+
r.Iteration(3, 90, 5*time.Second, 1247, 342)
364+
365+
out := buf.String()
366+
if !strings.Contains(out, "Iter 3/90") {
367+
t.Errorf("missing iteration info: %q", out)
368+
}
369+
if !strings.Contains(out, "deepseek-v4-pro") {
370+
t.Errorf("missing model name: %q", out)
371+
}
372+
if !strings.Contains(out, "1247 in") {
373+
t.Errorf("missing input tokens: %q", out)
374+
}
375+
if !strings.Contains(out, "342 out") {
376+
t.Errorf("missing output tokens: %q", out)
377+
}
378+
if !strings.Contains(out, "5.0s") {
379+
t.Errorf("missing latency: %q", out)
380+
}
381+
}
382+
383+
func TestRenderer_Iteration_StatsSuppressedWhenZero(t *testing.T) {
384+
var buf bytes.Buffer
385+
r := New(&buf, true).WithModel("test")
386+
387+
r.Iteration(1, 10, 0, 0, 0)
388+
389+
out := buf.String()
390+
// Check that the stats pattern (with "in ·") doesn't appear.
391+
// Don't check for "[" which is part of ANSI escape codes.
392+
if strings.Contains(out, "0 in") {
393+
t.Errorf("stats should not appear when all values are zero: %q", out)
394+
}
395+
}
396+
397+
func TestRenderer_Iteration_WithoutModel(t *testing.T) {
398+
var buf bytes.Buffer
399+
r := New(&buf, true)
400+
401+
r.Iteration(1, 10, time.Second, 100, 50)
402+
403+
out := buf.String()
404+
if !strings.Contains(out, "Iter 1/10") {
405+
t.Errorf("missing iteration info: %q", out)
406+
}
407+
if !strings.Contains(out, "100 in") {
408+
t.Errorf("missing input tokens: %q", out)
409+
}
410+
}
411+
412+
func TestRenderer_Iteration_NilSafe(t *testing.T) {
413+
var r *Renderer
414+
r.Iteration(1, 10, time.Second, 100, 50) // should not panic
415+
}

0 commit comments

Comments
 (0)