From cd1dcb804e18f0653710c3c7fd241ceeb337b90f Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 09:27:16 +0100
Subject: [PATCH 1/8] stash: add instrumentation
---
internal/gateway/router.go | 93 +++++++++++++++-
internal/gateway/router_test.go | 185 ++++++++++++++++++++++++++++++++
2 files changed, 277 insertions(+), 1 deletion(-)
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index 6da3b4a..14a8eb9 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -53,6 +53,27 @@ type RouterConfig struct {
DiagnosticDump bool
}
+// TurnType classifies what kind of agent-loop turn a request represents,
+// based on the role of the last message in the messages array.
+type TurnType string
+
+const (
+ // TurnToolResult means the last message role is "tool" — the model just
+ // received a tool result and must decide the next step. These are the
+ // candidate turns for downgrade to a cheaper model.
+ TurnToolResult TurnType = "tool_result"
+ // TurnUserPrompt means the last message role is "user" or "developer" —
+ // a fresh human prompt. The content classifier (classifyRequest) handles
+ // these.
+ TurnUserPrompt TurnType = "user_prompt"
+ // TurnAgentContinue means the last message role is "assistant" —
+ // continuation or prefill scenario. Conservative: keep model.
+ TurnAgentContinue TurnType = "agent_continue"
+ // TurnUnknown means empty messages, system-only, or unparseable
+ // structure. Conservative: keep model.
+ TurnUnknown TurnType = "unknown"
+)
+
// ClassifierResult contains the classification outcome for logging/shadowing.
type ClassifierResult struct {
IsSubagent bool `json:"is_subagent"`
@@ -65,6 +86,8 @@ type ClassifierResult struct {
OverrideApplied bool `json:"override_applied"`
OverrideReason string `json:"override_reason,omitempty"`
ClassificationAge string `json:"classification_age,omitempty"`
+ TurnType TurnType `json:"turn_type"`
+ ToolResultSize string `json:"tool_result_size,omitempty"`
}
// modelOverrideMap maps expensive models to cheaper equivalents for classification-based downgrades.
@@ -123,15 +146,26 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
// Instead, content-based classification handles all traffic uniformly.
result.IsSubagent = false
- // Step 2: content-based classification.
+ // Step 2: turn type detection for per-turn routing instrumentation.
+ result.TurnType = detectTurnType(body)
+ result.ToolResultSize = toolResultSize(body)
+
+ // Step 3: content-based classification.
result.RequestClass = classifyRequest(body)
+ // Determine whether this turn would be downgraded (based on request class only).
+ // This is logged but NOT acted on — it's pure instrumentation to measure opportunity.
+ wouldDowngrade := shouldDowngrade(result.RequestClass)
+
// Always log classification at DEBUG so operators can tune thresholds.
lastMsg := lastUserMessage(body)
lastMsgStripped := stripCursorNoise(lastMsg)
slog.Debug("router: classify",
"request_id", requestID,
"request_class", result.RequestClass,
+ "turn_type", result.TurnType,
+ "tool_result_size", result.ToolResultSize,
+ "would_downgrade", wouldDowngrade,
"sys_prompt_len", result.SysPromptLen,
"msg_count", result.MsgCount,
"has_tools", result.HasTools,
@@ -497,6 +531,63 @@ func messageRoles(body map[string]any) string {
return strings.Join(roles, ", ")
}
+// detectTurnType classifies the request by the role of its last message.
+// Tool-result rounds (last role == "tool") are the primary targets for
+// per-turn model downgrade within a multi-step agent flow.
+func detectTurnType(body map[string]any) TurnType {
+ msgs, ok := body["messages"].([]any)
+ if !ok || len(msgs) == 0 {
+ return TurnUnknown
+ }
+ last, ok := msgs[len(msgs)-1].(map[string]any)
+ if !ok {
+ return TurnUnknown
+ }
+ role, _ := last["role"].(string)
+ switch role {
+ case "tool":
+ return TurnToolResult
+ case "user", "developer":
+ return TurnUserPrompt
+ case "assistant":
+ return TurnAgentContinue
+ default:
+ return TurnUnknown
+ }
+}
+
+// toolResultSize buckets the content size of the last tool-result message.
+// Zero means the messages array is empty or the last message is not a tool.
+// Buckets help identify where token spend concentrates:
+//
+// <=512 chars → "small" (quick shell command outputs)
+// <=4096 chars → "medium" (diff outputs, file reads)
+// >4096 chars → "large" (full logs, stack traces)
+func toolResultSize(body map[string]any) string {
+ msgs, ok := body["messages"].([]any)
+ if !ok || len(msgs) == 0 {
+ return ""
+ }
+ last, ok := msgs[len(msgs)-1].(map[string]any)
+ if !ok {
+ return ""
+ }
+ role, _ := last["role"].(string)
+ if role != "tool" {
+ return ""
+ }
+ content, _ := last["content"].(string)
+ n := len(content)
+ switch {
+ case n <= 512:
+ return "small"
+ case n <= 4096:
+ return "medium"
+ default:
+ return "large"
+ }
+}
+
// systemPromptLength returns the length (in chars) of the first system or
// developer message content, or 0 if none found.
func systemPromptLength(body map[string]any) int {
diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go
index 5390d20..5505983 100644
--- a/internal/gateway/router_test.go
+++ b/internal/gateway/router_test.go
@@ -1,6 +1,7 @@
package gateway
import (
+ "strings"
"testing"
)
@@ -914,3 +915,187 @@ func TestContentClassification_WithCursorXML(t *testing.T) {
})
}
}
+
+func TestDetectTurnType(t *testing.T) {
+ tests := []struct {
+ name string
+ body map[string]any
+ want TurnType
+ }{
+ {
+ name: "tool result turn",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "You are a coding agent."},
+ map[string]any{"role": "user", "content": "Run the tests"},
+ map[string]any{"role": "assistant", "content": "", "tool_calls": []any{}},
+ map[string]any{"role": "tool", "content": "ok\tinternal/gateway\t0.123s"},
+ },
+ },
+ want: TurnToolResult,
+ },
+ {
+ name: "user prompt turn",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "You are a coding agent."},
+ map[string]any{"role": "user", "content": "What is a goroutine?"},
+ },
+ },
+ want: TurnUserPrompt,
+ },
+ {
+ name: "developer prompt turn",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "developer", "content": "You are a coding agent."},
+ map[string]any{"role": "user", "content": "Hello"},
+ },
+ },
+ want: TurnUserPrompt,
+ },
+ {
+ name: "assistant continuation turn",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "You are a coding agent."},
+ map[string]any{"role": "assistant", "content": "Let me think about the next step..."},
+ },
+ },
+ want: TurnAgentContinue,
+ },
+ {
+ name: "empty messages → unknown",
+ body: map[string]any{"messages": []any{}},
+ want: TurnUnknown,
+ },
+ {
+ name: "no messages key → unknown",
+ body: map[string]any{},
+ want: TurnUnknown,
+ },
+ {
+ name: "nil body → unknown",
+ body: nil,
+ want: TurnUnknown,
+ },
+ {
+ name: "unknown role → unknown",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Hello"},
+ map[string]any{"role": "bogus", "content": "?"},
+ },
+ },
+ want: TurnUnknown,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := detectTurnType(tt.body)
+ if got != tt.want {
+ t.Errorf("detectTurnType() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestToolResultSize(t *testing.T) {
+ tests := []struct {
+ name string
+ body map[string]any
+ want string
+ }{
+ {
+ name: "small tool result (≤512)",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Agent"},
+ map[string]any{"role": "tool", "content": "ok"},
+ },
+ },
+ want: "small",
+ },
+ {
+ name: "medium tool result (≤4096)",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Agent"},
+ map[string]any{
+ "role": "tool",
+ "content": strings.Repeat("x", 1024),
+ },
+ },
+ },
+ want: "medium",
+ },
+ {
+ name: "large tool result (>4096)",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Agent"},
+ map[string]any{
+ "role": "tool",
+ "content": strings.Repeat("x", 5000),
+ },
+ },
+ },
+ want: "large",
+ },
+ {
+ name: "boundary: exactly 512 → small",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Agent"},
+ map[string]any{
+ "role": "tool",
+ "content": strings.Repeat("x", 512),
+ },
+ },
+ },
+ want: "small",
+ },
+ {
+ name: "boundary: exactly 4096 → medium",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "Agent"},
+ map[string]any{
+ "role": "tool",
+ "content": strings.Repeat("x", 4096),
+ },
+ },
+ },
+ want: "medium",
+ },
+ {
+ name: "not a tool turn → empty",
+ body: map[string]any{
+ "messages": []any{
+ map[string]any{"role": "user", "content": "Hello"},
+ },
+ },
+ want: "",
+ },
+ {
+ name: "empty messages → empty",
+ body: map[string]any{"messages": []any{}},
+ want: "",
+ },
+ {
+ name: "nil body → empty",
+ body: nil,
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := toolResultSize(tt.body)
+ if got != tt.want {
+ t.Errorf("toolResultSize() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
From ee3a6252bed534370523ee5fd0c5c262b07fc1e9 Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 09:55:40 +0100
Subject: [PATCH 2/8] middleware refactor
---
internal/gateway/auth.go | 25 ++-
internal/gateway/auth_test.go | 342 +++++++++++++++++++++++++-------
internal/gateway/health.go | 3 -
internal/gateway/proxy.go | 38 ++--
internal/gateway/server.go | 12 +-
internal/gateway/server_test.go | 73 +++++++
internal/gateway/usage/usage.db | Bin 0 -> 24576 bytes
7 files changed, 380 insertions(+), 113 deletions(-)
create mode 100644 internal/gateway/usage/usage.db
diff --git a/internal/gateway/auth.go b/internal/gateway/auth.go
index e188fc6..e83f0d0 100644
--- a/internal/gateway/auth.go
+++ b/internal/gateway/auth.go
@@ -1,6 +1,7 @@
package gateway
import (
+ "crypto/subtle"
"encoding/json"
"net/http"
"strings"
@@ -24,12 +25,13 @@ func ExtractAPIKey(r *http.Request) string {
return ""
}
-// GatewayKeyMatches reports whether provided equals expected (constant-ish compare).
+// GatewayKeyMatches reports whether provided equals expected using a
+// constant-time comparison.
func GatewayKeyMatches(provided, expected string) bool {
if provided == "" || expected == "" {
return false
}
- return provided == expected
+ return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
// WriteUnauthorized writes an OpenAI-shaped 401 JSON body.
@@ -46,6 +48,7 @@ func WriteUnauthorized(w http.ResponseWriter) {
})
}
+// writeJSONError writes a JSON error response.
func writeJSONError(w http.ResponseWriter, status int, message, typ string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -57,10 +60,18 @@ func writeJSONError(w http.ResponseWriter, status int, message, typ string) {
})
}
-func requireGatewayKey(w http.ResponseWriter, r *http.Request, expected string) bool {
- if !GatewayKeyMatches(ExtractAPIKey(r), expected) {
- WriteUnauthorized(w)
- return false
+// AuthMiddleware returns an http.Handler that wraps next with gateway key
+// authentication. Requests that fail auth receive an OpenAI-shaped 401.
+// If expected is empty, auth is bypassed.
+func AuthMiddleware(next http.Handler, expected string) http.Handler {
+ if expected == "" {
+ return next
}
- return true
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !GatewayKeyMatches(ExtractAPIKey(r), expected) {
+ WriteUnauthorized(w)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
}
diff --git a/internal/gateway/auth_test.go b/internal/gateway/auth_test.go
index 1f5d8d9..e4f346d 100644
--- a/internal/gateway/auth_test.go
+++ b/internal/gateway/auth_test.go
@@ -1,82 +1,87 @@
package gateway
import (
+ "encoding/json"
+ "net/http"
"net/http/httptest"
"strings"
"testing"
-)
-
-func TestExtractAPIKey_Bearer(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("Authorization", "Bearer sk-test-key-12345")
- if got := ExtractAPIKey(req); got != "sk-test-key-12345" {
- t.Fatalf("got %q want %q", got, "sk-test-key-12345")
- }
-}
-
-func TestExtractAPIKey_BearerLowercase(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("Authorization", "bearer sk-test-key-12345")
- if got := ExtractAPIKey(req); got != "sk-test-key-12345" {
- t.Fatalf("got %q want %q", got, "sk-test-key-12345")
- }
-}
-
-func TestExtractAPIKey_BearerWithSpaces(t *testing.T) {
- // Authorization header is checked as-is (no leading-space trim on the header value itself).
- // The key value IS trimmed after extraction.
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("Authorization", "Bearer sk-test-key-12345 ")
- if got := ExtractAPIKey(req); got != "sk-test-key-12345" {
- t.Fatalf("got %q want %q", got, "sk-test-key-12345")
- }
-}
-
-func TestExtractAPIKey_XApiKey(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("x-api-key", "sk-x-api-key")
- if got := ExtractAPIKey(req); got != "sk-x-api-key" {
- t.Fatalf("got %q want %q", got, "sk-x-api-key")
- }
-}
-
-func TestExtractAPIKey_ApiKeyHeader(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("api-key", "sk-api-key")
- if got := ExtractAPIKey(req); got != "sk-api-key" {
- t.Fatalf("got %q want %q", got, "sk-api-key")
- }
-}
-
-func TestExtractAPIKey_OpenAIKeyHeader(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("x-openai-api-key", "sk-openai-key")
- if got := ExtractAPIKey(req); got != "sk-openai-key" {
- t.Fatalf("got %q want %q", got, "sk-openai-key")
- }
-}
-func TestExtractAPIKey_NoHeader(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- if got := ExtractAPIKey(req); got != "" {
- t.Fatalf("got %q want empty", got)
- }
-}
+ "github.com/commoddity/discursive/internal/config"
+)
-func TestExtractAPIKey_BearerTakesPriority(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("Authorization", "Bearer sk-bearer-key")
- req.Header.Set("x-api-key", "sk-x-key")
- if got := ExtractAPIKey(req); got != "sk-bearer-key" {
- t.Fatalf("got %q want %q", got, "sk-bearer-key")
+func TestExtractAPIKey(t *testing.T) {
+ tests := []struct {
+ name string
+ headers map[string]string
+ want string
+ }{
+ {
+ name: "Bearer",
+ headers: map[string]string{"Authorization": "Bearer sk-test-key-12345"},
+ want: "sk-test-key-12345",
+ },
+ {
+ name: "bearer lowercase",
+ headers: map[string]string{"Authorization": "bearer sk-test-key-12345"},
+ want: "sk-test-key-12345",
+ },
+ {
+ name: "Bearer with extra whitespace",
+ headers: map[string]string{"Authorization": "Bearer sk-test-key-12345 "},
+ want: "sk-test-key-12345",
+ },
+ {
+ name: "x-api-key header",
+ headers: map[string]string{"x-api-key": "sk-x-api-key"},
+ want: "sk-x-api-key",
+ },
+ {
+ name: "api-key header",
+ headers: map[string]string{"api-key": "sk-api-key"},
+ want: "sk-api-key",
+ },
+ {
+ name: "x-openai-api-key header",
+ headers: map[string]string{"x-openai-api-key": "sk-openai-key"},
+ want: "sk-openai-key",
+ },
+ {
+ name: "no header",
+ headers: map[string]string{},
+ want: "",
+ },
+ {
+ name: "Bearer takes priority over x-api-key",
+ headers: map[string]string{"Authorization": "Bearer sk-bearer-key", "x-api-key": "sk-x-key"},
+ want: "sk-bearer-key",
+ },
+ {
+ name: "Authorization without Bearer prefix",
+ headers: map[string]string{"Authorization": "Basic dXNlcjpwYXNz"},
+ want: "",
+ },
+ {
+ name: "empty Authorization falls through to x-api-key",
+ headers: map[string]string{"Authorization": "", "x-api-key": "sk-fallback"},
+ want: "sk-fallback",
+ },
+ {
+ name: "Bearer with empty value returns empty",
+ headers: map[string]string{"Authorization": "Bearer "},
+ want: "",
+ },
}
-}
-
-func TestExtractAPIKey_NoBearerPrefix(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
- if got := ExtractAPIKey(req); got != "" {
- t.Fatalf("got %q want empty", got)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ for k, v := range tt.headers {
+ req.Header.Set(k, v)
+ }
+ if got := ExtractAPIKey(req); got != tt.want {
+ t.Fatalf("ExtractAPIKey() = %q, want %q", got, tt.want)
+ }
+ })
}
}
@@ -89,6 +94,7 @@ func TestGatewayKeyMatches(t *testing.T) {
}{
{"exact match", "sk-abc123", "sk-abc123", true},
{"mismatch", "sk-abc123", "sk-xyz789", false},
+ {"mismatch same length", "sk-aaaaaaaaaa", "sk-bbbbbbbbbb", false},
{"empty provided", "", "sk-abc123", false},
{"empty expected", "sk-abc123", "", false},
{"both empty", "", "", false},
@@ -106,17 +112,199 @@ func TestWriteUnauthorized(t *testing.T) {
w := httptest.NewRecorder()
WriteUnauthorized(w)
- if w.Code != 401 {
- t.Fatalf("status = %d, want 401", w.Code)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
- body := w.Body.String()
- if !strings.Contains(body, "invalid_api_key") {
- t.Fatalf("missing invalid_api_key in body: %s", body)
+
+ var resp struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Param any `json:"param"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("invalid JSON body: %v", err)
}
- if !strings.Contains(body, "invalid_request_error") {
- t.Fatalf("missing invalid_request_error in body: %s", body)
+ if resp.Error.Code != "invalid_api_key" {
+ t.Fatalf("error.code = %q, want invalid_api_key", resp.Error.Code)
}
+ if resp.Error.Type != "invalid_request_error" {
+ t.Fatalf("error.type = %q, want invalid_request_error", resp.Error.Type)
+ }
+ if resp.Error.Param != nil {
+ t.Fatalf("error.param = %v, want nil", resp.Error.Param)
+ }
+ if !strings.Contains(resp.Error.Message, "invalid_api_key") {
+ t.Fatalf("error.message missing invalid_api_key: %q", resp.Error.Message)
+ }
+}
+
+func TestWriteJSONError(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ message string
+ typ string
+ }{
+ {"not_found", http.StatusNotFound, "resource not found", "not_found_error"},
+ {"bad_request", http.StatusBadRequest, "invalid input", "invalid_request_error"},
+ {"internal", http.StatusInternalServerError, "something went wrong", "internal_error"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ writeJSONError(w, tt.status, tt.message, tt.typ)
+
+ if w.Code != tt.status {
+ t.Fatalf("status = %d, want %d", w.Code, tt.status)
+ }
+ if ct := w.Header().Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("Content-Type = %q, want application/json", ct)
+ }
+
+ var resp struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("invalid JSON body: %v", err)
+ }
+ if resp.Error.Message != tt.message {
+ t.Fatalf("error.message = %q, want %q", resp.Error.Message, tt.message)
+ }
+ if resp.Error.Type != tt.typ {
+ t.Fatalf("error.type = %q, want %q", resp.Error.Type, tt.typ)
+ }
+ })
+ }
+}
+
+func TestAuthMiddleware(t *testing.T) {
+ okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ t.Run("valid key passes through", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "sk-correct")
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Authorization", "Bearer sk-correct")
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+ })
+
+ t.Run("invalid key returns 401", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "sk-correct")
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Authorization", "Bearer sk-wrong")
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
+ }
+ })
+
+ t.Run("missing key returns 401", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "sk-correct")
+ req := httptest.NewRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
+ }
+ })
+
+ t.Run("empty expected bypasses auth", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "")
+ req := httptest.NewRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d (bypass when key is empty)", w.Code, http.StatusOK)
+ }
+ })
+
+ t.Run("wrong key format returns 401", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "sk-correct")
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
+ }
+ })
+
+ t.Run("key via x-api-key header passes through", func(t *testing.T) {
+ mw := AuthMiddleware(okHandler, "sk-correct")
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("x-api-key", "sk-correct")
+ w := httptest.NewRecorder()
+ mw.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+ })
+}
+
+func TestAuthMiddleware_IntegrationWithServer(t *testing.T) {
+ srv, err := NewServer(ServerConfig{
+ ListenAddr: "127.0.0.1:0",
+ GatewayKey: "sk-test-gateway",
+ Settings: &config.AppSettings{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ t.Cleanup(func() { _ = srv.Shutdown(t.Context()) })
+
+ t.Run("GET /health bypasses auth", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/health")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("health status = %d, want %d", res.StatusCode, http.StatusOK)
+ }
+ })
+
+ t.Run("GET /v1/models without auth returns 401", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/v1/models")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusUnauthorized)
+ }
+ })
+
+ t.Run("POST /v1/chat/completions without auth returns 401", func(t *testing.T) {
+ res, err := http.Post(ts.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusUnauthorized)
+ }
+ })
}
diff --git a/internal/gateway/health.go b/internal/gateway/health.go
index db3ee6b..8804e56 100644
--- a/internal/gateway/health.go
+++ b/internal/gateway/health.go
@@ -15,9 +15,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
- if !requireGatewayKey(w, r, s.cfg.GatewayKey) {
- return
- }
listed := ListAdvertisedModels()
data := make([]map[string]any, 0, len(listed))
for _, m := range listed {
diff --git a/internal/gateway/proxy.go b/internal/gateway/proxy.go
index bbfbdad..dd03003 100644
--- a/internal/gateway/proxy.go
+++ b/internal/gateway/proxy.go
@@ -13,9 +13,6 @@ import (
)
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
- if !requireGatewayKey(w, r, s.cfg.GatewayKey) {
- return
- }
started := time.Now()
requestID := newRequestID()
@@ -194,31 +191,28 @@ func (s *Server) writeBufferedResponse(w http.ResponseWriter, status int, respBo
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+
+ // Always log the full upstream error body at ERROR level.
+ slog.Error("upstream_error",
+ "request_id", requestID,
+ "status", status,
+ "provider", string(provider),
+ "model", model,
+ "effort", effort,
+ "body", string(respBody),
+ )
+
+ // Surface provider errors verbatim. If the upstream body is valid JSON,
+ // pass it through untouched (preserves the provider's error shape). If
+ // it's not JSON, wrap the raw body in an OpenAI-shaped envelope so the
+ // actual provider message reaches Cursor instead of a generic placeholder.
var errObj map[string]any
if json.Unmarshal(respBody, &errObj) == nil {
_ = json.NewEncoder(w).Encode(errObj)
-
- // Always log the full upstream error body at ERROR level.
- slog.Error("upstream_error",
- "request_id", requestID,
- "status", status,
- "provider", string(provider),
- "model", model,
- "effort", effort,
- "body", string(respBody),
- )
} else {
- slog.Error("upstream_error",
- "request_id", requestID,
- "status", status,
- "provider", string(provider),
- "model", model,
- "effort", effort,
- "body", string(respBody),
- )
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
- "message": fmt.Sprintf("upstream status %d", status),
+ "message": fmt.Sprintf("upstream status %d: %s", status, string(respBody)),
"type": "upstream_error",
},
})
diff --git a/internal/gateway/server.go b/internal/gateway/server.go
index c6b7309..9d3f53b 100644
--- a/internal/gateway/server.go
+++ b/internal/gateway/server.go
@@ -106,11 +106,15 @@ func NewServer(cfg ServerConfig) (*Server, error) {
}
func (s *Server) routes() {
+ auth := func(h http.HandlerFunc) http.Handler {
+ return AuthMiddleware(h, s.cfg.GatewayKey)
+ }
+
s.mux.HandleFunc("GET /health", s.handleHealth)
- s.mux.HandleFunc("GET /v1/models", s.handleModels)
- s.mux.HandleFunc("POST /v1/models", s.handleModels)
- s.mux.HandleFunc("POST /v1/chat/completions", s.handleChatCompletions)
- s.mux.HandleFunc("POST /v1/responses", s.handleChatCompletions)
+ s.mux.Handle("GET /v1/models", auth(s.handleModels))
+ s.mux.Handle("POST /v1/models", auth(s.handleModels))
+ s.mux.Handle("POST /v1/chat/completions", auth(s.handleChatCompletions))
+ s.mux.Handle("POST /v1/responses", auth(s.handleChatCompletions))
}
// Handler returns the HTTP handler for httptest tests.
diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go
index ccb2199..c0fa98d 100644
--- a/internal/gateway/server_test.go
+++ b/internal/gateway/server_test.go
@@ -456,3 +456,76 @@ func TestToolCallIDRetry(t *testing.T) {
t.Fatalf("expected retry, calls=%d", calls.Load())
}
}
+
+func TestUpstreamError_JSONErrorPassedVerbatim(t *testing.T) {
+ upstreamErr := `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit_reached"}}`
+ env := setupEnv(t, "sk-moon", "", "", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(upstreamErr))
+ })
+
+ res, body := env.doJSON(t, http.MethodPost, "/v1/chat/completions", true, map[string]any{
+ "model": "gpt-4o",
+ "messages": []any{map[string]any{"role": "user", "content": "hi"}},
+ })
+ if res.StatusCode != http.StatusTooManyRequests {
+ t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusTooManyRequests)
+ }
+
+ var resp struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("invalid JSON body: %v\nbody: %s", err, body)
+ }
+ if resp.Error.Message != "rate limit exceeded" {
+ t.Fatalf("error.message = %q, want %q", resp.Error.Message, "rate limit exceeded")
+ }
+ if resp.Error.Type != "rate_limit_error" {
+ t.Fatalf("error.type = %q, want %q", resp.Error.Type, "rate_limit_error")
+ }
+ if resp.Error.Code != "rate_limit_reached" {
+ t.Fatalf("error.code = %q, want %q", resp.Error.Code, "rate_limit_reached")
+ }
+}
+
+func TestUpstreamError_NonJSONErrorWrappedWithRawBody(t *testing.T) {
+ rawErr := `
503 Service Unavailable`
+ env := setupEnv(t, "sk-moon", "", "", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(rawErr))
+ })
+
+ res, body := env.doJSON(t, http.MethodPost, "/v1/chat/completions", true, map[string]any{
+ "model": "gpt-4o",
+ "messages": []any{map[string]any{"role": "user", "content": "hi"}},
+ })
+ if res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusServiceUnavailable)
+ }
+
+ var resp struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("invalid JSON body: %v\nbody: %s", err, body)
+ }
+ if !strings.Contains(resp.Error.Message, rawErr) {
+ t.Fatalf("error.message should contain raw upstream body:\ngot: %q\nwant substring: %q", resp.Error.Message, rawErr)
+ }
+ if !strings.Contains(resp.Error.Message, "503") {
+ t.Fatalf("error.message should contain status code 503: %q", resp.Error.Message)
+ }
+ if resp.Error.Type != "upstream_error" {
+ t.Fatalf("error.type = %q, want %q", resp.Error.Type, "upstream_error")
+ }
+}
diff --git a/internal/gateway/usage/usage.db b/internal/gateway/usage/usage.db
new file mode 100644
index 0000000000000000000000000000000000000000..7efe6dc87f6ef69cbd2e2ef60a1924de1bd2e440
GIT binary patch
literal 24576
zcmeI#PjAyO9LDiv>mORJ+HO^EzHJo)F`1xM_pX}76`oy*r!(lmPVv;DDKDevCeK_bPcXcT&hcx-GKrfEDE!Z3`2
zI!fxuoBpnkyqWpMUp@=Q!yi|*_1{Ku?Tbc6Ip36ZJyTeg*?uL;s``L}sUe{_lmS}hOtz$7aC=NR!3vcPC
z17D7|MOvcyzEG;~G|bD}y~Hw(gJ_UFRb14?BqgqOFuCOhe)Gd>slGd>b`pd#PP}lK
zRWB^6KFwV#KfTxd_P{LH8x8aOZsL9H%dA#*UYS&MS}$yCGIOz2tyBVE+-Qz1(qr
z_Ul&nM7*+2wpC^}fh?M4)Me+;5uGF3)|u%jCe`L;a_PLod6{$#@__oBmr-@XVd5sy
zXE}&frrmL@msWQ=9H=YU%PP{*m{3r_wzQW*yum9|m!JxB6-mvVAX+gWjbZY8TZO54~Dx-d`!zpEXP)
z-RbdJU%l76-Z+WU@A;2wC)+@)>aXUW{=2E^(}q6L5I_I{1Q0*~0R#|0009ILKwwz~
zs(NC)|1azEGA{%WKmY**5I_I{1Q0*~0R&P3-v8MM2q1s}0tg_000IagfB*srEWZHn
x|I5F|%n$(t5I_I{1Q0*~0R#|000G|r*#ih5fB*srAbz#kKd7x(}G
literal 0
HcmV?d00001
From bed4f1084d6de085e050cd284f8013e1362668ac Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 10:31:34 +0100
Subject: [PATCH 3/8] feat: per-turn smart routing
---
internal/gateway/router.go | 44 ++++++++++++++---
internal/gateway/router_test.go | 87 +++++++++++++++++++++++++++++++++
2 files changed, 125 insertions(+), 6 deletions(-)
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index 14a8eb9..9894080 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -153,9 +153,13 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
// Step 3: content-based classification.
result.RequestClass = classifyRequest(body)
- // Determine whether this turn would be downgraded (based on request class only).
- // This is logged but NOT acted on — it's pure instrumentation to measure opportunity.
- wouldDowngrade := shouldDowngrade(result.RequestClass)
+ // Determine whether this turn should be downgraded.
+ // Two independent signals can trigger a downgrade:
+ // (a) content class — simple lookup, code search, structured extraction, etc.
+ // (b) per-turn — small/medium tool-result rounds within a multi-step agent flow.
+ // The per-turn signal is conservative: large tool results are excluded to
+ // minimize continuity risk (big outputs may need pro-level interpretation).
+ wouldDowngrade := shouldDowngrade(result.RequestClass) || shouldDowngradeTurn(result.TurnType, result.ToolResultSize)
// Always log classification at DEBUG so operators can tune thresholds.
lastMsg := lastUserMessage(body)
@@ -187,8 +191,8 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
return result
}
- // Determine whether to downgrade based on request class.
- if !shouldDowngrade(result.RequestClass) {
+ // Determine whether to downgrade: content class OR per-turn tool-result signal.
+ if !wouldDowngrade {
return result
}
@@ -206,11 +210,19 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
result.OverrideModel = override
result.OverrideApplied = true
- result.OverrideReason = string(result.RequestClass) + " downgrade (" + result.OriginalModel + " → " + override + ")"
+
+ // Build a reason that identifies which signal triggered the downgrade.
+ reason := string(result.RequestClass)
+ if shouldDowngradeTurn(result.TurnType, result.ToolResultSize) {
+ reason += "+" + string(result.TurnType) + "/" + result.ToolResultSize
+ }
+ result.OverrideReason = reason + " downgrade (" + result.OriginalModel + " → " + override + ")"
body["model"] = override
slog.Info("router: model_overridden",
"request_id", requestID,
"request_class", result.RequestClass,
+ "turn_type", result.TurnType,
+ "tool_result_size", result.ToolResultSize,
"from", result.OriginalModel,
"to", override,
"sys_prompt_len", result.SysPromptLen,
@@ -235,6 +247,26 @@ func shouldDowngrade(c RequestClass) bool {
}
}
+// shouldDowngradeTurn returns true when a tool-result turn is safe to run on a
+// cheaper model. This is the per-turn routing signal: within a multi-step agent
+// flow, the model just received a tool result and must decide the next step —
+// a task that typically does not require full reasoning capability.
+//
+// Conservative policy: only downgrade small and medium tool results. Large
+// results (e.g. full file trees, large diffs, verbose logs) are kept on the
+// original model because they may require pro-level interpretation.
+func shouldDowngradeTurn(turnType TurnType, toolResultSize string) bool {
+ if turnType != TurnToolResult {
+ return false
+ }
+ switch toolResultSize {
+ case "small", "medium":
+ return true
+ default:
+ return false
+ }
+}
+
// classifyRequest inspects the last user message and request structure to
// infer the task type. Returns ClassUnknown when no clear signal is found.
//
diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go
index 5505983..203a501 100644
--- a/internal/gateway/router_test.go
+++ b/internal/gateway/router_test.go
@@ -623,6 +623,67 @@ func TestSmartRouter_DisabledPreservesModelAndClassifies(t *testing.T) {
}
}
+// TestPerTurnDowngrade_ToolResultSmall verifies that a tool-result turn with a
+// small result gets downgraded to flash EVEN when the content class is
+// "editing" (which would normally keep the model). This is the core per-turn
+// routing behavior: the model just received a tool result and needs to decide
+// the next step — a task flash can handle.
+func TestPerTurnDowngrade_ToolResultSmall(t *testing.T) {
+ body := map[string]any{
+ "model": "deepseek-v4-pro",
+ "messages": []any{
+ map[string]any{"role": "system", "content": strings.Repeat("x", 15000)},
+ map[string]any{"role": "user", "content": "refactor the auth module and add tests"},
+ map[string]any{"role": "assistant", "content": "", "tool_calls": []any{}},
+ // Small tool result: "go test" output, ~30 chars
+ map[string]any{"role": "tool", "content": "ok\tinternal/gateway\t0.123s"},
+ },
+ }
+ r := NewSmartRouter(RouterConfig{Enabled: true})
+ result := r.ClassifyAndOverride(body, "req_test")
+
+ if !result.OverrideApplied {
+ t.Errorf("expected override applied for small tool-result turn, got none (class=%q, turn=%q, size=%q)",
+ result.RequestClass, result.TurnType, result.ToolResultSize)
+ }
+ if result.OverrideModel != "deepseek-v4-flash" {
+ t.Errorf("expected deepseek-v4-flash, got %q", result.OverrideModel)
+ }
+ if result.TurnType != TurnToolResult {
+ t.Errorf("expected turn_type=tool_result, got %q", result.TurnType)
+ }
+ if result.ToolResultSize != "small" {
+ t.Errorf("expected tool_result_size=small, got %q", result.ToolResultSize)
+ }
+}
+
+// TestPerTurnDowngrade_ToolResultLargeKeptModel verifies that a tool-result
+// turn with a LARGE result keeps the original model, even though it's a
+// tool-result turn. This is the conservative policy: large results may need
+// pro-level interpretation.
+func TestPerTurnDowngrade_ToolResultLargeKeptModel(t *testing.T) {
+ body := map[string]any{
+ "model": "deepseek-v4-pro",
+ "messages": []any{
+ map[string]any{"role": "system", "content": strings.Repeat("x", 15000)},
+ map[string]any{"role": "user", "content": "refactor the auth module and add tests"},
+ map[string]any{"role": "assistant", "content": "", "tool_calls": []any{}},
+ // Large tool result: >4096 chars
+ map[string]any{"role": "tool", "content": strings.Repeat("x", 5000)},
+ },
+ }
+ r := NewSmartRouter(RouterConfig{Enabled: true})
+ result := r.ClassifyAndOverride(body, "req_test")
+
+ if result.OverrideApplied {
+ t.Errorf("expected NO override for large tool-result turn, got override to %q (class=%q, turn=%q, size=%q)",
+ result.OverrideModel, result.RequestClass, result.TurnType, result.ToolResultSize)
+ }
+ if result.ToolResultSize != "large" {
+ t.Errorf("expected tool_result_size=large, got %q", result.ToolResultSize)
+ }
+}
+
func TestShouldDowngrade(t *testing.T) {
tests := []struct {
class RequestClass
@@ -648,6 +709,32 @@ func TestShouldDowngrade(t *testing.T) {
}
}
+func TestShouldDowngradeTurn(t *testing.T) {
+ tests := []struct {
+ name string
+ turnType TurnType
+ toolResultSize string
+ want bool
+ }{
+ {"tool_result small → downgrade", TurnToolResult, "small", true},
+ {"tool_result medium → downgrade", TurnToolResult, "medium", true},
+ {"tool_result large → keep (conservative)", TurnToolResult, "large", false},
+ {"tool_result empty size → keep", TurnToolResult, "", false},
+ {"tool_result bogus size → keep", TurnToolResult, "enormous", false},
+ {"user_prompt → keep", TurnUserPrompt, "", false},
+ {"agent_continue → keep", TurnAgentContinue, "", false},
+ {"unknown turn → keep", TurnUnknown, "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldDowngradeTurn(tt.turnType, tt.toolResultSize); got != tt.want {
+ t.Errorf("shouldDowngradeTurn(%q, %q) = %v, want %v", tt.turnType, tt.toolResultSize, got, tt.want)
+ }
+ })
+ }
+}
+
func TestLastUserMessage(t *testing.T) {
tests := []struct {
name string
From bc6ae14d447ffbc457391db26f11e3aabbc0801e Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 10:53:14 +0100
Subject: [PATCH 4/8] pre-final diagnostic run
---
internal/gateway/auth.go | 21 +++++---
internal/gateway/auth_test.go | 20 ++++++++
internal/gateway/proxy.go | 13 +++++
internal/gateway/proxy_test.go | 87 +++++++++++++++++++++++++++++++++
internal/gateway/router.go | 63 ++++++++++++++++++++++--
internal/gateway/router_test.go | 62 +++++++++++++++++++++++
6 files changed, 257 insertions(+), 9 deletions(-)
diff --git a/internal/gateway/auth.go b/internal/gateway/auth.go
index e83f0d0..1ad4cc9 100644
--- a/internal/gateway/auth.go
+++ b/internal/gateway/auth.go
@@ -7,17 +7,26 @@ import (
"strings"
)
+// bearerScheme is the OpenAI-compatible Authorization scheme used by Cursor.
+// It is matched case-insensitively so operators can send "Bearer", "bearer",
+// or any mixed-case variant and still authenticate.
+const bearerScheme = "Bearer"
+
+// apiKeyHeaders lists the fallback headers (after Authorization) that may
+// carry the gateway key, in priority order.
+var apiKeyHeaders = []string{"api-key", "x-api-key", "x-openai-api-key"}
+
// ExtractAPIKey reads the gateway API key from common OpenAI-style headers.
+// The Authorization scheme is matched case-insensitively. If Authorization is
+// present with a non-Bearer scheme (or empty), control falls through to the
+// api-key fallback headers.
func ExtractAPIKey(r *http.Request) string {
if auth := r.Header.Get("Authorization"); auth != "" {
- if rest, ok := strings.CutPrefix(auth, "Bearer "); ok {
- return strings.TrimSpace(rest)
- }
- if rest, ok := strings.CutPrefix(auth, "bearer "); ok {
- return strings.TrimSpace(rest)
+ if scheme, token, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, bearerScheme) {
+ return strings.TrimSpace(token)
}
}
- for _, name := range []string{"api-key", "x-api-key", "x-openai-api-key"} {
+ for _, name := range apiKeyHeaders {
if v := strings.TrimSpace(r.Header.Get(name)); v != "" {
return v
}
diff --git a/internal/gateway/auth_test.go b/internal/gateway/auth_test.go
index e4f346d..1c47501 100644
--- a/internal/gateway/auth_test.go
+++ b/internal/gateway/auth_test.go
@@ -26,6 +26,21 @@ func TestExtractAPIKey(t *testing.T) {
headers: map[string]string{"Authorization": "bearer sk-test-key-12345"},
want: "sk-test-key-12345",
},
+ {
+ name: "bearer uppercase scheme",
+ headers: map[string]string{"Authorization": "BEARER sk-test-key-12345"},
+ want: "sk-test-key-12345",
+ },
+ {
+ name: "bearer mixed-case scheme",
+ headers: map[string]string{"Authorization": "bEaReR sk-test-key-12345"},
+ want: "sk-test-key-12345",
+ },
+ {
+ name: "bearer separators between scheme and token",
+ headers: map[string]string{"Authorization": "Bearer sk-test-key-12345 "},
+ want: "sk-test-key-12345",
+ },
{
name: "Bearer with extra whitespace",
headers: map[string]string{"Authorization": "Bearer sk-test-key-12345 "},
@@ -61,6 +76,11 @@ func TestExtractAPIKey(t *testing.T) {
headers: map[string]string{"Authorization": "Basic dXNlcjpwYXNz"},
want: "",
},
+ {
+ name: "non-Bearer scheme falls through to api-key header",
+ headers: map[string]string{"Authorization": "Basic dXNlcjpwYXNz", "x-api-key": "sk-fallback"},
+ want: "sk-fallback",
+ },
{
name: "empty Authorization falls through to x-api-key",
headers: map[string]string{"Authorization": "", "x-api-key": "sk-fallback"},
diff --git a/internal/gateway/proxy.go b/internal/gateway/proxy.go
index dd03003..04908ea 100644
--- a/internal/gateway/proxy.go
+++ b/internal/gateway/proxy.go
@@ -52,6 +52,19 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// Classify request for subagent detection and downgrade to cheaper model.
if result := s.router.ClassifyAndOverride(sanitized.Body, requestID); result.OverrideApplied {
sanitized.Model = result.OverrideModel
+ // The override may map to a different provider (e.g. kimi-k3 →
+ // deepseek-v4-flash via the default fallback). Re-resolve the
+ // provider from the overridden model so the request is sent to the
+ // correct upstream. If the override model is unknown, fall back to a
+ // best-effort client error — we must not send a model to the wrong
+ // provider's endpoint.
+ if route, err := ResolveModel(result.OverrideModel); err != nil {
+ logRequest(requestID, "status", http.StatusBadGateway, "error", fmt.Sprintf("override model %q not resolvable: %v", result.OverrideModel, err), "provider", string(sanitized.Provider), "model", result.OverrideModel)
+ writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("override model %q not resolvable: %v", result.OverrideModel, err), "upstream_error")
+ return
+ } else {
+ sanitized.Provider = route.Provider
+ }
}
upstreamKey, err := s.upstreamKey(sanitized.Provider)
diff --git a/internal/gateway/proxy_test.go b/internal/gateway/proxy_test.go
index cf4bbb2..9adcb36 100644
--- a/internal/gateway/proxy_test.go
+++ b/internal/gateway/proxy_test.go
@@ -341,3 +341,90 @@ func TestProxyImageWithoutVisionKeyFailsFast(t *testing.T) {
t.Fatalf("text model called %d times despite fail-fast", textCalled.Load())
}
}
+
+// TestProxy_SmartRouterProviderSwitch verifies that when the smart router
+// downgrades a model to a model from a DIFFERENT provider, the gateway
+// re-resolves the upstream provider so the request is sent to the correct
+// endpoint. Regression for the "modelCode: does not exist" cross-provider
+// bug (e.g. gpt-4o→kimi-k3 downgraded to deepseek-v4-flash must reach the
+// DeepSeek endpoint, not the Moonshot endpoint).
+func TestProxy_SmartRouterProviderSwitch(t *testing.T) {
+ var moonshotCalled atomic.Int32
+ moonshotUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ moonshotCalled.Add(1)
+ t.Log("moonshot upstream hit (should not happen for downgraded request)")
+ _ = json.NewEncoder(w).Encode(mockCompletion("kimi-k3"))
+ }))
+ t.Cleanup(moonshotUp.Close)
+
+ var deepseekModel string
+ var deepseekCalled atomic.Int32
+ deepseekUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ deepseekCalled.Add(1)
+ var body map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ deepseekModel, _ = body["model"].(string)
+ _ = json.NewEncoder(w).Encode(mockCompletion(deepseekModel))
+ }))
+ t.Cleanup(deepseekUp.Close)
+
+ dataRoot := t.TempDir()
+ settings := config.DefaultSettings()
+ if err := settings.EnsureGatewayKey(); err != nil {
+ t.Fatal(err)
+ }
+ if err := settings.SetMoonshotKey(dataRoot, "sk-ms"); err != nil {
+ t.Fatal(err)
+ }
+ if err := settings.SetDeepSeekKey(dataRoot, "sk-ds"); err != nil {
+ t.Fatal(err)
+ }
+ if err := config.Save(dataRoot, settings); err != nil {
+ t.Fatal(err)
+ }
+
+ srv, err := gateway.NewServer(gateway.ServerConfig{
+ ListenAddr: "127.0.0.1:0",
+ GatewayKey: settings.GatewayKey,
+ DataRoot: dataRoot,
+ Settings: &settings,
+ HTTPClient: deepseekUp.Client(),
+ SmartRouterEnabled: true,
+ ChatURLOverride: map[config.Provider]string{
+ config.ProviderMoonshot: moonshotUp.URL + "/moonshot/chat/completions",
+ config.ProviderDeepSeek: deepseekUp.URL + "/deepseek/chat/completions",
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ t.Cleanup(func() { _ = srv.Shutdown(t.Context()) })
+
+ env := &testEnv{srv: srv, ts: ts, gatewayKey: settings.GatewayKey, dataRoot: dataRoot}
+
+ // gpt-4o resolves to kimi-k3 (Moonshot). It's a short, simple lookup —
+ // classified SimpleLookup → downgraded to defaultSubagentModel
+ // (deepseek-v4-flash), which lives on DeepSeek. The request must reach the
+ // DeepSeek upstream with the overridden model.
+ res, body := env.doJSON(t, http.MethodPost, "/v1/chat/completions", true, map[string]any{
+ "model": "gpt-4o",
+ "messages": []any{
+ map[string]any{"role": "user", "content": "what is a goroutine?"},
+ },
+ })
+ if res.StatusCode != 200 {
+ t.Fatalf("status %d body %s", res.StatusCode, body)
+ }
+
+ if deepseekCalled.Load() != 1 {
+ t.Fatalf("expected DeepSeek upstream to be called exactly once, got %d", deepseekCalled.Load())
+ }
+ if deepseekModel != "deepseek-v4-flash" {
+ t.Fatalf("expected overridden model deepseek-v4-flash sent to DeepSeek, got %q", deepseekModel)
+ }
+ if moonshotCalled.Load() != 0 {
+ t.Fatalf("Moonshot upstream was called %d times for a downgraded request, it must not be", moonshotCalled.Load())
+ }
+}
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index 9894080..d529d8d 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -2,6 +2,7 @@ package gateway
import (
"encoding/json"
+ "hash/fnv"
"log/slog"
"os"
"path/filepath"
@@ -181,9 +182,20 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
)
// Dump the full messages array to a temp file when content extraction fails,
- // so we can analyze Cursor's message structure and refine our extraction.
- // Gated behind the separate --diagnostic-dump flag.
- if r.cfg.DiagnosticDump && len(lastMsgStripped) == 0 {
+ // so we can analyze Cursor's message structure and refine our extraction,
+ // and — during debugging sessions (--diagnostic-dump is always operator-gated
+ // and off by default) — when a classify event is otherwise not visible from
+ // the log lines alone. These are:
+ // * stripped_len == 0 → extraction failed (original reason)
+ // * tool-result turns → validate size bucketing + tool schema
+ // * overrides actually applied → confirm the downgrade (model+provider)
+ // * no-op extraction → stripped_len == last_msg_len warns that
+ // stripCursorNoise stripped nothing, so the
+ // classifier may be missing structure
+ // * 1% random sample → catch unforeseen shapes current
+ // heuristics can't predict
+ // Dump happens before routing so the raw message array is captured verbatim.
+ if r.cfg.DiagnosticDump && shouldDumpForDiagnostics(result.TurnType, result.ToolResultSize, wouldDowngrade, len(lastMsg), len(lastMsgStripped), requestID) {
dumpMessages(body, requestID)
}
@@ -772,3 +784,48 @@ func dumpMessages(body map[string]any, requestID string) {
}
slog.Info("router: dump_messages written", "request_id", requestID, "file", filename, "size_bytes", len(b))
}
+
+// dumpSampleRate is the denominator for the deterministic sample of classify
+// events we dump during a debugging session (1 in 100). It exists so a
+// long-running --diagnostic-dump session surfaces shapes the targeted triggers
+// can't predict, without writing a file per request.
+const dumpSampleRate uint32 = 100
+
+// shouldDumpForDiagnostics decides whether a classify event warrants a raw
+// message dump during a debugging session. Dumping is always operator-gated by
+// the --diagnostic-dump flag; this function only narrows which requests within
+// that session produce a file so we don't write one per request even when the
+// flag is on. It returns true when the event is otherwise invisible from the
+// log lines alone:
+// - stripped_len == 0 extraction failed (always, original trigger)
+// - tool-result turn validate size bucketing + tool content schema
+// - would_downgrade confirm the model+provider downgrade
+// - no-op extraction stripCursorNoise removed nothing — the
+// classifier may be missing structure
+// - 1% deterministic sample catch unforeseen shapes heuristics miss
+func shouldDumpForDiagnostics(turnType TurnType, toolResultSize string, wouldDowngrade bool, lastMsgLen, strippedLen int, requestID string) bool {
+ // Extraction failed — original diagnostic trigger, always dump.
+ if strippedLen == 0 {
+ return true
+ }
+ // Tool-result turns: validate size bucketing and tool content schema.
+ if turnType == TurnToolResult {
+ return true
+ }
+ // Overrides actually applied: confirm the downgrade sent the expected model
+ // and provider, and nothing was lost in provider re-resolution.
+ if wouldDowngrade {
+ return true
+ }
+ // No-op extraction: if stripping removed nothing, the classifier may be
+ // missing structure it should have seen.
+ if lastMsgLen > 0 && strippedLen == lastMsgLen {
+ return true
+ }
+ // Deterministic sample so long-running sessions surface unforeseen shapes.
+ // Hash the requestID (a random per-request string) and gate on a modulus;
+ // hashing avoids mutable per-process state and is reproducible.
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(requestID))
+ return h.Sum32()%dumpSampleRate == 0
+}
diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go
index 203a501..e428dfc 100644
--- a/internal/gateway/router_test.go
+++ b/internal/gateway/router_test.go
@@ -1,6 +1,7 @@
package gateway
import (
+ "fmt"
"strings"
"testing"
)
@@ -735,6 +736,67 @@ func TestShouldDowngradeTurn(t *testing.T) {
}
}
+// TestShouldDumpForDiagnostics validates the targeted diagnostic-dump triggers.
+// The full-message dump is always operator-gated by --diagnostic-dump; this
+// function only narrows which requests within that session produce a file.
+func TestShouldDumpForDiagnostics(t *testing.T) {
+ tests := []struct {
+ name string
+ turnType TurnType
+ toolResultSize string
+ wouldDowngrade bool
+ lastMsgLen int
+ strippedLen int
+ requestID string
+ want bool
+ }{
+ {"extraction failed (stripped_len 0) → dump", TurnUserPrompt, "", false, 100, 0, "req_a", true},
+ {"tool-result turn small → dump (schema validation)", TurnToolResult, "small", true, 100, 40, "req_b", true},
+ {"tool-result turn large → dump", TurnToolResult, "large", false, 100, 40, "req_c", true},
+ {"would downgrade → dump", TurnUserPrompt, "", true, 100, 40, "req_d", true},
+ {"no-op extraction (stripped==last, non-zero) → dump", TurnUserPrompt, "", false, 40, 40, "req_e", true},
+ // A user_prompt that extracted fine with no override hits only the 1%
+ // deterministic sample, which is not guaranteed true for a fixed ID, so
+ // we do not assert it here; see TestShouldDumpForDiagnostics_Sample.
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldDumpForDiagnostics(tt.turnType, tt.toolResultSize, tt.wouldDowngrade, tt.lastMsgLen, tt.strippedLen, tt.requestID); got != tt.want {
+ t.Errorf("shouldDumpForDiagnostics(%q, %q, %v, %d, %d, %q) = %v, want %v",
+ tt.turnType, tt.toolResultSize, tt.wouldDowngrade, tt.lastMsgLen, tt.strippedLen, tt.requestID, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestShouldDumpForDiagnostics_Sample verifies the deterministic 1%-sample
+// path: the same requestID always yields the same decision (tokenized by FNV,
+// not by a clock or counter), and at most 1-in-100 of distinct IDs dump on the
+// sample branch alone.
+func TestShouldDumpForDiagnostics_Sample(t *testing.T) {
+ // Same ID, twice → same result (deterministic, no per-process state).
+ id := "req_deterministic_sample"
+ a := shouldDumpForDiagnostics(TurnUserPrompt, "", false, 100, 40, id)
+ b := shouldDumpForDiagnostics(TurnUserPrompt, "", false, 100, 40, id)
+ if a != b {
+ t.Fatalf("sample decision not deterministic for same requestID: %v vs %v", a, b)
+ }
+
+ // Distinct IDs: count how many of 10k hit the sample branch only.
+ var sampled int
+ for i := 0; i < 10000; i++ {
+ if shouldDumpForDiagnostics(TurnUserPrompt, "", false, 100, 40, fmt.Sprintf("req_sample_%d", i)) {
+ sampled++
+ }
+ }
+ // Allow slack for the exact 1% boundary across the modulus; require it to be
+ // on the order of 1% (roughly 100 +/- 30), not 0 or 50%+.
+ if sampled < 70 || sampled > 130 {
+ t.Fatalf("deterministic sample rate off: got %d/10000 (~%0.2f%%), want ~1%%", sampled, float64(sampled)*0.01)
+ }
+}
+
func TestLastUserMessage(t *testing.T) {
tests := []struct {
name string
From 3bdbb49237f657132e7bcd5a2975b83ef7865e73 Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 11:15:46 +0100
Subject: [PATCH 5/8] decision aware medium tier
---
internal/gateway/proxy_test.go | 2 +-
internal/gateway/router.go | 313 +++++++++++++++++---
internal/gateway/router_test.go | 296 +++++++++++++++++--
internal/usage/query.go | 498 +++++++++++++++-----------------
internal/usage/query_test.go | 405 ++++++++++++++++++++++++++
5 files changed, 1188 insertions(+), 326 deletions(-)
diff --git a/internal/gateway/proxy_test.go b/internal/gateway/proxy_test.go
index 9adcb36..f81353e 100644
--- a/internal/gateway/proxy_test.go
+++ b/internal/gateway/proxy_test.go
@@ -405,7 +405,7 @@ func TestProxy_SmartRouterProviderSwitch(t *testing.T) {
env := &testEnv{srv: srv, ts: ts, gatewayKey: settings.GatewayKey, dataRoot: dataRoot}
// gpt-4o resolves to kimi-k3 (Moonshot). It's a short, simple lookup —
- // classified SimpleLookup → downgraded to defaultSubagentModel
+ // classified SimpleLookup → downgraded to defaultFlashModel
// (deepseek-v4-flash), which lives on DeepSeek. The request must reach the
// DeepSeek upstream with the overridden model.
res, body := env.doJSON(t, http.MethodPost, "/v1/chat/completions", true, map[string]any{
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index d529d8d..c322f6c 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -75,6 +75,42 @@ const (
TurnUnknown TurnType = "unknown"
)
+// OverrideTier states how aggressively a request should be downgraded.
+// It replaces the old boolean "should downgrade" with a 3-way decision so the
+// router can route cheap turns to flash, decision-heavy cheap turns to pro,
+// and everything else to the original (kept) model.
+type OverrideTier string
+
+const (
+ // TierKeep means keep the original model — no override.
+ TierKeep OverrideTier = "keep"
+ // TierPro means downgrade to a mid-tier model that still reasons well
+ // (e.g. deepseek-v4-pro) — used for decision-heavy cheap tool results.
+ TierPro OverrideTier = "pro"
+ // TierFlash means downgrade to the cheapest model (e.g. deepseek-v4-flash)
+ // — used for read-only / low-risk tool results and simple content classes.
+ TierFlash OverrideTier = "flash"
+)
+
+// toolVerbosityCap is the max completion tokens we inject into cheap
+// (flash) tool-result turns. It exists so cheap turns cannot balloon into
+// multi-thousand-token streams (4–16k tokens / 20–106s latencies were observed
+// in real runs), which is the real cause of "the flow took too long". Keeping
+// cheap turns terse makes the whole agent loop faster at negligible cost.
+const toolVerbosityCap = 1500
+
+// Read-only tools never change state; their results only need cheap
+// interpretation. Write / decision tools (StrReplace, Shell, notebooks, etc.)
+// may warrant a mid-tier (pro) model when their results are non-trivial.
+var readOnlyToolNames = map[string]bool{
+ "Read": true,
+ "Grep": true,
+ "Glob": true,
+ "WebSearch": true,
+ "WebFetch": true,
+ "FetchMcpResource": true,
+}
+
// ClassifierResult contains the classification outcome for logging/shadowing.
type ClassifierResult struct {
IsSubagent bool `json:"is_subagent"`
@@ -89,19 +125,62 @@ type ClassifierResult struct {
ClassificationAge string `json:"classification_age,omitempty"`
TurnType TurnType `json:"turn_type"`
ToolResultSize string `json:"tool_result_size,omitempty"`
+ ToolName string `json:"tool_name,omitempty"`
+ OverrideTier OverrideTier `json:"override_tier,omitempty"`
+ MaxTokens int `json:"max_tokens,omitempty"`
}
-// modelOverrideMap maps expensive models to cheaper equivalents for classification-based downgrades.
-// Entries here take priority. Models not in this map fall back to defaultSubagentModel.
+// modelOverrideMap maps expensive models to cheaper equivalents for flash-tier
+// downgrades. Entries take priority; models not in the map fall back to
+// defaultFlashModel.
var modelOverrideMap = map[string]string{
"deepseek-v4-pro": "deepseek-v4-flash",
// "glm-5.2": "glm-4.7", // Uncomment to use glm-4.7 as flash for glm-5.2
}
-// defaultSubagentModel is the fallback model for any traffic that triggers a
-// downgrade but whose original model isn't in modelOverrideMap. Anything not
-// in the map (including models that aren't DeepSeek at all) falls back to this.
-const defaultSubagentModel = "deepseek-v4-flash"
+// proModelOverrideMap maps expensive models to a mid-tier pro equivalent for
+// decision-heavy downgrades (medium write/error tool results). Entries take
+// priority; models not in the map fall back to defaultProModel. Models that
+// are already pro-or-below (e.g. deepseek-v4-pro itself) have no entry, so a
+// pro-tier override is a no-op for them.
+var proModelOverrideMap = map[string]string{
+ "glm-5.2": "deepseek-v4-pro",
+ "kimi-k3": "deepseek-v4-pro",
+ "kimi-k2.7-code": "deepseek-v4-pro",
+ "deepseek-v4": "deepseek-v4-pro",
+ "deepseek-v4-pro": "deepseek-v4-pro",
+}
+
+// defaultFlashModel is the fallback model for any traffic that triggers a
+// cheap downgrade but whose original model isn't in modelOverrideMap. Anything
+// not in the map (including models that aren't DeepSeek at all) falls back to
+// this.
+const defaultFlashModel = "deepseek-v4-flash"
+
+// defaultProModel is the fallback model for pro-tier downgrades of originals
+// not present in proModelOverrideMap.
+const defaultProModel = "deepseek-v4-pro"
+
+// overrideModelForTier resolves the model to send for a downgrade of the given
+// tier. flash uses modelOverrideMap (fallback defaultFlashModel); pro uses
+// proModelOverrideMap (fallback defaultProModel). keep returns the original
+// model unchanged.
+func overrideModelForTier(original string, tier OverrideTier) string {
+ switch tier {
+ case TierPro:
+ if m, ok := proModelOverrideMap[original]; ok {
+ return m
+ }
+ return defaultProModel
+ case TierFlash:
+ if m, ok := modelOverrideMap[original]; ok {
+ return m
+ }
+ return defaultFlashModel
+ default:
+ return original
+ }
+}
// SmartRouter performs request classification and optional model override.
type SmartRouter struct {
@@ -150,17 +229,26 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
// Step 2: turn type detection for per-turn routing instrumentation.
result.TurnType = detectTurnType(body)
result.ToolResultSize = toolResultSize(body)
+ result.ToolName = toolResultName(body)
// Step 3: content-based classification.
result.RequestClass = classifyRequest(body)
- // Determine whether this turn should be downgraded.
- // Two independent signals can trigger a downgrade:
- // (a) content class — simple lookup, code search, structured extraction, etc.
- // (b) per-turn — small/medium tool-result rounds within a multi-step agent flow.
- // The per-turn signal is conservative: large tool results are excluded to
- // minimize continuity risk (big outputs may need pro-level interpretation).
- wouldDowngrade := shouldDowngrade(result.RequestClass) || shouldDowngradeTurn(result.TurnType, result.ToolResultSize)
+ // Determine this turn's routing tier. Two independent signals contribute:
+ // (a) content class — simple lookup, code search, structured extraction, ...
+ // (b) per-turn — tool-result rounds within a multi-step agent flow.
+ // The per-turn signal is decision-aware: cheap (small / medium read-only)
+ // results route to flash, non-trivial write/error results route to pro,
+ // and large results keep the original model (big outputs may need
+ // pro-level interpretation).
+ tier := overrideTier(result, body)
+ result.OverrideTier = tier
+
+ // Verbosity cap: cheap (flash) tiers get a tight max_tokens so they cannot
+ // balloon into multi-thousand-token streams that stall the agent loop.
+ if tier == TierFlash {
+ result.MaxTokens = toolVerbosityCap
+ }
// Always log classification at DEBUG so operators can tune thresholds.
lastMsg := lastUserMessage(body)
@@ -170,7 +258,9 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
"request_class", result.RequestClass,
"turn_type", result.TurnType,
"tool_result_size", result.ToolResultSize,
- "would_downgrade", wouldDowngrade,
+ "tool_name", result.ToolName,
+ "override_tier", result.OverrideTier,
+ "max_tokens", result.MaxTokens,
"sys_prompt_len", result.SysPromptLen,
"msg_count", result.MsgCount,
"has_tools", result.HasTools,
@@ -195,7 +285,7 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
// * 1% random sample → catch unforeseen shapes current
// heuristics can't predict
// Dump happens before routing so the raw message array is captured verbatim.
- if r.cfg.DiagnosticDump && shouldDumpForDiagnostics(result.TurnType, result.ToolResultSize, wouldDowngrade, len(lastMsg), len(lastMsgStripped), requestID) {
+ if r.cfg.DiagnosticDump && shouldDumpForDiagnostics(result.TurnType, result.ToolResultSize, tier != TierKeep, len(lastMsg), len(lastMsgStripped), requestID) {
dumpMessages(body, requestID)
}
@@ -203,19 +293,26 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
return result
}
- // Determine whether to downgrade: content class OR per-turn tool-result signal.
- if !wouldDowngrade {
+ // No override when the tier says to keep the original model.
+ if tier == TierKeep {
return result
}
- // Apply model override: prefer an explicit map entry; fall back to the
- // universal default (deepseek-v4-flash) for models not in the map.
- override, ok := modelOverrideMap[result.OriginalModel]
- if !ok {
- override = defaultSubagentModel
+ // Apply the verbosity cap first so it also applies when the effective tier
+ // is flash but the model override below is a no-op (e.g. the flow already
+ // runs on deepseek-v4-flash). Flash-tier turns are always kept terse to
+ // avoid multi-thousand-token streams that stall the agent loop. Applied
+ // before the model override so a no-op override still gets the cap.
+ if tier == TierFlash {
+ body["max_tokens"] = toolVerbosityCap
}
- // No-op if the resolved override equals the current model.
+ // Resolve the override model for the chosen tier. Both maps fall back to a
+ // provider-agnostic default so models not explicitly mapped still downgrade.
+ override := overrideModelForTier(result.OriginalModel, tier)
+
+ // No-op if the resolved override equals the current model (e.g. a pro-tier
+ // request that is already on deepseek-v4-pro).
if override == result.OriginalModel {
return result
}
@@ -224,17 +321,19 @@ func (r *SmartRouter) ClassifyAndOverride(body map[string]any, requestID string)
result.OverrideApplied = true
// Build a reason that identifies which signal triggered the downgrade.
- reason := string(result.RequestClass)
- if shouldDowngradeTurn(result.TurnType, result.ToolResultSize) {
- reason += "+" + string(result.TurnType) + "/" + result.ToolResultSize
- }
- result.OverrideReason = reason + " downgrade (" + result.OriginalModel + " → " + override + ")"
+ result.OverrideReason = overrideReason(result, tier) + " downgrade (" + result.OriginalModel + " → " + override + ")"
+
+ // Apply the model override to the body (proxy re-resolves the provider).
body["model"] = override
+
slog.Info("router: model_overridden",
"request_id", requestID,
"request_class", result.RequestClass,
"turn_type", result.TurnType,
"tool_result_size", result.ToolResultSize,
+ "tool_name", result.ToolName,
+ "override_tier", result.OverrideTier,
+ "max_tokens", result.MaxTokens,
"from", result.OriginalModel,
"to", override,
"sys_prompt_len", result.SysPromptLen,
@@ -259,24 +358,80 @@ func shouldDowngrade(c RequestClass) bool {
}
}
-// shouldDowngradeTurn returns true when a tool-result turn is safe to run on a
-// cheaper model. This is the per-turn routing signal: within a multi-step agent
-// flow, the model just received a tool result and must decide the next step —
-// a task that typically does not require full reasoning capability.
+// overrideTier decides the routing tier for a request. It combines the content
+// class (a) with the per-turn tool-result decision (b):
//
-// Conservative policy: only downgrade small and medium tool results. Large
-// results (e.g. full file trees, large diffs, verbose logs) are kept on the
-// original model because they may require pro-level interpretation.
-func shouldDowngradeTurn(turnType TurnType, toolResultSize string) bool {
- if turnType != TurnToolResult {
- return false
+// Content-driven downgrade triggered → TierFlash (cheap is fine for simple
+// lookup / code search / structured extraction / automation).
+//
+// Tool-result turn (last role == tool):
+// small → TierFlash (short outputs, low risk)
+// medium + read-only tool → TierFlash (just reading: grep/read/search)
+// medium + write/error tool → TierPro (decision-heavy: shell, edits,
+// tests, failures — needs pro)
+// large → TierKeep (big outputs need pro reasoning)
+// unknown / empty size → TierKeep (can't gauge risk → conservative)
+//
+// Everything else → content-class result (shouldDowngrade) or TierKeep.
+func overrideTier(result ClassifierResult, body map[string]any) OverrideTier {
+ // Per-turn tool-result signal first — it is the primary driver of cheap
+ // turns within a multi-step agent flow.
+ if result.TurnType == TurnToolResult {
+ switch result.ToolResultSize {
+ case "small":
+ return TierFlash
+ case "medium":
+ if isWriteTool(result.ToolName) {
+ return TierPro
+ }
+ return TierFlash
+ case "large":
+ return TierKeep
+ default:
+ // Unknown/empty size: we can't gauge risk, so keep the original
+ // model (conservative).
+ return TierKeep
+ }
+ }
+
+ // Non-tool turns follow the content classifier.
+ if shouldDowngrade(result.RequestClass) {
+ return TierFlash
+ }
+ return TierKeep
+}
+
+// overrideReason builds a human-readable reason for the override log, naming
+// whichever signal(s) triggered the tier.
+func overrideReason(result ClassifierResult, tier OverrideTier) string {
+ var parts []string
+ if result.TurnType == TurnToolResult && result.ToolResultSize != "" {
+ parts = append(parts, string(result.TurnType)+"/"+result.ToolResultSize)
+ if result.ToolName != "" {
+ parts = append(parts, "tool="+result.ToolName)
+ }
+ } else if result.RequestClass != "" && result.RequestClass != ClassUnknown {
+ parts = append(parts, string(result.RequestClass))
+ }
+ if tier == TierPro {
+ parts = append(parts, "decision-heavy→pro")
}
- switch toolResultSize {
- case "small", "medium":
+ if len(parts) == 0 {
+ return string(tier)
+ }
+ return strings.Join(parts, "+")
+}
+
+// isWriteTool returns true when a tool name represents a state-changing or
+// decision-heavy operation whose (non-small) result warrants a pro model.
+// Read-only tools (Read/Grep/Glob/search) → false. Everything unknown is
+// treated as a write/decision tool so we default to the more conservative
+// (pro) tier for medium results.
+func isWriteTool(name string) bool {
+ if name == "" {
return true
- default:
- return false
}
+ return !readOnlyToolNames[name]
}
// classifyRequest inspects the last user message and request structure to
@@ -632,6 +787,80 @@ func toolResultSize(body map[string]any) string {
}
}
+// toolResultName returns the tool's function name for the last tool-result
+// message, or "" when it can't be determined. It works by matching the last
+// tool message's tool_call_id against the preceding assistant message's
+// tool_calls[].id (or function call), returning the associated function name.
+// This lets the router distinguish read-only tools (Read/Grep/Glob/search)
+// from write/decision tools (Shell/StrReplace/notebook) to thread the
+// decision-aware pro tier.
+func toolResultName(body map[string]any) string {
+ msgs, ok := body["messages"].([]any)
+ if !ok || len(msgs) == 0 {
+ return ""
+ }
+
+ // Find the last tool message and its tool_call_id.
+ var callID string
+ lastIdx := -1
+ for i := len(msgs) - 1; i >= 0; i-- {
+ msg, ok := msgs[i].(map[string]any)
+ if !ok {
+ continue
+ }
+ role, _ := msg["role"].(string)
+ if role != "tool" {
+ continue
+ }
+ lastIdx = i
+ callID, _ = msg["tool_call_id"].(string)
+ break
+ }
+ if lastIdx < 0 {
+ return ""
+ }
+
+ // Walk backward from the tool message for the assistant message that
+ // issued the tool call with the matching id.
+ for i := lastIdx - 1; i >= 0; i-- {
+ msg, ok := msgs[i].(map[string]any)
+ if !ok {
+ continue
+ }
+ role, _ := msg["role"].(string)
+ if role != "assistant" {
+ continue
+ }
+ // Legacy shape: single function_call object.
+ if fc, ok := msg["function_call"].(map[string]any); ok {
+ name, _ := fc["name"].(string)
+ if name != "" {
+ return name
+ }
+ }
+ // Standard shape: tool_calls array.
+ tcs, ok := msg["tool_calls"].([]any)
+ if !ok {
+ continue
+ }
+ for _, tc := range tcs {
+ entry, ok := tc.(map[string]any)
+ if !ok {
+ continue
+ }
+ if id, _ := entry["id"].(string); id != "" && id != callID {
+ continue
+ }
+ if fn, ok := entry["function"].(map[string]any); ok {
+ if name, _ := fn["name"].(string); name != "" {
+ return name
+ }
+ }
+ }
+ }
+ return ""
+}
+
// systemPromptLength returns the length (in chars) of the first system or
// developer message content, or 0 if none found.
func systemPromptLength(body map[string]any) int {
diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go
index e428dfc..44a180a 100644
--- a/internal/gateway/router_test.go
+++ b/internal/gateway/router_test.go
@@ -1,6 +1,7 @@
package gateway
import (
+ "encoding/json"
"fmt"
"strings"
"testing"
@@ -552,9 +553,9 @@ func TestSmartRouter_UnknownProviderDefaultFallback(t *testing.T) {
t.Errorf("%q should be overridden (not in map → fallback default), class=%q",
model, result.RequestClass)
}
- if result.OverrideModel != defaultSubagentModel {
+ if result.OverrideModel != defaultFlashModel {
t.Errorf("%q override model = %q, want default %q",
- model, result.OverrideModel, defaultSubagentModel)
+ model, result.OverrideModel, defaultFlashModel)
}
})
}
@@ -575,8 +576,8 @@ func TestSmartRouter_Glm52ToFlashViaDefault(t *testing.T) {
if !result.OverrideApplied {
t.Errorf("glm-5.2 should be overridden (not in map → default), got class=%q", result.RequestClass)
}
- if stringField(body, "model") != defaultSubagentModel {
- t.Errorf("expected default %q, got %q", defaultSubagentModel, stringField(body, "model"))
+ if stringField(body, "model") != defaultFlashModel {
+ t.Errorf("expected default %q, got %q", defaultFlashModel, stringField(body, "model"))
}
}
@@ -685,6 +686,156 @@ func TestPerTurnDowngrade_ToolResultLargeKeptModel(t *testing.T) {
}
}
+// TestPerTurnDowngrade_ToolResultMediumWriteToPro verifies the decision-aware
+// tier: a MEDIUM tool result from a write/decision tool (StrReplace) on an
+// expensive original model (glm-5.2) downgrades to deepseek-v4-pro (not flash),
+// and does NOT apply the flash verbosity cap.
+func TestPerTurnDowngrade_ToolResultMediumWriteToPro(t *testing.T) {
+ body := map[string]any{
+ "model": "glm-5.2",
+ "messages": []any{
+ map[string]any{"role": "system", "content": strings.Repeat("x", 15000)},
+ map[string]any{"role": "user", "content": "refactor the auth module"},
+ map[string]any{
+ "role": "assistant",
+ "content": "",
+ "tool_calls": []any{
+ map[string]any{
+ "id": "call_edit",
+ "type": "function",
+ "function": map[string]any{"name": "StrReplace", "arguments": "{}"},
+ },
+ },
+ },
+ // Medium tool result: <4096 chars, write result
+ map[string]any{"role": "tool", "tool_call_id": "call_edit", "content": strings.Repeat("y", 2000)},
+ },
+ }
+ r := NewSmartRouter(RouterConfig{Enabled: true})
+ result := r.ClassifyAndOverride(body, "req_test")
+
+ if !result.OverrideApplied {
+ t.Fatalf("expected override for medium write tool-result turn (class=%q, turn=%q, size=%q, tool=%q)",
+ result.RequestClass, result.TurnType, result.ToolResultSize, result.ToolName)
+ }
+ if result.OverrideTier != TierPro {
+ t.Errorf("expected override_tier=pro, got %q", result.OverrideTier)
+ }
+ if result.OverrideModel != "deepseek-v4-pro" {
+ t.Errorf("expected deepseek-v4-pro for medium write tool result, got %q", result.OverrideModel)
+ }
+ if result.ToolName != "StrReplace" {
+ t.Errorf("expected tool_name=StrReplace, got %q", result.ToolName)
+ }
+ // Pro tier must NOT receive the flash verbosity cap.
+ switch v := body["max_tokens"].(type) {
+ case json.Number:
+ if n, _ := v.Int64(); n == toolVerbosityCap {
+ t.Errorf("pro tier must not cap max_tokens to %d, got %d", toolVerbosityCap, n)
+ }
+ case float64:
+ if int(v) == toolVerbosityCap {
+ t.Errorf("pro tier must not cap max_tokens to %d, got %v", toolVerbosityCap, v)
+ }
+ }
+}
+
+// TestPerTurnDowngrade_ToolResultMediumReadToFlash verifies a MEDIUM read-only
+// tool result (Read) downgrades to flash and gets the verbosity cap.
+func TestPerTurnDowngrade_ToolResultMediumReadToFlash(t *testing.T) {
+ body := map[string]any{
+ "model": "glm-5.2",
+ "messages": []any{
+ map[string]any{"role": "system", "content": strings.Repeat("x", 15000)},
+ map[string]any{"role": "user", "content": "refactor the auth module"},
+ map[string]any{
+ "role": "assistant",
+ "content": "",
+ "tool_calls": []any{
+ map[string]any{
+ "id": "call_read",
+ "type": "function",
+ "function": map[string]any{"name": "Read", "arguments": "{}"},
+ },
+ },
+ },
+ map[string]any{"role": "tool", "tool_call_id": "call_read", "content": strings.Repeat("z", 2000)},
+ },
+ }
+ r := NewSmartRouter(RouterConfig{Enabled: true})
+ result := r.ClassifyAndOverride(body, "req_test")
+
+ if !result.OverrideApplied {
+ t.Fatalf("expected override for medium read tool-result turn (turn=%q, size=%q, tool=%q)",
+ result.TurnType, result.ToolResultSize, result.ToolName)
+ }
+ if result.OverrideTier != TierFlash {
+ t.Errorf("expected override_tier=flash, got %q", result.OverrideTier)
+ }
+ if result.OverrideModel != "deepseek-v4-flash" {
+ t.Errorf("expected deepseek-v4-flash for medium read tool result, got %q", result.OverrideModel)
+ }
+ // Flash tier must cap max_tokens on the body.
+ var capVal int
+ switch v := body["max_tokens"].(type) {
+ case int:
+ capVal = v
+ case json.Number:
+ n, _ := v.Int64()
+ capVal = int(n)
+ case float64:
+ capVal = int(v)
+ }
+ if capVal != toolVerbosityCap {
+ t.Errorf("expected body max_tokens capped to %d, got %d", toolVerbosityCap, capVal)
+ }
+}
+
+// TestPerTurnVerbosityCap_AppliedEvenWhenModelOverrideNoop verifies that when
+// the flow already runs on flash (so the model override is a no-op), a small
+// tool-result turn still receives the flash verbosity cap.
+func TestPerTurnVerbosityCap_AppliedEvenWhenModelOverrideNoop(t *testing.T) {
+ body := map[string]any{
+ "model": "deepseek-v4-flash",
+ "messages": []any{
+ map[string]any{"role": "system", "content": strings.Repeat("x", 15000)},
+ map[string]any{"role": "user", "content": "refactor the auth module"},
+ map[string]any{
+ "role": "assistant",
+ "content": "",
+ "tool_calls": []any{
+ map[string]any{
+ "id": "call_shell",
+ "type": "function",
+ "function": map[string]any{"name": "Shell", "arguments": "{}"},
+ },
+ },
+ },
+ map[string]any{"role": "tool", "tool_call_id": "call_shell", "content": "ok"},
+ },
+ }
+ r := NewSmartRouter(RouterConfig{Enabled: true})
+ result := r.ClassifyAndOverride(body, "req_test")
+
+ // Override is a no-op (flash → flash) but the cap should still be applied.
+ if result.OverrideApplied {
+ t.Errorf("expected NO override (flash → flash no-op), got override to %q", result.OverrideModel)
+ }
+ var capVal int
+ switch v := body["max_tokens"].(type) {
+ case int:
+ capVal = v
+ case json.Number:
+ n, _ := v.Int64()
+ capVal = int(n)
+ case float64:
+ capVal = int(v)
+ }
+ if capVal != toolVerbosityCap {
+ t.Errorf("expected body max_tokens capped to %d on flash no-op turn, got %d", toolVerbosityCap, capVal)
+ }
+}
+
func TestShouldDowngrade(t *testing.T) {
tests := []struct {
class RequestClass
@@ -710,27 +861,134 @@ func TestShouldDowngrade(t *testing.T) {
}
}
-func TestShouldDowngradeTurn(t *testing.T) {
+// TestOverrideTier covers the decision-aware per-turn and content routing tier
+// logic. Tool-result turns route cheap (small / medium read-only) results to
+// flash, decision-heavy (medium write/error) results to pro, and large results
+// to keep. Non-tool turns follow the content classifier.
+func TestOverrideTier(t *testing.T) {
tests := []struct {
- name string
- turnType TurnType
- toolResultSize string
- want bool
+ name string
+ result ClassifierResult
+ body map[string]any
+ want OverrideTier
+ }{
+ {"tool small → flash", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "small", ToolName: "Shell"}, nil, TierFlash},
+ {"tool medium read → flash", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "Read"}, nil, TierFlash},
+ {"tool medium grep → flash", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "Grep"}, nil, TierFlash},
+ {"tool medium glob → flash", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "Glob"}, nil, TierFlash},
+ {"tool medium write → pro", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "StrReplace"}, nil, TierPro},
+ {"tool medium shell → pro", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "Shell"}, nil, TierPro},
+ {"tool medium unknown tool → pro (write default)", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: "CustomThing"}, nil, TierPro},
+ {"tool medium empty tool name → pro (conservative default)", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "medium", ToolName: ""}, nil, TierPro},
+ {"tool large → keep", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "large", ToolName: "Shell"}, nil, TierKeep},
+ {"tool empty size → keep", ClassifierResult{TurnType: TurnToolResult, ToolResultSize: "", ToolName: "Read"}, nil, TierKeep},
+ {"content simple lookup → flash", ClassifierResult{TurnType: TurnUserPrompt, RequestClass: ClassSimpleLookup}, nil, TierFlash},
+ {"content code search → flash", ClassifierResult{TurnType: TurnUserPrompt, RequestClass: ClassCodeSearch}, nil, TierFlash},
+ {"content editing → keep", ClassifierResult{TurnType: TurnUserPrompt, RequestClass: ClassEditing}, nil, TierKeep},
+ {"content complex reasoning → keep", ClassifierResult{TurnType: TurnUserPrompt, RequestClass: ClassComplexReasoning}, nil, TierKeep},
+ {"content unknown → keep", ClassifierResult{TurnType: TurnUserPrompt, RequestClass: ClassUnknown}, nil, TierKeep},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // overrideTier uses result fields only; body is unused today but kept
+ // in the signature for future signals.
+ if got := overrideTier(tt.result, tt.body); got != tt.want {
+ t.Errorf("overrideTier(%+v) = %q, want %q", tt.result, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestIsWriteTool verifies the read-only vs write/decision tool classification.
+func TestIsWriteTool(t *testing.T) {
+ readOnly := []string{"Read", "Grep", "Glob", "WebSearch", "WebFetch", "FetchMcpResource"}
+ for _, name := range readOnly {
+ if isWriteTool(name) {
+ t.Errorf("isWriteTool(%q) = true, want false (read-only)", name)
+ }
+ }
+
+ writeTools := []string{"Shell", "StrReplace", "EditNotebook", "Write", "Delete", "", "CustomTool"}
+ for _, name := range writeTools {
+ if !isWriteTool(name) {
+ t.Errorf("isWriteTool(%q) = false, want true (write/decision/unknown)", name)
+ }
+ }
+}
+
+// TestToolResultName verifies we can resolve the function name for the last
+// tool-result message from the preceding assistant tool_calls entry.
+func TestToolResultName(t *testing.T) {
+ body := map[string]any{
+ "messages": []any{
+ map[string]any{"role": "system", "content": "sys"},
+ map[string]any{"role": "user", "content": "do the thing"},
+ map[string]any{
+ "role": "assistant",
+ "content": "",
+ "tool_calls": []any{
+ map[string]any{
+ "id": "call_read",
+ "type": "function",
+ "function": map[string]any{
+ "name": "Read",
+ "arguments": "{}",
+ },
+ },
+ map[string]any{
+ "id": "call_shell",
+ "type": "function",
+ "function": map[string]any{
+ "name": "Shell",
+ "arguments": "{}",
+ },
+ },
+ },
+ },
+ map[string]any{"role": "tool", "tool_call_id": "call_shell", "content": "exit 0"},
+ },
+ }
+ if got := toolResultName(body); got != "Shell" {
+ t.Errorf("toolResultName() = %q, want Shell", got)
+ }
+
+ // Empty messages → "".
+ if got := toolResultName(map[string]any{"messages": []any{}}); got != "" {
+ t.Errorf("toolResultName(empty) = %q, want empty", got)
+ }
+
+ // Not a tool-result last message → "".
+ noTool := map[string]any{
+ "messages": []any{map[string]any{"role": "user", "content": "hi"}},
+ }
+ if got := toolResultName(noTool); got != "" {
+ t.Errorf("toolResultName(no tool) = %q, want empty", got)
+ }
+}
+
+// TestOverrideModelForTier verifies tier→model resolution across the maps and
+// default fallbacks, including the pro/no-op cases.
+func TestOverrideModelForTier(t *testing.T) {
+ tests := []struct {
+ name string
+ original string
+ tier OverrideTier
+ want string
}{
- {"tool_result small → downgrade", TurnToolResult, "small", true},
- {"tool_result medium → downgrade", TurnToolResult, "medium", true},
- {"tool_result large → keep (conservative)", TurnToolResult, "large", false},
- {"tool_result empty size → keep", TurnToolResult, "", false},
- {"tool_result bogus size → keep", TurnToolResult, "enormous", false},
- {"user_prompt → keep", TurnUserPrompt, "", false},
- {"agent_continue → keep", TurnAgentContinue, "", false},
- {"unknown turn → keep", TurnUnknown, "", false},
+ {"flash deepseek-pro → flash", "deepseek-v4-pro", TierFlash, "deepseek-v4-flash"},
+ {"flash unknown → default flash", "kimi-k3", TierFlash, "deepseek-v4-flash"},
+ {"pro glm-5.2 → deepseek-pro", "glm-5.2", TierPro, "deepseek-v4-pro"},
+ {"pro kimi-k3 → deepseek-pro", "kimi-k3", TierPro, "deepseek-v4-pro"},
+ {"pro deepseek-pro → deepseek-pro (no-op)", "deepseek-v4-pro", TierPro, "deepseek-v4-pro"},
+ {"pro unknown → default pro", "thaura", TierPro, "deepseek-v4-pro"},
+ {"keep deepseek-pro → unchanged", "deepseek-v4-pro", TierKeep, "deepseek-v4-pro"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if got := shouldDowngradeTurn(tt.turnType, tt.toolResultSize); got != tt.want {
- t.Errorf("shouldDowngradeTurn(%q, %q) = %v, want %v", tt.turnType, tt.toolResultSize, got, tt.want)
+ if got := overrideModelForTier(tt.original, tt.tier); got != tt.want {
+ t.Errorf("overrideModelForTier(%q, %q) = %q, want %q", tt.original, tt.tier, got, tt.want)
}
})
}
diff --git a/internal/usage/query.go b/internal/usage/query.go
index 8232199..9564386 100644
--- a/internal/usage/query.go
+++ b/internal/usage/query.go
@@ -54,96 +54,125 @@ type BucketModelBreakdown struct {
EstUSD float64 `json:"est_usd"`
}
+// Shared SQL prefixes. Each query method appends its WHERE / GROUP BY /
+// ORDER BY clause. The full SQL emitted (column selection, GROUP BY, ORDER BY)
+// is the contract for upstream callers and must not change.
+const (
+ // eventDetailQuery selects the raw event columns for single-summary queries.
+ eventDetailQuery = `SELECT id, session_id, timestamp, provider, model,
+ prompt_tokens, completion_tokens, cache_hit_tokens, cache_miss_tokens,
+ est_usd, request_id, latency_ms
+ FROM events`
+ // dayAggregateQuery groups rows by calendar day.
+ dayAggregateQuery = `SELECT date(timestamp) as day,
+ COUNT(*) as reqs,
+ COALESCE(SUM(prompt_tokens),0),
+ COALESCE(SUM(completion_tokens),0),
+ COALESCE(SUM(cache_hit_tokens),0),
+ COALESCE(SUM(cache_miss_tokens),0),
+ COALESCE(SUM(est_usd),0)
+ FROM events`
+ // modelBreakdownQuery groups rows by provider and model.
+ modelBreakdownQuery = `SELECT provider, model,
+ COUNT(*) as reqs,
+ COALESCE(SUM(prompt_tokens),0),
+ COALESCE(SUM(completion_tokens),0),
+ COALESCE(SUM(cache_hit_tokens),0),
+ COALESCE(SUM(cache_miss_tokens),0),
+ COALESCE(SUM(est_usd),0)
+ FROM events`
+ // providerBreakdownQuery groups rows by provider.
+ providerBreakdownQuery = `SELECT provider,
+ COUNT(*) as reqs,
+ COALESCE(SUM(prompt_tokens),0),
+ COALESCE(SUM(completion_tokens),0),
+ COALESCE(SUM(cache_hit_tokens),0),
+ COALESCE(SUM(cache_miss_tokens),0),
+ COALESCE(SUM(est_usd),0)
+ FROM events`
+ // sessionsQueryBase aggregates per-session totals; QuerySessionsSince appends
+ // an optional WHERE clause and the GROUP BY / ORDER BY.
+ sessionsQueryBase = `SELECT session_id,
+ COUNT(*) as reqs,
+ COALESCE(SUM(prompt_tokens),0),
+ COALESCE(SUM(completion_tokens),0),
+ COALESCE(SUM(est_usd),0),
+ MIN(timestamp) as first_seen,
+ MAX(timestamp) as last_seen
+ FROM events`
+)
+
// QueryDailyTotals returns a DailySummary for a specific date (YYYY-MM-DD).
func (s *Store) QueryDailyTotals(date string) (DailySummary, error) {
- rows, err := s.db.Query(
- `SELECT id, session_id, timestamp, provider, model,
- prompt_tokens, completion_tokens, cache_hit_tokens, cache_miss_tokens,
- est_usd, request_id, latency_ms
- FROM events WHERE date(timestamp) = ? ORDER BY timestamp ASC`, date)
- if err != nil {
- return DailySummary{}, fmt.Errorf("query daily totals: %w", err)
- }
- defer func() { _ = rows.Close() }()
-
- return buildDailySummary(rows, date, "")
+ return queryAndScan(s, "query daily totals",
+ eventDetailQuery+` WHERE date(timestamp) = ? ORDER BY timestamp ASC`, []any{date},
+ func(rows *sql.Rows) (DailySummary, error) {
+ return buildDailySummary(rows, date, "")
+ })
}
// QueryLastNDays returns DailySummary entries for the last N calendar days.
func (s *Store) QueryLastNDays(n int) ([]DailySummary, error) {
- rows, err := s.db.Query(
- `SELECT date(timestamp) as day,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events
- GROUP BY day
- ORDER BY day DESC
- LIMIT ?`, n)
- if err != nil {
- return nil, fmt.Errorf("query last n days: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanDaySummaries(rows)
+ return queryAndScan(s, "query last n days",
+ dayAggregateQuery+` GROUP BY day ORDER BY day DESC LIMIT ?`, []any{n},
+ func(rows *sql.Rows) ([]DailySummary, error) {
+ return scanRows(rows, scanDaySummaryRow)
+ })
}
// QueryByDaySince returns DailySummary entries grouped by day since a given time.
// When window is sub-day (e.g. 1h/3h/12h), groups by a configurable bucket.
// bucketMinutes: 0 means group by day (date). >0 means group by floor(timestamp / bucket).
func (s *Store) QueryByDaySince(since time.Time, bucketMinutes int) ([]DailySummary, error) {
- var rows *sql.Rows
- var err error
if bucketMinutes > 0 {
bucketSecs := bucketMinutes * 60
- rows, err = s.db.Query(
- `SELECT strftime('%Y-%m-%dT%H:%M:00',
- datetime((CAST(strftime('%s', timestamp) AS INTEGER) / ?) * ?, 'unixepoch')) as bucket,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events WHERE timestamp >= ?
- GROUP BY bucket
- ORDER BY bucket ASC`, bucketSecs, bucketSecs, since.UTC().Format(time.RFC3339))
- } else {
- rows, err = s.db.Query(
- `SELECT date(timestamp) as day,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events WHERE timestamp >= ?
- GROUP BY day
- ORDER BY day ASC`, since.UTC().Format(time.RFC3339))
+ q := `SELECT strftime('%Y-%m-%dT%H:%M:00',
+ datetime((CAST(strftime('%s', timestamp) AS INTEGER) / ?) * ?, 'unixepoch')) as bucket,
+ COUNT(*) as reqs,
+ COALESCE(SUM(prompt_tokens),0),
+ COALESCE(SUM(completion_tokens),0),
+ COALESCE(SUM(cache_hit_tokens),0),
+ COALESCE(SUM(cache_miss_tokens),0),
+ COALESCE(SUM(est_usd),0)
+ FROM events WHERE timestamp >= ?
+ GROUP BY bucket
+ ORDER BY bucket ASC`
+ return queryAndScan(s, "query by day since", q,
+ []any{bucketSecs, bucketSecs, since.UTC().Format(time.RFC3339)},
+ func(rows *sql.Rows) ([]DailySummary, error) {
+ return scanRows(rows, scanDaySummaryRow)
+ })
}
+ return queryAndScan(s, "query by day since",
+ dayAggregateQuery+` WHERE timestamp >= ? GROUP BY day ORDER BY day ASC`,
+ []any{since.UTC().Format(time.RFC3339)},
+ func(rows *sql.Rows) ([]DailySummary, error) {
+ return scanRows(rows, scanDaySummaryRow)
+ })
+}
+
+// queryAndScan runs q with args, closes the result rows, and hands them to build.
+// build owns scanning/consuming rows; any failure there is returned as-is.
+func queryAndScan[T any](s *Store, label, q string, args []any, build func(*sql.Rows) (T, error)) (T, error) {
+ rows, err := s.db.Query(q, args...)
if err != nil {
- return nil, fmt.Errorf("query by day since: %w", err)
+ var zero T
+ return zero, fmt.Errorf("%s: %w", label, err)
}
defer func() { _ = rows.Close() }()
- return scanDaySummaries(rows)
+ return build(rows)
}
-// scanDaySummaries scans rows grouped by a time bucket into DailySummary slices.
-func scanDaySummaries(rows *sql.Rows) ([]DailySummary, error) {
- var out []DailySummary
+// scanRows walks each row, using perRow to scan a single typed value, and returns
+// the collected slice. It surfaces rows.Err() after iteration.
+func scanRows[T any](rows *sql.Rows, perRow func(*sql.Rows) (T, error)) ([]T, error) {
+ var out []T
for rows.Next() {
- var bucket string
- var ds DailySummary
- if err := rows.Scan(&bucket, &ds.RequestCount, &ds.TokensIn,
- &ds.TokensOut, &ds.CacheHitTokens, &ds.CacheMissTokens, &ds.EstUSD); err != nil {
- return nil, fmt.Errorf("scan day/bucket: %w", err)
+ item, err := perRow(rows)
+ if err != nil {
+ return nil, err
}
- ds.Date = bucket
- ds.EstUSD = RoundUSD(ds.EstUSD)
- ds.CursorReference = cursorRef()
- out = append(out, ds)
+ out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
@@ -151,6 +180,18 @@ func scanDaySummaries(rows *sql.Rows) ([]DailySummary, error) {
return out, nil
}
+// scanDaySummaryRow scans one row of dayAggregateQuery (or the bucket variant).
+func scanDaySummaryRow(rows *sql.Rows) (DailySummary, error) {
+ var ds DailySummary
+ if err := rows.Scan(&ds.Date, &ds.RequestCount, &ds.TokensIn,
+ &ds.TokensOut, &ds.CacheHitTokens, &ds.CacheMissTokens, &ds.EstUSD); err != nil {
+ return DailySummary{}, fmt.Errorf("scan day/bucket: %w", err)
+ }
+ ds.EstUSD = RoundUSD(ds.EstUSD)
+ ds.CursorReference = cursorRef()
+ return ds, nil
+}
+
// QueryByDayModelSince returns per-model breakdown per bucket since a given time.
// This powers the Spend by Period chart split by model instead of cache hit/miss.
func (s *Store) QueryByDayModelSince(since time.Time, bucketMinutes int) ([]BucketModelBreakdown, error) {
@@ -200,17 +241,60 @@ func (s *Store) QueryByDayModelSince(since time.Time, bucketMinutes int) ([]Buck
// QuerySessionDetail returns a DailySummary for a specific session ID.
func (s *Store) QuerySessionDetail(sessionID string) (DailySummary, error) {
- rows, err := s.db.Query(
- `SELECT id, session_id, timestamp, provider, model,
- prompt_tokens, completion_tokens, cache_hit_tokens, cache_miss_tokens,
- est_usd, request_id, latency_ms
- FROM events WHERE session_id = ? ORDER BY timestamp ASC`, sessionID)
- if err != nil {
- return DailySummary{}, fmt.Errorf("query session: %w", err)
+ return queryAndScan(s, "query session",
+ eventDetailQuery+` WHERE session_id = ? ORDER BY timestamp ASC`, []any{sessionID},
+ func(rows *sql.Rows) (DailySummary, error) {
+ return buildDailySummary(rows, "", sessionID)
+ })
+}
+
+// accumulateDailyEvent folds a single event into ds and its byModel breakdown.
+// It is pure: it reads only ev and mutates only ds/byModel, never the DB.
+func accumulateDailyEvent(ds *DailySummary, byModel map[string]*ModelBreakdown, ev Event) {
+ ds.RequestCount++
+ ds.TokensIn += ev.PromptTokens
+ ds.TokensOut += ev.CompletionTokens
+ ds.CacheHitTokens += ev.CacheHitTokens
+ ds.CacheMissTokens += ev.CacheMissTokens
+ ds.EstUSD += ev.EstUSD
+
+ mb, ok := byModel[ev.Model]
+ if !ok {
+ mb = &ModelBreakdown{Model: ev.Model, Provider: string(ev.Provider)}
+ byModel[ev.Model] = mb
}
- defer func() { _ = rows.Close() }()
+ mb.RequestCount++
+ mb.TokensIn += ev.PromptTokens
+ mb.TokensOut += ev.CompletionTokens
+ mb.CacheHitTokens += ev.CacheHitTokens
+ mb.CacheMissTokens += ev.CacheMissTokens
+ mb.EstUSD += ev.EstUSD
+}
- return buildDailySummary(rows, "", sessionID)
+// finalizeDailySummary rounds estimates and folds the model map into ds.ByModel.
+func finalizeDailySummary(ds DailySummary, byModel map[string]*ModelBreakdown) DailySummary {
+ ds.EstUSD = RoundUSD(ds.EstUSD)
+ for _, mb := range byModel {
+ mb.EstUSD = RoundUSD(mb.EstUSD)
+ ds.ByModel = append(ds.ByModel, *mb)
+ }
+ return ds
+}
+
+// scanEventRow scans one raw event row into an Event, parsing its provider/timestamp.
+func scanEventRow(rows *sql.Rows) (Event, error) {
+ var ev Event
+ var tsStr, provStr string
+ if err := rows.Scan(
+ &ev.ID, &ev.SessionID, &tsStr, &provStr, &ev.Model,
+ &ev.PromptTokens, &ev.CompletionTokens, &ev.CacheHitTokens, &ev.CacheMissTokens,
+ &ev.EstUSD, &ev.RequestID, &ev.LatencyMS,
+ ); err != nil {
+ return Event{}, fmt.Errorf("scan event: %w", err)
+ }
+ ev.Timestamp, _ = time.Parse(time.RFC3339Nano, tsStr)
+ ev.Provider = config.Provider(provStr)
+ return ev, nil
}
// buildDailySummary scans event rows into a DailySummary.
@@ -223,56 +307,21 @@ func buildDailySummary(rows *sql.Rows, date, sessionID string) (DailySummary, er
byModel := make(map[string]*ModelBreakdown)
for rows.Next() {
- var ev Event
- var tsStr, provStr string
- if err := rows.Scan(
- &ev.ID, &ev.SessionID, &tsStr, &provStr, &ev.Model,
- &ev.PromptTokens, &ev.CompletionTokens, &ev.CacheHitTokens, &ev.CacheMissTokens,
- &ev.EstUSD, &ev.RequestID, &ev.LatencyMS,
- ); err != nil {
- return DailySummary{}, fmt.Errorf("scan event: %w", err)
+ ev, err := scanEventRow(rows)
+ if err != nil {
+ return DailySummary{}, err
}
- ev.Timestamp, _ = time.Parse(time.RFC3339Nano, tsStr)
- ev.Provider = config.Provider(provStr)
-
if sessionID != "" && ev.SessionID != sessionID {
continue
}
-
- ds.RequestCount++
- ds.TokensIn += ev.PromptTokens
- ds.TokensOut += ev.CompletionTokens
- ds.CacheHitTokens += ev.CacheHitTokens
- ds.CacheMissTokens += ev.CacheMissTokens
- ds.EstUSD += ev.EstUSD
-
- mb, ok := byModel[ev.Model]
- if !ok {
- mb = &ModelBreakdown{
- Model: ev.Model,
- Provider: string(ev.Provider),
- }
- byModel[ev.Model] = mb
- }
- mb.RequestCount++
- mb.TokensIn += ev.PromptTokens
- mb.TokensOut += ev.CompletionTokens
- mb.CacheHitTokens += ev.CacheHitTokens
- mb.CacheMissTokens += ev.CacheMissTokens
- mb.EstUSD += ev.EstUSD
+ accumulateDailyEvent(&ds, byModel, ev)
}
if err := rows.Err(); err != nil {
return DailySummary{}, fmt.Errorf("rows: %w", err)
}
- ds.EstUSD = RoundUSD(ds.EstUSD)
- for _, mb := range byModel {
- mb.EstUSD = RoundUSD(mb.EstUSD)
- ds.ByModel = append(ds.ByModel, *mb)
- }
-
- return ds, nil
+ return finalizeDailySummary(ds, byModel), nil
}
func cursorRef() CursorReference {
@@ -310,132 +359,73 @@ type SessionInfo struct {
func (s *Store) QueryMonthToDate() (DailySummary, error) {
now := time.Now().UTC()
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
- rows, err := s.db.Query(
- `SELECT id, session_id, timestamp, provider, model,
- prompt_tokens, completion_tokens, cache_hit_tokens, cache_miss_tokens,
- est_usd, request_id, latency_ms
- FROM events WHERE date(timestamp) >= ? ORDER BY timestamp ASC`, start)
- if err != nil {
- return DailySummary{}, fmt.Errorf("query mtd: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return buildDailySummary(rows, start, "")
+ return queryAndScan(s, "query mtd",
+ eventDetailQuery+` WHERE date(timestamp) >= ? ORDER BY timestamp ASC`, []any{start},
+ func(rows *sql.Rows) (DailySummary, error) {
+ return buildDailySummary(rows, start, "")
+ })
}
// QueryByModelSince returns usage breakdown by model since a given time.
func (s *Store) QueryByModelSince(since time.Time) ([]ModelBreakdown, error) {
- rows, err := s.db.Query(
- `SELECT provider, model,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events WHERE timestamp >= ?
- GROUP BY provider, model
- ORDER BY SUM(est_usd) DESC`, since.UTC().Format(time.RFC3339))
- if err != nil {
- return nil, fmt.Errorf("query by model since: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanModelBreakdowns(rows)
+ return queryAndScan(s, "query by model since",
+ modelBreakdownQuery+` WHERE timestamp >= ? GROUP BY provider, model ORDER BY SUM(est_usd) DESC`,
+ []any{since.UTC().Format(time.RFC3339)},
+ func(rows *sql.Rows) ([]ModelBreakdown, error) {
+ return scanRows(rows, scanModelBreakdownRow)
+ })
}
// QueryByModel returns all-time usage breakdown by model.
func (s *Store) QueryByModel() ([]ModelBreakdown, error) {
- rows, err := s.db.Query(
- `SELECT provider, model,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events
- GROUP BY provider, model
- ORDER BY SUM(est_usd) DESC`)
- if err != nil {
- return nil, fmt.Errorf("query by model: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanModelBreakdowns(rows)
+ return queryAndScan(s, "query by model",
+ modelBreakdownQuery+` GROUP BY provider, model ORDER BY SUM(est_usd) DESC`, nil,
+ func(rows *sql.Rows) ([]ModelBreakdown, error) {
+ return scanRows(rows, scanModelBreakdownRow)
+ })
}
-func scanModelBreakdowns(rows *sql.Rows) ([]ModelBreakdown, error) {
- var out []ModelBreakdown
- for rows.Next() {
- var mb ModelBreakdown
- if err := rows.Scan(&mb.Provider, &mb.Model,
- &mb.RequestCount, &mb.TokensIn, &mb.TokensOut,
- &mb.CacheHitTokens, &mb.CacheMissTokens, &mb.EstUSD); err != nil {
- return nil, fmt.Errorf("scan model: %w", err)
- }
- mb.EstUSD = RoundUSD(mb.EstUSD)
- out = append(out, mb)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("rows: %w", err)
+// scanModelBreakdownRow scans one row of modelBreakdownQuery.
+func scanModelBreakdownRow(rows *sql.Rows) (ModelBreakdown, error) {
+ var mb ModelBreakdown
+ if err := rows.Scan(&mb.Provider, &mb.Model,
+ &mb.RequestCount, &mb.TokensIn, &mb.TokensOut,
+ &mb.CacheHitTokens, &mb.CacheMissTokens, &mb.EstUSD); err != nil {
+ return ModelBreakdown{}, fmt.Errorf("scan model: %w", err)
}
- return out, nil
+ mb.EstUSD = RoundUSD(mb.EstUSD)
+ return mb, nil
}
// QueryByProviderSince returns usage breakdown by provider since a given time.
func (s *Store) QueryByProviderSince(since time.Time) ([]ProviderBreakdown, error) {
- rows, err := s.db.Query(
- `SELECT provider,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events WHERE timestamp >= ?
- GROUP BY provider
- ORDER BY SUM(est_usd) DESC`, since.UTC().Format(time.RFC3339))
- if err != nil {
- return nil, fmt.Errorf("query by provider since: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanProviderBreakdowns(rows)
+ return queryAndScan(s, "query by provider since",
+ providerBreakdownQuery+` WHERE timestamp >= ? GROUP BY provider ORDER BY SUM(est_usd) DESC`,
+ []any{since.UTC().Format(time.RFC3339)},
+ func(rows *sql.Rows) ([]ProviderBreakdown, error) {
+ return scanRows(rows, scanProviderBreakdownRow)
+ })
}
// QueryByProvider returns all-time usage breakdown by provider.
func (s *Store) QueryByProvider() ([]ProviderBreakdown, error) {
- rows, err := s.db.Query(
- `SELECT provider,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(cache_hit_tokens),0),
- COALESCE(SUM(cache_miss_tokens),0),
- COALESCE(SUM(est_usd),0)
- FROM events
- GROUP BY provider
- ORDER BY SUM(est_usd) DESC`)
- if err != nil {
- return nil, fmt.Errorf("query by provider: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanProviderBreakdowns(rows)
+ return queryAndScan(s, "query by provider",
+ providerBreakdownQuery+` GROUP BY provider ORDER BY SUM(est_usd) DESC`, nil,
+ func(rows *sql.Rows) ([]ProviderBreakdown, error) {
+ return scanRows(rows, scanProviderBreakdownRow)
+ })
}
-func scanProviderBreakdowns(rows *sql.Rows) ([]ProviderBreakdown, error) {
- var out []ProviderBreakdown
- for rows.Next() {
- var pb ProviderBreakdown
- if err := rows.Scan(&pb.Provider,
- &pb.RequestCount, &pb.TokensIn, &pb.TokensOut,
- &pb.CacheHitTokens, &pb.CacheMissTokens, &pb.EstUSD); err != nil {
- return nil, fmt.Errorf("scan provider: %w", err)
- }
- pb.EstUSD = RoundUSD(pb.EstUSD)
- out = append(out, pb)
+// scanProviderBreakdownRow scans one row of providerBreakdownQuery.
+func scanProviderBreakdownRow(rows *sql.Rows) (ProviderBreakdown, error) {
+ var pb ProviderBreakdown
+ if err := rows.Scan(&pb.Provider,
+ &pb.RequestCount, &pb.TokensIn, &pb.TokensOut,
+ &pb.CacheHitTokens, &pb.CacheMissTokens, &pb.EstUSD); err != nil {
+ return ProviderBreakdown{}, fmt.Errorf("scan provider: %w", err)
}
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("rows: %w", err)
- }
- return out, nil
+ pb.EstUSD = RoundUSD(pb.EstUSD)
+ return pb, nil
}
// QuerySessions returns a list of all unique sessions with summary info.
@@ -445,53 +435,33 @@ func (s *Store) QuerySessions() ([]SessionInfo, error) {
// QuerySessionsSince returns sessions whose last event is since a given time.
func (s *Store) QuerySessionsSince(since time.Time) ([]SessionInfo, error) {
- var rows *sql.Rows
- var err error
- if since.IsZero() {
- rows, err = s.db.Query(
- `SELECT session_id,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(est_usd),0),
- MIN(timestamp) as first_seen,
- MAX(timestamp) as last_seen
- FROM events
- GROUP BY session_id
- ORDER BY MAX(timestamp) DESC`)
- } else {
- rows, err = s.db.Query(
- `SELECT session_id,
- COUNT(*) as reqs,
- COALESCE(SUM(prompt_tokens),0),
- COALESCE(SUM(completion_tokens),0),
- COALESCE(SUM(est_usd),0),
- MIN(timestamp) as first_seen,
- MAX(timestamp) as last_seen
- FROM events WHERE timestamp >= ?
- GROUP BY session_id
- ORDER BY MAX(timestamp) DESC`, since.UTC().Format(time.RFC3339))
- }
- if err != nil {
- return nil, fmt.Errorf("query sessions: %w", err)
+ q := sessionsQueryBase
+ var args []any
+ if !since.IsZero() {
+ q += " WHERE timestamp >= ?"
+ args = append(args, since.UTC().Format(time.RFC3339))
}
- defer func() { _ = rows.Close() }()
+ q += " GROUP BY session_id ORDER BY MAX(timestamp) DESC"
- var out = make([]SessionInfo, 0)
- for rows.Next() {
- var si SessionInfo
- if err := rows.Scan(&si.SessionID,
- &si.RequestCount, &si.TokensIn, &si.TokensOut, &si.EstUSD,
- &si.FirstSeen, &si.LastSeen); err != nil {
- return nil, fmt.Errorf("scan session: %w", err)
- }
- si.EstUSD = RoundUSD(si.EstUSD)
- out = append(out, si)
+ sessions, err := queryAndScan(s, "query sessions", q, args, func(rows *sql.Rows) ([]SessionInfo, error) {
+ return scanRows(rows, scanSessionInfoRow)
+ })
+ if sessions == nil {
+ sessions = []SessionInfo{}
}
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("rows: %w", err)
+ return sessions, err
+}
+
+// scanSessionInfoRow scans one row of sessionsQueryBase (+ WHERE/GROUP BY/ORDER BY).
+func scanSessionInfoRow(rows *sql.Rows) (SessionInfo, error) {
+ var si SessionInfo
+ if err := rows.Scan(&si.SessionID,
+ &si.RequestCount, &si.TokensIn, &si.TokensOut, &si.EstUSD,
+ &si.FirstSeen, &si.LastSeen); err != nil {
+ return SessionInfo{}, fmt.Errorf("scan session: %w", err)
}
- return out, nil
+ si.EstUSD = RoundUSD(si.EstUSD)
+ return si, nil
}
// DBStats holds database-level statistics.
diff --git a/internal/usage/query_test.go b/internal/usage/query_test.go
index 58df72a..2077d7c 100644
--- a/internal/usage/query_test.go
+++ b/internal/usage/query_test.go
@@ -1,6 +1,8 @@
package usage
import (
+ "database/sql"
+ "reflect"
"testing"
"time"
@@ -193,3 +195,406 @@ func TestQueryEmptyReturns(t *testing.T) {
}
})
}
+
+func TestAccumulateDailyEvent(t *testing.T) {
+ existing := &ModelBreakdown{
+ Model: "kimi-k3", Provider: string(config.ProviderMoonshot),
+ RequestCount: 1, TokensIn: 100, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25,
+ }
+
+ tests := []struct {
+ name string
+ ev Event
+ seedDS *DailySummary
+ seedModels map[string]ModelBreakdown
+ wantDS DailySummary
+ wantModels map[string]ModelBreakdown
+ }{
+ {
+ name: "first event into empty summary",
+ ev: Event{Model: "kimi-k3", Provider: config.ProviderMoonshot,
+ PromptTokens: 100, CompletionTokens: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25},
+ wantDS: DailySummary{RequestCount: 1, TokensIn: 100, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25},
+ wantModels: map[string]ModelBreakdown{
+ "kimi-k3": {Model: "kimi-k3", Provider: string(config.ProviderMoonshot),
+ RequestCount: 1, TokensIn: 100, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25},
+ },
+ },
+ {
+ name: "second event into existing model",
+ ev: Event{Model: "kimi-k3", Provider: config.ProviderMoonshot,
+ PromptTokens: 50, CompletionTokens: 25, EstUSD: 0.75},
+ seedDS: &DailySummary{RequestCount: 1, TokensIn: 100, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25},
+ seedModels: map[string]ModelBreakdown{"kimi-k3": *existing},
+ wantDS: DailySummary{RequestCount: 2, TokensIn: 150, TokensOut: 75,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 2.0},
+ wantModels: map[string]ModelBreakdown{
+ "kimi-k3": {Model: "kimi-k3", Provider: string(config.ProviderMoonshot),
+ RequestCount: 2, TokensIn: 150, TokensOut: 75,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 2.0},
+ },
+ },
+ {
+ name: "event starts a distinct model breakdown",
+ ev: Event{Model: "deepseek-v4-flash", Provider: config.ProviderDeepSeek,
+ PromptTokens: 5, EstUSD: 0.5},
+ seedDS: &DailySummary{RequestCount: 1, TokensIn: 100, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.25},
+ seedModels: map[string]ModelBreakdown{"kimi-k3": *existing},
+ wantDS: DailySummary{RequestCount: 2, TokensIn: 105, TokensOut: 50,
+ CacheHitTokens: 10, CacheMissTokens: 90, EstUSD: 1.75},
+ wantModels: map[string]ModelBreakdown{
+ "kimi-k3": *existing,
+ "deepseek-v4-flash": {Model: "deepseek-v4-flash", Provider: string(config.ProviderDeepSeek),
+ RequestCount: 1, TokensIn: 5, TokensOut: 0, EstUSD: 0.5},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ds := DailySummary{}
+ if tt.seedDS != nil {
+ ds = *tt.seedDS
+ }
+ byModel := make(map[string]*ModelBreakdown)
+ for k, v := range tt.seedModels {
+ mb := v
+ byModel[k] = &mb
+ }
+
+ accumulateDailyEvent(&ds, byModel, tt.ev)
+
+ // ByModel is never set by the accumulator; normalize to nil and
+ // compare deeply.
+ ds.ByModel = nil
+ if !reflect.DeepEqual(ds, tt.wantDS) {
+ t.Fatalf("summary mismatch:\n got %+v\nwant %+v", ds, tt.wantDS)
+ }
+ got := make(map[string]ModelBreakdown, len(byModel))
+ for k, mb := range byModel {
+ got[k] = *mb
+ }
+ if !reflect.DeepEqual(got, tt.wantModels) {
+ t.Fatalf("model breakdowns mismatch:\n got %+v\nwant %+v", got, tt.wantModels)
+ }
+ })
+ }
+}
+
+func TestFinalizeDailySummary(t *testing.T) {
+ tests := []struct {
+ name string
+ ds DailySummary
+ models map[string]ModelBreakdown
+ want DailySummary
+ }{
+ {
+ name: "rounds totals and folds model map",
+ ds: DailySummary{EstUSD: 0.123456},
+ models: map[string]ModelBreakdown{
+ "a": {Model: "a", EstUSD: 0.9876},
+ },
+ want: DailySummary{EstUSD: RoundUSD(0.123456),
+ ByModel: []ModelBreakdown{{Model: "a", EstUSD: 0.988}}},
+ },
+ {
+ name: "rounds floats to three decimals",
+ ds: DailySummary{EstUSD: 1.00005},
+ models: map[string]ModelBreakdown{
+ "b": {Model: "b", EstUSD: 2.34567},
+ "c": {Model: "c", EstUSD: 0.0001},
+ },
+ want: DailySummary{EstUSD: RoundUSD(1.00005),
+ ByModel: []ModelBreakdown{
+ {Model: "b", EstUSD: RoundUSD(2.34567)},
+ {Model: "c", EstUSD: 0.0},
+ }},
+ },
+ {
+ name: "no models yields empty ByModel",
+ ds: DailySummary{EstUSD: 2.5},
+ want: DailySummary{EstUSD: 2.5, ByModel: nil},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ byModel := make(map[string]*ModelBreakdown, len(tt.models))
+ for k, v := range tt.models {
+ mb := v
+ byModel[k] = &mb
+ }
+ got := finalizeDailySummary(tt.ds, byModel)
+
+ if got.EstUSD != tt.want.EstUSD {
+ t.Fatalf("EstUSD mismatch: got %v, want %v", got.EstUSD, tt.want.EstUSD)
+ }
+ gotModels := make(map[string]ModelBreakdown, len(got.ByModel))
+ for _, mb := range got.ByModel {
+ gotModels[mb.Model] = mb
+ }
+ wantModels := make(map[string]ModelBreakdown, len(tt.want.ByModel))
+ for _, mb := range tt.want.ByModel {
+ wantModels[mb.Model] = mb
+ }
+ if !reflect.DeepEqual(gotModels, wantModels) {
+ t.Fatalf("ByModel mismatch:\n got %+v\nwant %+v", gotModels, wantModels)
+ }
+ })
+ }
+}
+
+func TestScanRows(t *testing.T) {
+ root := t.TempDir()
+ store, err := NewStore(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, _ = store.Record(Event{
+ SessionID: "s1", Provider: config.ProviderMoonshot, Model: "kimi-k3",
+ PromptTokens: 1000, CompletionTokens: 500, CacheHitTokens: 100,
+ Timestamp: time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC),
+ })
+ _, _ = store.Record(Event{
+ SessionID: "s1", Provider: config.ProviderMoonshot, Model: "kimi-k3",
+ PromptTokens: 2000, CompletionTokens: 1000, CacheHitTokens: 200,
+ Timestamp: time.Date(2026, 7, 15, 13, 0, 0, 0, time.UTC),
+ })
+ _, _ = store.Record(Event{
+ SessionID: "s2", Provider: config.ProviderDeepSeek, Model: "deepseek-v4-flash",
+ PromptTokens: 3000, Timestamp: time.Date(2026, 7, 16, 9, 0, 0, 0, time.UTC),
+ })
+
+ t.Run("day summaries grouped by day", func(t *testing.T) {
+ want := map[string]DailySummary{
+ "2026-07-15": {Date: "2026-07-15", RequestCount: 2, TokensIn: 3000, TokensOut: 1500, CacheHitTokens: 300},
+ "2026-07-16": {Date: "2026-07-16", RequestCount: 1, TokensIn: 3000, TokensOut: 0, CacheHitTokens: 0},
+ }
+ rows := queryRows(t, store, dayAggregateQuery+" GROUP BY day ORDER BY day ASC", nil)
+ got, err := scanRows(rows, scanDaySummaryRow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertDailyMap(t, got, want)
+ })
+
+ t.Run("model breakdowns grouped by provider,model", func(t *testing.T) {
+ want := map[string]ModelBreakdown{
+ "kimi-k3": {Model: "kimi-k3", Provider: string(config.ProviderMoonshot), RequestCount: 2, TokensIn: 3000, TokensOut: 1500, CacheHitTokens: 300},
+ "deepseek-v4-flash": {Model: "deepseek-v4-flash", Provider: string(config.ProviderDeepSeek), RequestCount: 1, TokensIn: 3000},
+ }
+ rows := queryRows(t, store, modelBreakdownQuery+" GROUP BY provider, model ORDER BY SUM(est_usd) DESC", nil)
+ got, err := scanRows(rows, scanModelBreakdownRow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertModelMap(t, got, want)
+ })
+
+ t.Run("provider breakdowns grouped by provider", func(t *testing.T) {
+ want := map[string]ProviderBreakdown{
+ string(config.ProviderMoonshot): {Provider: string(config.ProviderMoonshot), RequestCount: 2, TokensIn: 3000, TokensOut: 1500, CacheHitTokens: 300},
+ string(config.ProviderDeepSeek): {Provider: string(config.ProviderDeepSeek), RequestCount: 1, TokensIn: 3000},
+ }
+ rows := queryRows(t, store, providerBreakdownQuery+" GROUP BY provider ORDER BY SUM(est_usd) DESC", nil)
+ got, err := scanRows(rows, scanProviderBreakdownRow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertProviderMap(t, got, want)
+ })
+
+ t.Run("session info grouped by session", func(t *testing.T) {
+ want := map[string]SessionInfo{
+ "s1": {SessionID: "s1", RequestCount: 2, TokensIn: 3000, TokensOut: 1500, FirstSeen: "2026-07-15T12:00:00Z", LastSeen: "2026-07-15T13:00:00Z"},
+ "s2": {SessionID: "s2", RequestCount: 1, TokensIn: 3000, FirstSeen: "2026-07-16T09:00:00Z", LastSeen: "2026-07-16T09:00:00Z"},
+ }
+ rows := queryRows(t, store, sessionsQueryBase+" GROUP BY session_id ORDER BY MAX(timestamp) DESC", nil)
+ got, err := scanRows(rows, scanSessionInfoRow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertSessionMap(t, got, want)
+ })
+
+ t.Run("empty result yields nil slice", func(t *testing.T) {
+ rows := queryRows(t, store, dayAggregateQuery+" WHERE date(timestamp) >= ? GROUP BY day ORDER BY day ASC", []any{"2099-01-01"})
+ got, err := scanRows(rows, scanDaySummaryRow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Fatalf("expected nil slice, got %+v", got)
+ }
+ })
+}
+
+func TestQueryAndScan(t *testing.T) {
+ root := t.TempDir()
+ store, err := NewStore(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = store.Record(Event{
+ SessionID: "s1", Provider: config.ProviderMoonshot, Model: "kimi-k3",
+ PromptTokens: 1000, Timestamp: time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC),
+ })
+
+ tests := []struct {
+ name string
+ probe func() (int, error)
+ want int
+ }{
+ {
+ name: "queries and scans rows",
+ probe: func() (int, error) {
+ return queryAndScan(store, "test query", modelBreakdownQuery, nil, func(rows *sql.Rows) (int, error) {
+ got, err := scanRows(rows, scanModelBreakdownRow)
+ return len(got), err
+ })
+ },
+ want: 1,
+ },
+ {
+ name: "returns empty count for no rows",
+ probe: func() (int, error) {
+ return queryAndScan(store, "test query", modelBreakdownQuery+
+ " WHERE timestamp >= ? GROUP BY provider, model", []any{"2099-01-01"},
+ func(rows *sql.Rows) (int, error) {
+ got, err := scanRows(rows, scanModelBreakdownRow)
+ return len(got), err
+ })
+ },
+ want: 0,
+ },
+ {
+ name: "propagates query errors",
+ probe: func() (int, error) {
+ return queryAndScan(store, "test query", "SELECT * FROM no_such_table", nil,
+ func(rows *sql.Rows) (int, error) { return 0, nil })
+ },
+ want: -1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.probe()
+ if tt.want == -1 {
+ if err == nil {
+ t.Fatal("expected an error, got nil")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != tt.want {
+ t.Fatalf("got %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+// queryRows runs q on the store and returns the rows for scanning.
+func queryRows(t *testing.T, store *Store, q string, args []any) *sql.Rows {
+ t.Helper()
+ rows, err := store.db.Query(q, args...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = rows.Close() })
+ return rows
+}
+
+func assertDailyMap(t *testing.T, got []DailySummary, want map[string]DailySummary) {
+ t.Helper()
+ gotMap := make(map[string]DailySummary, len(got))
+ for _, d := range got {
+ gotMap[d.Date] = d
+ }
+ if len(gotMap) != len(want) {
+ t.Fatalf("day count mismatch: got %v, want %v", keysOf(gotMap), keysOf(want))
+ }
+ for k, w := range want {
+ g, ok := gotMap[k]
+ if !ok {
+ t.Fatalf("missing day %q", k)
+ }
+ g.EstUSD, w.EstUSD = 0, 0
+ g.CursorReference, w.CursorReference = CursorReference{}, CursorReference{}
+ g.ByModel, w.ByModel = nil, nil
+ if !reflect.DeepEqual(g, w) {
+ t.Errorf("day %q mismatch:\n got %+v\nwant %+v", k, g, w)
+ }
+ }
+}
+
+func assertModelMap(t *testing.T, got []ModelBreakdown, want map[string]ModelBreakdown) {
+ t.Helper()
+ gotMap := make(map[string]ModelBreakdown, len(got))
+ for _, m := range got {
+ gotMap[m.Model] = m
+ }
+ for k, w := range want {
+ g, ok := gotMap[k]
+ if !ok {
+ t.Fatalf("missing model %q", k)
+ }
+ g.EstUSD, w.EstUSD = 0, 0
+ if g != w {
+ t.Errorf("model %q mismatch:\n got %+v\nwant %+v", k, g, w)
+ }
+ }
+}
+
+func assertProviderMap(t *testing.T, got []ProviderBreakdown, want map[string]ProviderBreakdown) {
+ t.Helper()
+ gotMap := make(map[string]ProviderBreakdown, len(got))
+ for _, p := range got {
+ gotMap[p.Provider] = p
+ }
+ for k, w := range want {
+ g, ok := gotMap[k]
+ if !ok {
+ t.Fatalf("missing provider %q", k)
+ }
+ g.EstUSD, w.EstUSD = 0, 0
+ if g != w {
+ t.Errorf("provider %q mismatch:\n got %+v\nwant %+v", k, g, w)
+ }
+ }
+}
+
+func assertSessionMap(t *testing.T, got []SessionInfo, want map[string]SessionInfo) {
+ t.Helper()
+ gotMap := make(map[string]SessionInfo, len(got))
+ for _, si := range got {
+ gotMap[si.SessionID] = si
+ }
+ for k, w := range want {
+ g, ok := gotMap[k]
+ if !ok {
+ t.Fatalf("missing session %q", k)
+ }
+ g.EstUSD, w.EstUSD = 0, 0
+ if g != w {
+ t.Errorf("session %q mismatch:\n got %+v\nwant %+v", k, g, w)
+ }
+ }
+}
+
+func keysOf[K comparable](m map[K]DailySummary) []K {
+ out := make([]K, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
From 51e2703cd80e6d280aab96f65183d0011f63b5a2 Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 11:33:55 +0100
Subject: [PATCH 6/8] chore: final pass on docs updates
---
.cursor/rules/deepseek.mdc | 13 +-
.cursor/rules/gateway.mdc | 30 ++-
.cursor/rules/release.mdc | 4 +-
README.md | 205 ++++++++++++------
internal/usageui/server_test.go | 362 +++++++++++++++++---------------
5 files changed, 379 insertions(+), 235 deletions(-)
diff --git a/.cursor/rules/deepseek.mdc b/.cursor/rules/deepseek.mdc
index 318989c..db0154b 100644
--- a/.cursor/rules/deepseek.mdc
+++ b/.cursor/rules/deepseek.mdc
@@ -53,4 +53,15 @@ Official first call: https://api-docs.deepseek.com/
| ----- | ---- | ----- |
| Upstream 401 after alias maps to DeepSeek | Only Moonshot key saved | Require `deepseekKeyEncrypted` when resolved provider is `deepseek` (doctor / set-key) |
-
+### `Downgraded flash turns are nearly free via prompt cache`
+
+In a multi-turn agent flow, the conversation context grows monotonically, so
+every successive tool-result request re-sends nearly the whole prior context.
+DeepSeek auto-caches this prefix, making iterated cheap turns extraordinarily
+cheap.
+
+| Symptom | Cause | Fix |
+| ----- | ---- | ----- |
+| `deepseek-v4-flash` tool-result turns log `est_usd ≈ 0.000` with ~99% `cache_hit_tokens` (e.g. 40,576 hit / 46 miss) while `tokens_in` climbs each turn | DeepSeek prompt caching keys on a matching prefix; because each turn appends one tool result to unchanged prior messages, the prefix (and thus the cache) is reused | Expected and desirable — do not treat near-zero flash-cost turns in a stable conversation as under-counting. It means the per-turn downgrade is already paying for itself; run medium write-turns on `pro` and small/read-only on `flash` without fear of cost blow-up |
+
+``
diff --git a/.cursor/rules/gateway.mdc b/.cursor/rules/gateway.mdc
index 4d2bff7..39b70c6 100644
--- a/.cursor/rules/gateway.mdc
+++ b/.cursor/rules/gateway.mdc
@@ -164,9 +164,35 @@ when the model has a mapped cheaper variant:
| Symptom | Cause | Fix |
| ------- | ----- | --- |
| "fix linter errors in some files" stays on the expensive model instead of flash | `isStrongEditing` matches "fix" and blocks the `automation` downgrade | Let lint tasks classify as automation even with a strong-editing word present (`isLintTask` → bypass the `!isStrongEditing` guard) |
-| `glm-5.2` / `kimi-k3` never downgrade on automation tasks | Model has no mapped cheaper variant and falls back to `deepseek-v4-flash` (`defaultSubagentModel`). Override also changes provider (re-resolved via `ResolveModel`), so cross-provider routing works: e.g. `glm-5.2` → `deepseek-v4-flash` routes to `provider=deepseek`, not Z.AI. To add a same-provider cheap variant (e.g. `glm-4.7` as flash for `glm-5.2`), uncomment the entry in `modelOverrideMap` — it takes priority over the universal fallback. |
+| `glm-5.2` / `kimi-k3` never downgrade on automation tasks | Model has no mapped cheaper variant and falls back to `deepseek-v4-flash` (`defaultFlashModel`). Override also changes provider (re-resolved via `ResolveModel`), so cross-provider routing works: e.g. `glm-5.2` → `deepseek-v4-flash` routes to `provider=deepseek`, not Z.AI. To add a same-provider cheap variant (e.g. `glm-4.7` as flash for `glm-5.2`), uncomment the entry in `modelOverrideMap` — it takes priority over the universal fallback. |
-
+#### Per-turn routing tiers (`flash` / `pro` / `keep`)
+
+Within one multi-step agent flow, each tool-result request is routed by an
+`OverrideTier` decided from the **tool result size + the tool name** (not the
+content class). Real 71s GLM 5.2 refactor session: 7/10 turns went `flash`,
+2 decision-heavy `pro`, initial prompt + one large result stayed `glm-5.2`.
+
+| Signal | Tier | Model | Rationale |
+| ------ | ---- | ----- | --------- |
+| tool result, size `small` | flash | `deepseek-v4-flash` | short output → cheap interpretation |
+| tool result, size `medium`, read-only tool (`Read`/`Grep`/`Glob`/search) | flash | `deepseek-v4-flash` | just reading → no pro needed |
+| tool result, size `medium`, write/decision tool (`Shell`/`StrReplace`/tests/unknown) | pro | `deepseek-v4-pro` | may decide state changes → needs a reasoning model |
+| tool result, size `large` | keep | original | big output needs pro interpretation |
+| user prompt | keep | original | content classifier decides (see above) |
+
+Route on the resolved `tool_name` (matched from the last `tool_call_id` back to
+the assistant `tool_calls[].function.name`). Treat any non-read-only tool name —
+**including empty/unknown** — as write/decision → pro. A cheap tier is only safe
+when the result can be interpreted without deep reasoning.
+
+#### Flash verbosity cap (output token ceiling on cheap turns)
+
+| Symptom | Cause | Fix |
+| ------- | ----- | --- |
+| Agent flow "takes too long / overthought a lot" for a simple change | On `flash` turn the model is over-confident and emits 4–16k output tokens (~20–106s) rather than a terse next step | Cap cheap tiers' `max_tokens` to `toolVerbosityCap` (1500) so flash turns stay terse. Apply the cap even when the model override is a no-op (flow already on flash). Cheap turns should land at 78–600 output tokens, not thousands |
+
+``
### `DiagnosticDump: gated message-array dumps for content-extraction analysis`
diff --git a/.cursor/rules/release.mdc b/.cursor/rules/release.mdc
index b3bb872..311eb40 100644
--- a/.cursor/rules/release.mdc
+++ b/.cursor/rules/release.mdc
@@ -9,8 +9,8 @@ alwaysApply: false
## Release Process
- Version is tracked in `VERSION`.
-- `make release-patch` / `make release-minor` / `make release-major` bumps the
- version in `VERSION`, commits, tags, and pushes.
+- `make release-patch` / `make release-minor` bumps the version in `VERSION`,
+ commits, tags, and pushes.
- **Tag push triggers CI:** the `.github/workflows/release-binaries.yaml`
workflow runs on push to `main`, pull requests to `main`, and pushes of tags
matching `v*`.
diff --git a/README.md b/README.md
index b75c847..0960619 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@
### Table of Contents
- [📦 Quickstart](#-quickstart)
+- [⚡ Smart Routing](#-smart-routing)
- [☁️ Setting up Cloudflare](#️-setting-up-cloudflare)
- [📊 Usage Dashboard](#-usage-dashboard)
- [🪐 Providers](#-providers)
@@ -89,6 +90,14 @@ discursive status --show-key | jq
Gateway keys are masked by default. Pass `--show-key` to print the full
`gateway_key` for Cursor setup.
+> **💡 Smart routing is on by default.** The gateway's *smart router* inspects
+> every request and, when safe, routes simpler work (short lookups, code
+> search, structured extraction, and cheap per-turn tool results) to a cheaper
+> model — typically `deepseek-v4-flash` — to cut cost and latency. More complex
+> work (editing/refactoring, reasoning, large outputs) keeps the original model.
+> See [Smart Routing](#-smart-routing) below, or disable it with
+> `discursive start --smart-router=false`.
+
### 3. Configure Cursor
Open **Cursor Settings → Models** and enter:
@@ -112,16 +121,16 @@ Reload Cursor: **Cmd+Shift+P → Reload Window**. You should see
Change the model alias in Cursor's model picker — no restart needed:
-| Cursor alias | Provider | Real model | Use |
-| ------------- | -------- | ------------------- | -------------------------------------- |
-| `gpt-4o` | Moonshot | `kimi-k3` | Planning / flagship |
-| `gpt-4o-mini` | Moonshot | `kimi-k2.7-code` | Coding; always thinks |
-| `o1` | DeepSeek | `deepseek-v4-pro` | Harder execution |
-| `o3-mini` | DeepSeek | `deepseek-v4-flash` | Cheap execution |
-| `gpt-5-nano` | Thaura | `thaura` | Ethical AI; optional provider |
-| `gpt-4.1-turbo` | Z.AI | `glm-5.2` | Planning; cheaper than K3 |
-| `gpt-4.1` | Z.AI | `glm-4.7` | Cheap execution |
-| `gpt-4-turbo` | Z.AI | `glm-5.2` | Compat alias (Cursor may rewrite `gpt-4.1-turbo` to this) |
+| Cursor alias | Provider | Real model | Use |
+| --------------- | -------- | ------------------- | --------------------------------------------------------- |
+| `gpt-4o` | Moonshot | `kimi-k3` | Planning / flagship |
+| `gpt-4o-mini` | Moonshot | `kimi-k2.7-code` | Coding; always thinks |
+| `o1` | DeepSeek | `deepseek-v4-pro` | Harder execution |
+| `o3-mini` | DeepSeek | `deepseek-v4-flash` | Cheap execution |
+| `gpt-5-nano` | Thaura | `thaura` | Ethical AI; optional provider |
+| `gpt-4.1-turbo` | Z.AI | `glm-5.2` | Planning; cheaper than K3 |
+| `gpt-4.1` | Z.AI | `glm-4.7` | Cheap execution |
+| `gpt-4-turbo` | Z.AI | `glm-5.2` | Compat alias (Cursor may rewrite `gpt-4.1-turbo` to this) |
@@ -133,6 +142,81 @@ In Cursor Settings → Models: turn off "Override OpenAI API Key" and
---
+## ⚡ Smart Routing
+
+The gateway can **automatically downgrade individual requests** to a cheaper,
+faster model when the work is simple enough — cutting token cost and latency
+without changing what you pick in Cursor. Smart routing is **on by default** and
+requires no configuration, but every behavior is configurable via
+`discursive start` flags.
+
+> The router runs entirely **inside the gateway**. Cursor still sends every
+> request to the gateway under whatever model alias you chose; the gateway
+> inspects each request, may route it to a cheaper model, and proxies upstream.
+> Cursor's model picker is unaware of the routing.
+
+### What gets downgraded
+
+Each incoming request is classified and routed to one of three tiers
+(`keep` / `pro` / `flash`), based on the **content class** and, for per-turn
+tool results, the **result size + tool name**:
+
+| Request type | Tier | Model |
+| --------------------------------------------------------------- | ----- | --------------------- |
+| Simple lookup / explanation | flash | `deepseek-v4-flash` |
+| Code search / exploration | flash | `deepseek-v4-flash` |
+| Structured extraction (`json_object` / `json_schema`) | flash | `deepseek-v4-flash` |
+| Automation / mechanical work (lint, git, scripts, PR) | flash | `deepseek-v4-flash` |
+| Tool result — small | flash | `deepseek-v4-flash` |
+| Tool result — medium, read-only (`Read`/`Grep`/`Glob`) | flash | `deepseek-v4-flash` |
+| Tool result — medium, write/decision (`Shell`/`StrReplace`) | pro | `deepseek-v4-pro` |
+| Editing / refactoring | keep | original model |
+| Complex reasoning / architecture | keep | original model |
+| Tool result — large | keep | original model |
+
+Full details and rationale live in the agent rules (`.cursor/rules/gateway.mdc`,
+*Per-turn routing tiers*). In practice a real GLM 5.2 refactor session routed
+7 of 10 per-turn requests to `flash` and 2 decision-heavy ones to `pro`, keeping
+the original model for the initial prompt and the one large tool result.
+
+### Flash output cap
+
+Flash-tier requests are limited to **1,500 output tokens** (`max_tokens =
+1500`), so cheap turns stay terse instead of "overthinking" and emitting
+4–16k tokens. This is the main lever that keeps agent flows fast. The cap is
+applied even when the flow is already on `flash` (model override is a no-op).
+
+### `discursive start` flags
+
+| Flag | Default | Purpose |
+| -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--smart-router` | `true` | Enable the smart router (classification + downgrade + per-turn routing + flash cap). Set `--smart-router=false` to run the gateway with no automatic model changes. |
+| `--diagnostic-dump` | `false` | Debugging aid: write the raw `messages` array Cursor sent to `/tmp/discursive-msgdump-.json` whenever content extraction from the last user message returns nothing (e.g. tool-result rounds). Independent of `--smart-router` — dumps can fire even when routing is off, or stay silent while it's on. |
+| `--log-level` | `info` | Log verbosity: `debug`, `info`, `warn`, `error`. Use `debug` to see the per-request `request_class`, `turn_type`, `tool_result_size`, `tool_name`, and `override_tier` lines from the router. Overrides `DISCURSIVE_LOG_LEVEL`. |
+| `--background` | `false` | Detach and run in the background. Logs to `{dataRoot}/gateway.log`. |
+| `--tunnel` | (config) | Tunnel mode: `named`, `none`, or `quick` (persists to config). |
+| `--public-url` | (config) | Public HTTPS base URL ending in `/v1` (persists to config). |
+
+Examples:
+
+```bash
+# Routing on (default) + debug logging
+discursive start --smart-router --log-level debug
+
+# Routing on + debug logging + message dumps for a debugging session
+discursive start --smart-router --log-level debug --diagnostic-dump
+
+# Disable routing entirely
+discursive start --smart-router=false
+```
+
+> **💡 Tip:** At `--log-level debug`, the router logs one line per request with
+> `request_class`, `turn_type`, `tool_result_size`, `tool_name`, and
+> `override_tier`. This is the easiest way to see exactly what the router is
+> doing and tune your expectations.
+
+---
+
## ☁️ Setting up Cloudflare
@@ -190,7 +274,7 @@ logs include an `effort` field on request/response/usage lines.
| --------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------- |
| `kimi-k3` | `low`, `high`, `max` | `low` (API default is `max`; we default lower for cost) |
| `deepseek-v4-pro` / `deepseek-v4-flash` | `off`, `high`, `max` | `off` (`off` → `thinking: disabled`; otherwise `thinking: enabled` + `reasoning_effort`) |
-| `glm-5.2` | `off`, `high`, `max` | `off` (`off` → `thinking: disabled`; otherwise `thinking: enabled` + `reasoning_effort`) |
+| `glm-5.2` | `off`, `high`, `max` | `off` (`off` → `thinking: disabled`; otherwise `thinking: enabled` + `reasoning_effort`) |
- Lower effort usually means fewer thinking tokens and lower cost. `thaura` does not
expose this control.
@@ -204,10 +288,10 @@ selector: [Kimi K2.7 Code](https://www.kimi.com/resources/kimi-k2-7-code)
windows and native reasoning capabilities.
-| API model ID | Cache hit / MTok | Input / MTok | Output / MTok | Role |
-| ------------ | ---------------- | ------------ | ------------- | ----------------------------------------------- |
-| `kimi-k3` | $0.30 | $3.00 | $15.00 | Flagship; 1M-token context, always thinks |
-| `kimi-k2.7-code` | $0.19 | $0.95 | $4.00 | Coding model; always thinks |
+| API model ID | Cache hit / MTok | Input / MTok | Output / MTok | Role |
+| ---------------- | ---------------- | ------------ | ------------- | ----------------------------------------- |
+| `kimi-k3` | $0.30 | $3.00 | $15.00 | Flagship; 1M-token context, always thinks |
+| `kimi-k2.7-code` | $0.19 | $0.95 | $4.00 | Coding model; always thinks |
- Pricing: [https://platform.kimi.ai/docs/pricing/chat](https://platform.kimi.ai/docs/pricing/chat)
@@ -236,6 +320,37 @@ models at a fraction of the cost per token.
---
+### 🪻 Z.AI
+
+[Z.AI](https://docs.z.ai/) provides GLM-series models with
+thinking support and prompt caching. Z.AI is used via the **GLM Coding Plan**
+(subscription, credits quota), which exposes the OpenAI-compatible base URL
+`https://api.z.ai/api/coding/paas/v4`.
+
+| API model ID | Cache hit / MTok | Input / MTok | Output / MTok | Role |
+| ------------ | ---------------- | ------------ | ------------- | ------------------------------------------------------------------------ |
+| `glm-5.2` | $0.26 | $1.40 | $4.40 | Planning model; reasoning_effort + cache |
+| `glm-4.7` | $0.11 | $0.60 | $2.20 | Budget execution; thinking on/off |
+| `glm-4.6v` | $0.05 | $0.30 | $0.90 | Vision worker — describes images for ALL providers (not user-selectable) |
+
+> **Image routing:** any request (any provider) that contains image content is
+> intercepted by the gateway and each image is described by Z.AI `glm-4.6v`
+> (coding-plan endpoint) before the selected text model is called. A Z.AI API
+> key is therefore required to send images. If it is missing or the vision
+> model rejects the image, the request **fails fast** with a clear `vision_error`
+> rather than silently dropping the image.
+
+- Pricing: [https://docs.z.ai/guides/overview/pricing](https://docs.z.ai/guides/overview/pricing)
+- API docs: [https://docs.z.ai/api-reference/introduction](https://docs.z.ai/api-reference/introduction)
+- API key: [https://z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/apikey-list) (GLM Coding Plan key)
+
+| Parameter | `glm-5.2` | `glm-4.7` |
+| ------------------ | ------------------------------------------------------------- | ----------------------- |
+| `thinking` | `{type: "enabled"}` when reasoning; else `{type: "disabled"}` | `{type: "enabled" | "disabled"}` |
+| `reasoning_effort` | Normalized → `off`/`high`/`max` | Deleted (not supported) |
+
+---
+
### 🐪 Thaura
@@ -245,9 +360,9 @@ excellence with ethical principles, designed to support Palestinian liberation
and mission-aligned technology development.
-| API model ID | Input / MTok | Output / MTok | Role |
-| ------------ | ------------ | ------------- | -------------------------------------------- |
-| `thaura` | $0.50 | $2.00 | OpenAI-compatible chat and tool use |
+| API model ID | Input / MTok | Output / MTok | Role |
+| ------------ | ------------ | ------------- | ----------------------------------- |
+| `thaura` | $0.50 | $2.00 | OpenAI-compatible chat and tool use |
- Pricing: [https://thaura.ai/api-platform](https://thaura.ai/api-platform)
@@ -286,37 +401,9 @@ and mission-aligned technology development.
>
+---
-### 🪻 Z.AI
-
-[Z.AI](https://docs.z.ai/) provides GLM-series models with
-thinking support and prompt caching. Z.AI is used via the **GLM Coding Plan**
-(subscription, credits quota), which exposes the OpenAI-compatible base URL
-`https://api.z.ai/api/coding/paas/v4`.
-
-| API model ID | Cache hit / MTok | Input / MTok | Output / MTok | Role |
-| ------------ | ---------------- | ------------ | ------------- | ----------------------------------------- |
-| `glm-5.2` | $0.26 | $1.40 | $4.40 | Planning model; reasoning_effort + cache |
-| `glm-4.7` | $0.11 | $0.60 | $2.20 | Budget execution; thinking on/off |
-| `glm-4.6v` | $0.05 | $0.30 | $0.90 | Vision worker — describes images for ALL providers (not user-selectable) |
-
-> **Image routing:** any request (any provider) that contains image content is
-> intercepted by the gateway and each image is described by Z.AI `glm-4.6v`
-> (coding-plan endpoint) before the selected text model is called. A Z.AI API
-> key is therefore required to send images. If it is missing or the vision
-> model rejects the image, the request **fails fast** with a clear `vision_error`
-> rather than silently dropping the image.
-
-- Pricing: [https://docs.z.ai/guides/overview/pricing](https://docs.z.ai/guides/overview/pricing)
-- API docs: [https://docs.z.ai/api-reference/introduction](https://docs.z.ai/api-reference/introduction)
-- API key: [https://z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/apikey-list) (GLM Coding Plan key)
-
-| Parameter | `glm-5.2` | `glm-4.7` |
-| ------------------ | ------------------------------------------------------------- | -------------------------------------------- |
-| `thinking` | `{type: "enabled"}` when reasoning; else `{type: "disabled"}` | `{type: "enabled"|"disabled"}` |
-| `reasoning_effort` | Normalized → `off`/`high`/`max` | Deleted (not supported) |
-
## 🛠 Tech Stack
@@ -327,11 +414,9 @@ thinking support and prompt caching. Z.AI is used via the **GLM Coding Plan**
| Tunnel | [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) named tunnel |
| Upstream APIs | OpenAI-compatible chat completions (Moonshot + DeepSeek + Thaura + Z.AI) |
-
---
-
## 📁 File Structure
```
@@ -363,18 +448,18 @@ planning/ # MVP task sequence (T01–T10)
All output is JSON on stdout. Pipe through `jq` for readability.
-| Command | Description |
-| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `discursive start` | Start gateway on `localhost:4001`. `--background` forks to daemon. `--log-level` (debug/info/warn/error). `--tunnel` (named/none/quick), `--public-url`. Auto-invokes `init` if config is incomplete on first run. |
-| `discursive stop` | Send SIGTERM via PID file. No-op if not running. |
-| `discursive status` | Config dump + runtime state: PID alive? uptime? log file path/size, tunnel mode, model mapping. Gateway key masked by default; `--show-key` prints the full key. |
-| `discursive logs` | Pretty-print `gateway.log` with colored level prefixes. `--follow` (`-f`) for live tail (uses fsnotify — no polling). `-n N` for last N lines. File auto-rotates at ~2 MB, keeps 2 backups. |
-| `discursive log-level [debug | info | warn | error]` | Show or set log verbosity. Set persists per-process; hints how to export `DISCURSIVE_LOG_LEVEL` for persistence. |
-| `discursive doctor` | Health checks: keys present, port available, local/public HTTP health, tunnel mode, cloudflared binary, logs writable. |
-| `discursive usage` | Token + cost estimates per session/model. |
+| Command | Description |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `discursive start` | Start gateway on `localhost:4001`. `--background` forks to daemon. `--log-level` (debug/info/warn/error). `--tunnel` (named/none/quick), `--public-url`. `--smart-router` (on by default), `--diagnostic-dump` (off by default). Auto-invokes `init` if config is incomplete on first run. See [Smart Routing](#-smart-routing). |
+| `discursive stop` | Send SIGTERM via PID file. No-op if not running. |
+| `discursive status` | Config dump + runtime state: PID alive? uptime? log file path/size, tunnel mode, model mapping. Gateway key masked by default; `--show-key` prints the full key. |
+| `discursive logs` | Pretty-print `gateway.log` with colored level prefixes. `--follow` (`-f`) for live tail (uses fsnotify — no polling). `-n N` for last N lines. File auto-rotates at ~2 MB, keeps 2 backups. |
+| `discursive log-level [debug | info | warn | error]` | Show or set log verbosity. Set persists per-process; hints how to export `DISCURSIVE_LOG_LEVEL` for persistence. |
+| `discursive doctor` | Health checks: keys present, port available, local/public HTTP health, tunnel mode, cloudflared binary, logs writable. |
+| `discursive usage` | Token + cost estimates per session/model. |
| `discursive set` | Configure settings via flags. `--moonshot-key`, `--deepseek-key`, `--thaura-key`, `--zai-key`, `--tunnel-token`, `--public-url`, `--rotate-gateway-key`, `--model`. Combine several in one call. `--show-key` prints the full gateway key. |
-| `discursive completion [bash | zsh | fish | powershell]` | Generate a shell completion script (see [Shell Completion](#️-shell-completion)). |
-| `discursive version` | Print version. |
+| `discursive completion [bash | zsh | fish | powershell]` | Generate a shell completion script (see [Shell Completion](#️-shell-completion)). |
+| `discursive version` | Print version. |
JSON slog on **stdout**, interactive prompts on **stderr** — pipe-friendly.
diff --git a/internal/usageui/server_test.go b/internal/usageui/server_test.go
index 45a9570..9ebb97d 100644
--- a/internal/usageui/server_test.go
+++ b/internal/usageui/server_test.go
@@ -111,193 +111,215 @@ func TestIndexPage(t *testing.T) {
}
}
-func TestAPISummary(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/summary")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var ds usage.DailySummary
- if err := json.Unmarshal(w.Body.Bytes(), &ds); err != nil {
- t.Fatal(err)
- }
- if ds.RequestCount < 1 {
- t.Fatalf("expected at least 1 request, got %d", ds.RequestCount)
- }
+// apiTest describes a single table-driven API endpoint test. On success
+// (expected status code match), the response body is unmarshaled into a fresh
+// T and check is invoked with the decoded value. When check is nil only the
+// status code is asserted.
+type apiTest[T any] struct {
+ name string
+ path string
+ expectedStatus int
+ check func(t *testing.T, got T)
}
-func TestAPIByDay(t *testing.T) {
+// runAPITest executes one apiTest entry against a fresh test server.
+func runAPITest[T any](t *testing.T, tc apiTest[T]) {
+ t.Helper()
srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-day")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var days []usage.DailySummary
- if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil {
- t.Fatal(err)
+ w := doJSON(t, srv, tc.path)
+ if w.Code != tc.expectedStatus {
+ t.Fatalf("status %d, want %d", w.Code, tc.expectedStatus)
}
- if len(days) < 1 {
- t.Fatalf("expected at least 1 day, got %d", len(days))
- }
-}
-
-func TestAPIByModel(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-model")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
+ if tc.check == nil {
+ return
}
- var models []usage.ModelBreakdown
- if err := json.Unmarshal(w.Body.Bytes(), &models); err != nil {
+ var got T
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
- if len(models) < 1 {
- t.Fatalf("expected at least 1 model, got %d", len(models))
- }
+ tc.check(t, got)
}
-func TestAPIByProvider(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-provider")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var provs []usage.ProviderBreakdown
- if err := json.Unmarshal(w.Body.Bytes(), &provs); err != nil {
- t.Fatal(err)
- }
- if len(provs) < 1 {
- t.Fatalf("expected at least 1 provider, got %d", len(provs))
- }
-}
+func TestAPIEndpoints(t *testing.T) {
+ // --- /api/summary ---
+ t.Run("TestAPISummary", func(t *testing.T) {
+ runAPITest(t, apiTest[usage.DailySummary]{
+ name: "TestAPISummary",
+ path: "/api/summary",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, ds usage.DailySummary) {
+ if ds.RequestCount < 1 {
+ t.Fatalf("expected at least 1 request, got %d", ds.RequestCount)
+ }
+ },
+ })
+ })
-func TestAPISessions(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/sessions")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var sessions []usage.SessionInfo
- if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
- t.Fatal(err)
- }
- if len(sessions) < 1 {
- t.Fatalf("expected at least 1 session, got %d", len(sessions))
- }
-}
+ // --- /api/by-day ---
+ t.Run("TestAPIByDay", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.DailySummary]{
+ name: "TestAPIByDay",
+ path: "/api/by-day",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, days []usage.DailySummary) {
+ if len(days) < 1 {
+ t.Fatalf("expected at least 1 day, got %d", len(days))
+ }
+ },
+ })
+ })
-func TestAPIHealth(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/health")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var h HealthInfo
- if err := json.Unmarshal(w.Body.Bytes(), &h); err != nil {
- t.Fatal(err)
- }
- if h.Version != "0.0.0-test" {
- t.Fatalf("version: %q", h.Version)
- }
- if h.TunnelMode != "quick" {
- t.Fatalf("tunnel_mode: %q", h.TunnelMode)
- }
- if !h.HasMoonshotKey {
- t.Fatal("expected has_moonshot_key")
- }
- if !h.HasThauraKey {
- t.Fatal("expected has_thaura_key")
- }
- if !h.HasZaiKey {
- t.Fatal("expected has_zai_key")
- }
-}
+ // --- /api/by-model ---
+ t.Run("TestAPIByModel", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.ModelBreakdown]{
+ name: "TestAPIByModel",
+ path: "/api/by-model",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, models []usage.ModelBreakdown) {
+ if len(models) < 1 {
+ t.Fatalf("expected at least 1 model, got %d", len(models))
+ }
+ },
+ })
+ })
-func TestAPISessionDetail(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/sessions?session_id=sess-test")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var ds usage.DailySummary
- if err := json.Unmarshal(w.Body.Bytes(), &ds); err != nil {
- t.Fatal(err)
- }
- if ds.RequestCount < 1 {
- t.Fatalf("expected at least 1 request, got %d", ds.RequestCount)
- }
- if len(ds.ByModel) < 1 {
- t.Fatalf("expected by_model breakdown, got %d", len(ds.ByModel))
- }
-}
+ // --- /api/by-provider ---
+ t.Run("TestAPIByProvider", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.ProviderBreakdown]{
+ name: "TestAPIByProvider",
+ path: "/api/by-provider",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, provs []usage.ProviderBreakdown) {
+ if len(provs) < 1 {
+ t.Fatalf("expected at least 1 provider, got %d", len(provs))
+ }
+ },
+ })
+ })
-func TestAPIByDaySince(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-day?since=2025-01-01T00:00:00Z")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var days []usage.DailySummary
- if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil {
- t.Fatal(err)
- }
- // All seeded events are after 2025, so should produce at least 1 day.
- if len(days) < 1 {
- t.Fatalf("expected at least 1 day with since filter, got %d", len(days))
- }
-}
+ // --- /api/sessions ---
+ t.Run("TestAPISessions", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.SessionInfo]{
+ name: "TestAPISessions",
+ path: "/api/sessions",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, sessions []usage.SessionInfo) {
+ if len(sessions) < 1 {
+ t.Fatalf("expected at least 1 session, got %d", len(sessions))
+ }
+ },
+ })
+ })
-func TestAPIByModelSince(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-model?since=2025-01-01T00:00:00Z")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var models []usage.ModelBreakdown
- if err := json.Unmarshal(w.Body.Bytes(), &models); err != nil {
- t.Fatal(err)
- }
- if len(models) < 1 {
- t.Fatalf("expected at least 1 model, got %d", len(models))
- }
-}
+ // --- /api/health ---
+ t.Run("TestAPIHealth", func(t *testing.T) {
+ runAPITest(t, apiTest[HealthInfo]{
+ name: "TestAPIHealth",
+ path: "/api/health",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, h HealthInfo) {
+ if h.Version != "0.0.0-test" {
+ t.Fatalf("version: %q", h.Version)
+ }
+ if h.TunnelMode != "quick" {
+ t.Fatalf("tunnel_mode: %q", h.TunnelMode)
+ }
+ if !h.HasMoonshotKey {
+ t.Fatal("expected has_moonshot_key")
+ }
+ if !h.HasThauraKey {
+ t.Fatal("expected has_thaura_key")
+ }
+ if !h.HasZaiKey {
+ t.Fatal("expected has_zai_key")
+ }
+ },
+ })
+ })
-func TestAPIByProviderSince(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-provider?since=2025-01-01T00:00:00Z")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var provs []usage.ProviderBreakdown
- if err := json.Unmarshal(w.Body.Bytes(), &provs); err != nil {
- t.Fatal(err)
- }
- if len(provs) < 1 {
- t.Fatalf("expected at least 1 provider, got %d", len(provs))
- }
-}
+ // --- /api/sessions?session_id=sess-test ---
+ t.Run("TestAPISessionDetail", func(t *testing.T) {
+ runAPITest(t, apiTest[usage.DailySummary]{
+ name: "TestAPISessionDetail",
+ path: "/api/sessions?session_id=sess-test",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, ds usage.DailySummary) {
+ if ds.RequestCount < 1 {
+ t.Fatalf("expected at least 1 request, got %d", ds.RequestCount)
+ }
+ if len(ds.ByModel) < 1 {
+ t.Fatalf("expected by_model breakdown, got %d", len(ds.ByModel))
+ }
+ },
+ })
+ })
-func TestAPISessionsSince(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/sessions?since=2025-01-01T00:00:00Z")
- if w.Code != http.StatusOK {
- t.Fatalf("status %d", w.Code)
- }
- var sessions []usage.SessionInfo
- if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
- t.Fatal(err)
- }
- if len(sessions) < 1 {
- t.Fatalf("expected at least 1 session, got %d", len(sessions))
- }
-}
+ // --- /api/by-day?since=2025-01-01T00:00:00Z ---
+ t.Run("TestAPIByDaySince", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.DailySummary]{
+ name: "TestAPIByDaySince",
+ path: "/api/by-day?since=2025-01-01T00:00:00Z",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, days []usage.DailySummary) {
+ // All seeded events are after 2025, so should produce at least 1 day.
+ if len(days) < 1 {
+ t.Fatalf("expected at least 1 day with since filter, got %d", len(days))
+ }
+ },
+ })
+ })
-func TestAPIBadSince(t *testing.T) {
- srv := newTestServer(t)
- w := doJSON(t, srv, "/api/by-day?since=not-a-date")
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 for bad since, got %d", w.Code)
- }
+ // --- /api/by-model?since=2025-01-01T00:00:00Z ---
+ t.Run("TestAPIByModelSince", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.ModelBreakdown]{
+ name: "TestAPIByModelSince",
+ path: "/api/by-model?since=2025-01-01T00:00:00Z",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, models []usage.ModelBreakdown) {
+ if len(models) < 1 {
+ t.Fatalf("expected at least 1 model, got %d", len(models))
+ }
+ },
+ })
+ })
+
+ // --- /api/by-provider?since=2025-01-01T00:00:00Z ---
+ t.Run("TestAPIByProviderSince", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.ProviderBreakdown]{
+ name: "TestAPIByProviderSince",
+ path: "/api/by-provider?since=2025-01-01T00:00:00Z",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, provs []usage.ProviderBreakdown) {
+ if len(provs) < 1 {
+ t.Fatalf("expected at least 1 provider, got %d", len(provs))
+ }
+ },
+ })
+ })
+
+ // --- /api/sessions?since=2025-01-01T00:00:00Z ---
+ t.Run("TestAPISessionsSince", func(t *testing.T) {
+ runAPITest(t, apiTest[[]usage.SessionInfo]{
+ name: "TestAPISessionsSince",
+ path: "/api/sessions?since=2025-01-01T00:00:00Z",
+ expectedStatus: http.StatusOK,
+ check: func(t *testing.T, sessions []usage.SessionInfo) {
+ if len(sessions) < 1 {
+ t.Fatalf("expected at least 1 session, got %d", len(sessions))
+ }
+ },
+ })
+ })
+
+ // --- /api/by-day?since=not-a-date (bad input → 400, no body check) ---
+ t.Run("TestAPIBadSince", func(t *testing.T) {
+ runAPITest(t, apiTest[json.RawMessage]{
+ name: "TestAPIBadSince",
+ path: "/api/by-day?since=not-a-date",
+ expectedStatus: http.StatusBadRequest,
+ check: nil,
+ })
+ })
}
func TestAPIByDayModelEmptyPadsHourBuckets(t *testing.T) {
From c7ab98234d2a825e966a215e1bb29675a5520931 Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 11:36:24 +0100
Subject: [PATCH 7/8] open-pr skill
---
.cursor/skills/open-pr/SKILL.md | 79 +++++++++++++++++++++++++++++++++
1 file changed, 79 insertions(+)
create mode 100644 .cursor/skills/open-pr/SKILL.md
diff --git a/.cursor/skills/open-pr/SKILL.md b/.cursor/skills/open-pr/SKILL.md
new file mode 100644
index 0000000..7ab9e6b
--- /dev/null
+++ b/.cursor/skills/open-pr/SKILL.md
@@ -0,0 +1,79 @@
+---
+name: open-pr
+description: >
+ Create a GitHub PR from the current branch in this Discursive Go repo.
+ Generates PR description with functional line count, key files to review,
+ and non-technical summary from the git diff. Supports draft mode.
+disable-model-invocation: true
+allowed-tools: Bash, Read
+---
+
+# /open-pr — Create a GitHub Pull Request
+
+**Canonical skill file:** `SKILL.md` (this file).
+
+Read and follow that file in full. This entry exists so Cursor discovers the
+skill under `.cursor/skills/`.
+
+## Repo context
+
+This is the **Discursive** local gateway repo (Go). PRs for this repo are
+personal — they are not tied to a JIRA / ticket tracker, and there is no
+`[WIP]` prefix convention. Keep titles plain-descriptive. This repo's
+`/task-3-complete` skill does **not** open PRs (it commits and pushes only), so
+this skill is invoked standalone: **always ask the user which branch to target
+as the PR base. Never assume or default to a base branch without asking.**
+
+## PR title (required format)
+
+```
+
+```
+
+- `` is a short imperative summary of the change (e.g. "Add smart
+ router with content-based model downgrade").
+- **No JIRA key, no `[WIP]` prefix** — this repo uses neither.
+- For draft PRs, use GitHub's native `draft` flag instead of any title prefix.
+
+Examples:
+
+- `Add smart router with content-based model downgrade`
+- `Fix usage query time-window handling`
+- `Bump dependencies and clean up release config`
+
+## PR body order (required)
+
+1. `**Functional lines changed:** …` — from functional diff with pathspec
+ `:(exclude)*.md` `:(exclude)*.mdc` `:(exclude)**/test/**`
+ `:(exclude)**/*_test.go` `:(exclude)**/*.txt` plus doc/rule exclusions
+ (this repo's docs live in `.cursor/rules/`, `.cursor/skills/`, and reference
+ code lives in `examples/`):
+ `:(exclude).cursor/rules/**` `:(exclude).cursor/skills/**`
+ `:(exclude)examples/**` `:(exclude)VERSION`
+2. `## Key files to review` — 3–8 curated paths from functional `--stat`
+3. Optional `## TODO_IN_THIS_PR`
+4. `## Summary` — short bullet-point list of what changed and why. Derive from
+ the functional diff (step 1). Keep each bullet to 1–2 lines. Do not
+ enumerate markdown, rules, or test file changes here. For a CLI/gateway
+ change the summary already covers the behavioral change; no separate
+ "what the user sees" section is needed.
+5. Optional `## Non-functional changes` — only when markdown, rules, or test
+ file changes are large enough to be worth highlighting. Keep it brief.
+
+### Rules-only / docs-only PRs (zero functional files)
+
+If (and only if) the functional diff is empty — e.g. the PR touches only
+`.md`/`.mdc`, rules, or test files:
+
+- `**Functional lines changed:**` → report `0` explicitly.
+- `## Key files to review` → fall back to the most relevant 3–8 paths from the
+ **full** diff (largest changed files first), since there is no functional
+ `--stat` to draw from.
+- The `## Summary` (and the `## Non-functional changes` section) must describe
+ the actual changed files, because there is no functional diff to summarize.
+ Keep bullets to 1–2 lines each.
+- The existing 3–8 functional-path requirement for `## Key files to review`
+ still applies in full whenever functional files are present.
+
+Do **not** apply this fallback when the functional diff is non-empty — keep the
+functional files as the source for Key files and Summary.
From 1d412fabe6900d08f48cdc7a16f0e70253107340 Mon Sep 17 00:00:00 2001
From: commoddity <47662958+commoddity@users.noreply.github.com>
Date: Sat, 8 Aug 2026 11:41:42 +0100
Subject: [PATCH 8/8] improve open-pr skill
---
.cursor/skills/open-pr/SKILL.md | 190 +++++++++++++++++++++++---------
1 file changed, 135 insertions(+), 55 deletions(-)
diff --git a/.cursor/skills/open-pr/SKILL.md b/.cursor/skills/open-pr/SKILL.md
index 7ab9e6b..46c3bcb 100644
--- a/.cursor/skills/open-pr/SKILL.md
+++ b/.cursor/skills/open-pr/SKILL.md
@@ -10,70 +10,150 @@ allowed-tools: Bash, Read
# /open-pr — Create a GitHub Pull Request
-**Canonical skill file:** `SKILL.md` (this file).
+## Call budget
-Read and follow that file in full. This entry exists so Cursor discovers the
-skill under `.cursor/skills/`.
+This skill MUST complete in **≤8 total tool calls**:
+- Step 0: 1 Bash — batch state gathering
+- Step 1: 1 AskQuestion
+- Step 2: 1 Bash — dump diff + stats
+- Step 3: 1 Read — read the dumped diff once (100ms, tiny)
+- Step 4: 2 Bash — `git push` then `gh pr create` (dependent; can't chain a
+ heredoc body onto a `&&` push safely)
+- Step 5: 1 Bash — cleanup `tmp/diff.diff`
+- Step 6: inline report
-## Repo context
+Every call beyond 8 (and any Bash call that isn't one of the above) is a bug.
+The prior session made ~15 redundant Shell calls (multiple `git status`/`git
+log`/`git branch`, per-file `git diff`, `git fetch`, `gh pr view`). None of
+those are allowed in this flow.
-This is the **Discursive** local gateway repo (Go). PRs for this repo are
-personal — they are not tied to a JIRA / ticket tracker, and there is no
-`[WIP]` prefix convention. Keep titles plain-descriptive. This repo's
-`/task-3-complete` skill does **not** open PRs (it commits and pushes only), so
-this skill is invoked standalone: **always ask the user which branch to target
-as the PR base. Never assume or default to a base branch without asking.**
+## Flow (strict)
-## PR title (required format)
+### Step 0: gather state (1 batch Bash call)
+With `working_directory` set to the repo root:
+
+```bash
+git branch -vv && git branch -r && git log -15 --oneline
```
-
+
+From this output, identify:
+- Current branch name
+- Remote base branch options (pick `main`, plus any other active branches)
+- Recent commits (the branch's story)
+
+**Do NOT run `git status`, `git log`, or `git branch` separately.**
+
+### Step 1: ask base branch (AskQuestion)
+
+**Always** present a choice of base branches derived from step 0. Include `main`
+and any other visible active branches plus an "Other" option. Never default.
+
+### Step 2: dump functional diff + stats (1 Bash call)
+
+Dump to gitignored `./tmp/diff.diff`, plus `--shortstat` and `--stat`:
+
+```bash
+git diff ...HEAD -- ':(exclude)*.md' ':(exclude)*.mdc' ':(exclude)**/test/**' ':(exclude)**/*_test.go' ':(exclude)**/*.txt' ':(exclude).cursor/rules/**' ':(exclude).cursor/skills/**' ':(exclude)examples/**' ':(exclude)VERSION' > ./tmp/diff.diff && echo "---SHORTSTAT---" && git diff ...HEAD --shortstat -- ':(exclude)*.md' ':(exclude)*.mdc' ':(exclude)**/test/**' ':(exclude)**/*_test.go' ':(exclude)**/*.txt' ':(exclude).cursor/rules/**' ':(exclude).cursor/skills/**' ':(exclude)examples/**' ':(exclude)VERSION' && echo "---STAT---" && git diff ...HEAD --stat -- ':(exclude)*.md' ':(exclude)*.mdc' ':(exclude)**/test/**' ':(exclude)**/*_test.go' ':(exclude)**/*.txt' ':(exclude).cursor/rules/**' ':(exclude).cursor/skills/**' ':(exclude)examples/**' ':(exclude)VERSION' && echo "---NONFUNCSTAT---" && git diff ...HEAD --shortstat -- '*.md' '*.mdc' '.cursor/skills/**' '*.txt'
```
-- `` is a short imperative summary of the change (e.g. "Add smart
- router with content-based model downgrade").
-- **No JIRA key, no `[WIP]` prefix** — this repo uses neither.
-- For draft PRs, use GitHub's native `draft` flag instead of any title prefix.
+If the functional diff is empty (zero lines, or only binary `usage.db` type
+junk), fall back to the full diff (no pathspec) and treat as a rules/docs-only
+PR per that section below.
-Examples:
+### Step 3: read the diff ONCE (1 Read call)
+
+Read `./tmp/diff.diff` with the Read tool. Derive from it:
+
+- **Summary bullets (4–6):** what changed and why. Each 1–2 lines. Do not
+ enumerate markdown/rules/test file changes here.
+- **Key files to review:** pick 3–8 from the `--stat` in step 2 (largest first).
+
+Prefer this single Read over many per-file `git diff` calls.
+
+### Step 4: push then create PR (2 Bash calls)
+
+Push the branch:
+
+```bash
+git push -u origin HEAD
+```
+
+If the branch is already pushed (no-op), that's fine — `gh pr create` still
+works. If push fails for a non-obvious reason (not "Everything up-to-date"),
+surface the error and stop.
+
+Then create the PR (body assembled from step 3's read):
+
+```bash
+gh pr create --base --title "" --body "$(cat <<'EOF'
+**Functional lines changed:** files, + −
+
+## Key files to review
+- `…` — …
+- `…` — …
+
+## Summary
+- **…**: …
+- **…**: …
+
+## Non-functional changes # only if substantial rules/skills/README changes
+- `…` — …
+EOF
+)"
+```
+
+For draft PRs, add `--draft`. Capture the PR URL from the output. The
+`## Non-functional changes` section is included only when step 2's
+`---NONFUNCSTAT---` shows substantial changed files (e.g. README + rules large
+enough to matter to reviewers). Keep it brief.
+
+### Step 5: cleanup (1 Bash call)
+
+```bash
+rm -f ./tmp/diff.diff
+```
+
+### Step 6: report (inline, no call)
+
+Report the PR URL captured from step 4's `gh pr create` output. Done. Do not
+`gh pr view` after creation unless the PR appeared to fail.
+
+## PR body order (always)
+
+1. `**Functional lines changed:** …`
+2. `## Key files to review`
+3. Optional `## TODO_IN_THIS_PR`
+4. `## Summary`
+5. Optional `## Non-functional changes`
+
+## PR title format
+```
+
+```
+
+No JIRA key, no `[WIP]` prefix. For draft PRs, use `--draft`.
+
+Examples:
- `Add smart router with content-based model downgrade`
- `Fix usage query time-window handling`
-- `Bump dependencies and clean up release config`
-
-## PR body order (required)
-
-1. `**Functional lines changed:** …` — from functional diff with pathspec
- `:(exclude)*.md` `:(exclude)*.mdc` `:(exclude)**/test/**`
- `:(exclude)**/*_test.go` `:(exclude)**/*.txt` plus doc/rule exclusions
- (this repo's docs live in `.cursor/rules/`, `.cursor/skills/`, and reference
- code lives in `examples/`):
- `:(exclude).cursor/rules/**` `:(exclude).cursor/skills/**`
- `:(exclude)examples/**` `:(exclude)VERSION`
-2. `## Key files to review` — 3–8 curated paths from functional `--stat`
-3. Optional `## TODO_IN_THIS_PR`
-4. `## Summary` — short bullet-point list of what changed and why. Derive from
- the functional diff (step 1). Keep each bullet to 1–2 lines. Do not
- enumerate markdown, rules, or test file changes here. For a CLI/gateway
- change the summary already covers the behavioral change; no separate
- "what the user sees" section is needed.
-5. Optional `## Non-functional changes` — only when markdown, rules, or test
- file changes are large enough to be worth highlighting. Keep it brief.
-
-### Rules-only / docs-only PRs (zero functional files)
-
-If (and only if) the functional diff is empty — e.g. the PR touches only
-`.md`/`.mdc`, rules, or test files:
-
-- `**Functional lines changed:**` → report `0` explicitly.
-- `## Key files to review` → fall back to the most relevant 3–8 paths from the
- **full** diff (largest changed files first), since there is no functional
- `--stat` to draw from.
-- The `## Summary` (and the `## Non-functional changes` section) must describe
- the actual changed files, because there is no functional diff to summarize.
- Keep bullets to 1–2 lines each.
-- The existing 3–8 functional-path requirement for `## Key files to review`
- still applies in full whenever functional files are present.
-
-Do **not** apply this fallback when the functional diff is non-empty — keep the
-functional files as the source for Key files and Summary.
+
+## Rules-only / docs-only PRs (zero functional files)
+
+When the functional diff is empty:
+
+- `**Functional lines changed:**` → `0` explicitly.
+- `## Key files to review` → 3–8 paths from the **full** diff (largest first).
+- `## Summary` → describe the actual changed files.
+
+## Anti-patterns (do NOT do these)
+
+- Do NOT read individual file diffs with `git diff -- ` — use the single
+ temp-file dump instead.
+- Do NOT run `git status` — the branch state is from step 0.
+- Do NOT run `git fetch` as a separate call — if needed, prepend to step 0.
+- Do NOT run `gh pr view` after creation unless step 4 output is suspicious.
+- Do NOT run more than one Bash call for independent commands — chain with `&&`.
+- Do NOT forget `working_directory` — every Bash call that touches the repo
+ needs it set explicitly.