Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion cmd/sin-code/internal/llm/provider.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
// Purpose: generic OpenAI-compatible LLM client. Single-shot chat completion
// request with bearer auth, JSON marshaling, and typed response decoding.
// Persists parsed tool-usage via a pluggable Recorder (issue #168).
package llm

import (
Expand Down Expand Up @@ -57,6 +58,12 @@ type Client struct {
// in tests that hand-build the struct (HTTP roundtrips still work,
// just without breaker protection).
breaker *circuitbreaker.Breaker

// Recorder persists parsed ChatResponse.Usage on every successful
// call. Defaults to NopRecorder (drained) when wired via NewClient.
// Issue #168: previously the Usage block was parsed but dropped at
// provider.go:42-46; it now flows through a pluggable recorder.
Recorder Recorder
}

func NewClient(baseURL, apiKey string) *Client {
Expand All @@ -74,7 +81,8 @@ func NewClient(baseURL, apiKey string) *Client {
Timeout: 120 * time.Second,
Transport: circuitbreaker.RoundTripper(http.DefaultTransport, br),
},
breaker: br,
breaker: br,
Recorder: NopRecorder{},
}
}

Expand Down Expand Up @@ -126,6 +134,16 @@ func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, erro
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
// Issue #168: persist parsed Usage. Best-effort — a recorder failure
// never blocks the caller. SessionID comes from the context (set by
// agentloop.Loop, the tui/chat runner, or the spec author).
if c.Recorder != nil && (out.Usage.PromptTokens != 0 || out.Usage.CompletionTokens != 0 || out.Usage.TotalTokens != 0) {
if recErr := c.Recorder.RecordUsage(ctx, SessionIDFromContext(ctx),
req.Model, SourceAdHoc,
out.Usage.PromptTokens, out.Usage.CompletionTokens, out.Usage.TotalTokens); recErr != nil {
fmt.Fprintf(os.Stderr, "warn: usage recorder: %v\n", recErr)
}
}
return &out, nil
}

Expand Down
146 changes: 146 additions & 0 deletions cmd/sin-code/internal/llm/recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MIT
// Purpose: LLM-Usage Recorder interface for the observability system
// (issue #168). Decouples the LLM client from the storage layer
// so any persistence backend (SQLite, file, no-op) can be plugged
// in without changing cmd/sin-code/internal/llm/provider.go.
//
// The Recorder is called once per LLM request, with the
// ChatResponse.Usage already parsed. The default NopRecorder
// drops every event (used when no store is wired, so the LLM
// client always has a valid Recorder to call).
//
// Docs: docs/TOKEN-TRACKING.md
package llm

import (
"context"
"crypto/rand"
"encoding/hex"
"sync"
)

// randomSessionID returns a hex-encoded 64-bit random session id.
// Generated once per process; cached for the lifetime of the
// binary so every LLM call within the same process shares the
// default id.
func randomSessionID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand should never fail on a sane OS, but if it
// does we fall back to a fixed sentinel. The Recorder
// contract is best-effort; a missing session id is
// logged but never breaks the LLM call.
return "session-fallback"
}
return hex.EncodeToString(b[:])
}

// Source is a coarse-grained taxonomy for usage events. The exact
// value set is deliberately small — anything finer-grained belongs
// in Tags on a future Event type, not in the Source enum.
type Source string

const (
// SourceAdHoc marks one-off LLM calls from CLI commands
// (sin-code tokens, sin-code spec author, etc.). Distinct
// from SourceChat so dashboards can surface "spike in ad-hoc
// calls" without confusing it with the long-running chat loop.
SourceAdHoc Source = "ad-hoc"

// SourceChat marks interactive chat sessions (sin-code chat,
// TUI, WebUI). Long-running, may have many turns.
SourceChat Source = "chat"

// SourceAgent marks autonomous runs (sin-code daemon, eval-set
// runner, PRP engine). Burst-y, often offline.
SourceAgent Source = "agent"
)

// Recorder persists a single usage event. Implementations must be
// safe for concurrent use across many LLM calls (the LLM client
// itself runs goroutines for stream + retry).
//
// The default NopRecorder is the no-op implementation. Wire a real
// recorder via ClientOptions.Recorder when you want persistence.
type Recorder interface {
// RecordUsage persists a single usage event. Returns an error
// if the underlying store fails; the LLM client logs but
// does not propagate the error (a failed usage write must not
// break the user's request).
RecordUsage(ctx context.Context, sessionID, model string, source Source, promptTokens, completionTokens, totalTokens int) error
}

// NopRecorder is the default Recorder. It implements the interface
// but does nothing — used when no recorder is wired. Cheap (a
// single function call with no allocation, no I/O), so it's safe
// to use in the hot path of the LLM client without measurable cost.
type NopRecorder struct{}

// RecordUsage for NopRecorder is a no-op. Always returns nil.
func (NopRecorder) RecordUsage(_ context.Context, _, _ string, _ Source, _, _, _ int) error {
return nil
}

// SessionIDFromContext extracts a stable per-session identifier
// from the context. Returns "" if no session ID has been set.
//
// The convention is: callers (chat command, daemon, etc.) attach
// the session ID via WithSessionID(ctx, id) on every LLM call.
// The Recorder uses this to group usage by session in dashboards.
func SessionIDFromContext(ctx context.Context) string {
if v := ctx.Value(sessionIDKey{}); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

// WithSessionID returns a child context that carries the given
// session ID. Use it for every LLM call that belongs to a
// conversational session (chat, agent, daemon tick, etc.).
//
// Stateless commands that don't belong to a session (sin-code
// tokens, sin-code spec author) should pass a synthetic session
// ID derived from the command + invocation timestamp, so the
// Recorder can still group them.
func WithSessionID(parent context.Context, id string) context.Context {
return context.WithValue(parent, sessionIDKey{}, id)
}

// sessionIDKey is the unexported context key for session IDs. Using
// an unexported empty struct ensures no other package can collide
// with our key (the standard Go idiom).
type sessionIDKey struct{}

// muSessionID protects a process-global fallback session ID for
// callers that don't set one explicitly. This is purely defensive:
// every well-behaved caller should set its own via WithSessionID.
var (
muSessionID sync.Mutex
fallbackSession string
)

// DefaultSessionID returns a stable, process-unique session
// identifier. Use it as the default in the LLM client when no
// session ID is present in the context.
//
// The id is a hex-encoded random 64-bit value, generated once
// per process. This means usage from a CLI invocation is grouped
// into a single session (the "process"), while a long-running
// chat loop can group by turn via explicit WithSessionID calls.
var (
defaultSessionOnce sync.Once
defaultSession string
)

func init() {
defaultSessionOnce.Do(func() {
defaultSession = randomSessionID()
})
}

// DefaultSessionID is the session ID used when no explicit
// WithSessionID was applied. Exposed so the LLM client can
// pass it to the Recorder.
func DefaultSessionID() string { return defaultSession }
136 changes: 136 additions & 0 deletions cmd/sin-code/internal/llm/recorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for the LLM-Usage Recorder interface.
// Docs: docs/TOKEN-TRACKING.md
package llm

import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
)

func TestNopRecorder_RecordUsage(t *testing.T) {
r := NopRecorder{}
err := r.RecordUsage(context.Background(), "sess-1", "claude-haiku-4-5", SourceAdHoc, 100, 50, 150)
if err != nil {
t.Fatalf("NopRecorder should never error, got: %v", err)
}
}

func TestSessionIDFromContext_Absent(t *testing.T) {
if got := SessionIDFromContext(context.Background()); got != "" {
t.Fatalf("expected empty session id, got %q", got)
}
}

func TestWithSessionID_Present(t *testing.T) {
ctx := WithSessionID(context.Background(), "sess-abc")
if got := SessionIDFromContext(ctx); got != "sess-abc" {
t.Fatalf("expected sess-abc, got %q", got)
}
}

func TestWithSessionID_Overwrite(t *testing.T) {
ctx := WithSessionID(context.Background(), "outer")
ctx = WithSessionID(ctx, "inner")
if got := SessionIDFromContext(ctx); got != "inner" {
t.Fatalf("expected inner to overwrite outer, got %q", got)
}
}

func TestWithSessionID_WrongType(t *testing.T) {
// Defensive: if something else stored an int at the same key
// (e.g. another package using the same context-key strategy),
// SessionIDFromContext should return "" rather than panic.
ctx := context.WithValue(context.Background(), sessionIDKey{}, 42)
if got := SessionIDFromContext(ctx); got != "" {
t.Fatalf("expected empty on type-mismatch, got %q", got)
}
}

func TestDefaultSessionID_Stable(t *testing.T) {
a := DefaultSessionID()
b := DefaultSessionID()
if a == "" {
t.Fatal("DefaultSessionID returned empty")
}
if a != b {
t.Fatalf("DefaultSessionID is not stable across calls: %q vs %q", a, b)
}
}

func TestDefaultSessionID_Format(t *testing.T) {
// 8 bytes hex = 16 chars
id := DefaultSessionID()
if len(id) != 16 {
t.Fatalf("expected 16-char hex id, got %d chars: %q", len(id), id)
}
for _, r := range id {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
t.Fatalf("non-hex char in id: %q", id)
}
}
}

// fakeRecorder is a minimal in-memory recorder for concurrency
// tests. Production code uses internal/usage.Store; this fake is
// for the interface contract only.
type fakeRecorder struct {
mu sync.Mutex
events []fakeEvent
failOn int32 // atomic; if >= 0, fail the Nth call
calls int32
}

type fakeEvent struct {
SessionID, Model, Source string
Prompt, Completion, Total int
}

func (f *fakeRecorder) RecordUsage(_ context.Context, sessionID, model string, source Source, p, c, t int) error {
n := atomic.AddInt32(&f.calls, 1)
if atomic.LoadInt32(&f.failOn) == n {
return errors.New("synthetic failure")
}
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, fakeEvent{
SessionID: sessionID, Model: model, Source: string(source),
Prompt: p, Completion: c, Total: t,
})
return nil
}

func TestRecorder_ConcurrentSafe(t *testing.T) {
// The Recorder contract requires safe concurrent use. Hammer it
// from many goroutines and assert the event count matches.
f := &fakeRecorder{}
const n = 100
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = f.RecordUsage(context.Background(), "s", "m", SourceAdHoc, i, i, i*2)
}(i)
}
wg.Wait()
f.mu.Lock()
defer f.mu.Unlock()
if got := len(f.events); got != n {
t.Fatalf("expected %d events, got %d", n, got)
}
}

func TestRecorder_ErrorIsReported(t *testing.T) {
f := &fakeRecorder{failOn: 3}
for i := 1; i <= 5; i++ {
err := f.RecordUsage(context.Background(), "s", "m", SourceChat, i, i, i*2)
wantErr := i == 3
if (err != nil) != wantErr {
t.Fatalf("call %d: want err=%v, got %v", i, wantErr, err)
}
}
}
Loading
Loading