From 3bbf9af9cab194b10242880f57a3e2ccbb181d1a Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 31 Mar 2026 20:41:04 +0200 Subject: [PATCH 1/4] feat(usage): add cache analytics support --- .../admin/dashboard/static/js/dashboard.js | 12 + .../dashboard/static/js/modules/charts.js | 81 +++-- .../static/js/modules/execution-plans.js | 36 ++- .../dashboard/static/js/modules/usage.js | 87 +++++- internal/admin/dashboard/templates/index.html | 23 ++ internal/admin/handler.go | 35 +++ internal/admin/handler_test.go | 67 ++++ internal/app/app.go | 3 +- internal/app/app_test.go | 3 + internal/responsecache/handle_request_test.go | 94 +++++- internal/responsecache/responsecache.go | 15 +- internal/responsecache/semantic.go | 7 +- internal/responsecache/semantic_test.go | 8 +- internal/responsecache/simple.go | 14 +- internal/responsecache/usage_hit.go | 60 ++++ internal/server/http.go | 1 + internal/usage/cache_type.go | 41 +++ internal/usage/extractor.go | 57 ++++ internal/usage/extractor_test.go | 28 ++ internal/usage/reader.go | 34 ++ internal/usage/reader_mongodb.go | 295 ++++++++++++++---- internal/usage/reader_mongodb_test.go | 39 +++ internal/usage/reader_postgresql.go | 133 ++++++-- internal/usage/reader_sqlite.go | 136 ++++++-- internal/usage/store_mongodb.go | 3 + internal/usage/store_postgresql.go | 8 +- internal/usage/store_postgresql_test.go | 25 +- internal/usage/store_sqlite.go | 12 +- internal/usage/usage.go | 9 +- 29 files changed, 1177 insertions(+), 189 deletions(-) create mode 100644 internal/responsecache/usage_hit.go create mode 100644 internal/usage/cache_type.go create mode 100644 internal/usage/reader_mongodb_test.go diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 018467db..43fa8714 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -46,6 +46,18 @@ function dashboard() { // Data summary: { total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, total_input_cost: null, total_output_cost: null, total_cost: null }, daily: [], + cacheOverview: { + summary: { + total_hits: 0, + exact_hits: 0, + semantic_hits: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_saved_cost: null + }, + daily: [] + }, models: [], categories: [], activeCategory: 'all', diff --git a/internal/admin/dashboard/static/js/modules/charts.js b/internal/admin/dashboard/static/js/modules/charts.js index b9145fc3..8ef2bee5 100644 --- a/internal/admin/dashboard/static/js/modules/charts.js +++ b/internal/admin/dashboard/static/js/modules/charts.js @@ -1,33 +1,61 @@ (function(global) { function dashboardChartsModule() { return { - _overviewChartConfig(colors, labels, inputData, outputData) { + _overviewChartConfig(colors, labels, inputData, outputData, cacheInputData, cacheOutputData) { + const cacheEnabled = typeof this.cacheAnalyticsEnabled === 'function' && this.cacheAnalyticsEnabled(); + const datasets = [ + { + label: 'Input Tokens', + data: inputData, + borderColor: '#c2845a', + backgroundColor: 'rgba(194, 132, 90, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 5 + }, + { + label: 'Output Tokens', + data: outputData, + borderColor: '#7a9e7e', + backgroundColor: 'rgba(122, 158, 126, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 5 + } + ]; + if (cacheEnabled) { + datasets.push( + { + label: 'Local Cache Input Tokens', + data: cacheInputData, + borderColor: '#5f7dcf', + backgroundColor: 'rgba(95, 125, 207, 0.08)', + fill: false, + tension: 0.3, + pointRadius: 2, + pointHoverRadius: 4, + borderDash: [6, 4] + }, + { + label: 'Local Cache Output Tokens', + data: cacheOutputData, + borderColor: '#b0677b', + backgroundColor: 'rgba(176, 103, 123, 0.08)', + fill: false, + tension: 0.3, + pointRadius: 2, + pointHoverRadius: 4, + borderDash: [6, 4] + } + ); + } return { type: 'line', data: { labels: labels, - datasets: [ - { - label: 'Input Tokens', - data: inputData, - borderColor: '#c2845a', - backgroundColor: 'rgba(194, 132, 90, 0.1)', - fill: true, - tension: 0.3, - pointRadius: 3, - pointHoverRadius: 5 - }, - { - label: 'Output Tokens', - data: outputData, - borderColor: '#7a9e7e', - backgroundColor: 'rgba(122, 158, 126, 0.1)', - fill: true, - tension: 0.3, - pointRadius: 3, - pointHoverRadius: 5 - } - ] + datasets: datasets }, options: { responsive: true, @@ -182,7 +210,12 @@ const labels = filled.map((d) => d.date); const inputData = filled.map((d) => d.input_tokens); const outputData = filled.map((d) => d.output_tokens); - const config = this._overviewChartConfig(colors, labels, inputData, outputData); + const cacheByDate = {}; + const cacheDaily = this.fillMissingDays(this.cacheOverview && Array.isArray(this.cacheOverview.daily) ? this.cacheOverview.daily : []); + cacheDaily.forEach((d) => { cacheByDate[d.date] = d; }); + const cacheInputData = labels.map((label) => (cacheByDate[label] && cacheByDate[label].input_tokens) || 0); + const cacheOutputData = labels.map((label) => (cacheByDate[label] && cacheByDate[label].output_tokens) || 0); + const config = this._overviewChartConfig(colors, labels, inputData, outputData, cacheInputData, cacheOutputData); if (this.chart) { this.chart.destroy(); diff --git a/internal/admin/dashboard/static/js/modules/execution-plans.js b/internal/admin/dashboard/static/js/modules/execution-plans.js index 18510fd8..24865de1 100644 --- a/internal/admin/dashboard/static/js/modules/execution-plans.js +++ b/internal/admin/dashboard/static/js/modules/execution-plans.js @@ -61,6 +61,7 @@ 'LOGGING_ENABLED', 'USAGE_ENABLED', 'GUARDRAILS_ENABLED', + 'CACHE_ENABLED', 'REDIS_URL', 'SEMANTIC_CACHE_ENABLED' ]; @@ -80,6 +81,10 @@ }, executionPlanCacheVisible() { + const explicit = this.executionPlanRuntimeFlag('CACHE_ENABLED'); + if (explicit !== '') { + return this.executionPlanRuntimeBooleanFlag('CACHE_ENABLED', false); + } const redis = this.executionPlanRuntimeFlag('REDIS_URL'); const semantic = this.executionPlanRuntimeFlag('SEMANTIC_CACHE_ENABLED'); if (redis === '' && semantic === '') { @@ -671,11 +676,32 @@ if (payload && typeof payload === 'object' && !Array.isArray(payload) && payload[key] !== undefined && payload[key] !== null) { next[key] = String(payload[key]).trim(); } - } - this.executionPlanRuntimeConfig = next; - } catch (e) { - console.error('Failed to fetch dashboard config:', e); - this.executionPlanRuntimeConfig = {}; + } + this.executionPlanRuntimeConfig = next; + if (typeof this.fetchCacheOverview === 'function') { + if (this.executionPlanCacheVisible()) { + this.fetchCacheOverview(); + } else { + this.cacheOverview = { + summary: { + total_hits: 0, + exact_hits: 0, + semantic_hits: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_saved_cost: null + }, + daily: [] + }; + if (typeof this.renderChart === 'function') { + this.renderChart(); + } + } + } + } catch (e) { + console.error('Failed to fetch dashboard config:', e); + this.executionPlanRuntimeConfig = {}; } finally { if (timeoutID !== null && typeof clearTimeout === 'function') { clearTimeout(timeoutID); diff --git a/internal/admin/dashboard/static/js/modules/usage.js b/internal/admin/dashboard/static/js/modules/usage.js index 89b6b7b0..84d9cad7 100644 --- a/internal/admin/dashboard/static/js/modules/usage.js +++ b/internal/admin/dashboard/static/js/modules/usage.js @@ -1,6 +1,27 @@ (function(global) { function dashboardUsageModule() { return { + emptyCacheOverview() { + return { + summary: { + total_hits: 0, + exact_hits: 0, + semantic_hits: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_saved_cost: null + }, + daily: [] + }; + }, + + cacheAnalyticsEnabled() { + return typeof this.executionPlanRuntimeBooleanFlag === 'function' + ? this.executionPlanRuntimeBooleanFlag('CACHE_ENABLED', false) + : false; + }, + _usageQueryStr() { if (this.customStartDate && this.customEndDate) { return 'start_date=' + this._formatDate(this.customStartDate) + @@ -9,6 +30,61 @@ return 'days=' + this.days; }, + async fetchCacheOverview() { + if (!this.cacheAnalyticsEnabled()) { + this.cacheOverview = this.emptyCacheOverview(); + if (this.page === 'overview') this.renderChart(); + return; + } + + const controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_cacheOverviewFetchController') + : null; + const options = { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } + + try { + let queryStr; + if (this.customStartDate && this.customEndDate) { + queryStr = 'start_date=' + this._formatDate(this.customStartDate) + + '&end_date=' + this._formatDate(this.customEndDate); + } else { + queryStr = 'days=' + this.days; + } + queryStr += '&interval=' + this.interval; + + const res = await fetch('/admin/api/v1/cache/overview?' + queryStr, options); + if (!this.handleFetchResponse(res, 'cache overview')) { + this.cacheOverview = this.emptyCacheOverview(); + return; + } + const payload = await res.json(); + if (controller && controller.signal.aborted) { + return; + } + this.cacheOverview = payload && typeof payload === 'object' ? payload : this.emptyCacheOverview(); + if (!this.cacheOverview.summary) { + this.cacheOverview.summary = this.emptyCacheOverview().summary; + } + if (!Array.isArray(this.cacheOverview.daily)) { + this.cacheOverview.daily = []; + } + if (this.page === 'overview') this.renderChart(); + } catch (e) { + if (typeof this._isAbortError === 'function' && this._isAbortError(e)) { + return; + } + console.error('Failed to fetch cache overview:', e); + this.cacheOverview = this.emptyCacheOverview(); + } finally { + if (typeof this._clearAbortableRequest === 'function') { + this._clearAbortableRequest('_cacheOverviewFetchController', controller); + } + } + }, + async fetchUsage() { const controller = typeof this._startAbortableRequest === 'function' ? this._startAbortableRequest('_usageFetchController') @@ -48,6 +124,11 @@ this.summary = summary; this.daily = daily; this.renderChart(); + if (this.cacheAnalyticsEnabled()) { + this.fetchCacheOverview(); + } else { + this.cacheOverview = this.emptyCacheOverview(); + } if (this.page === 'usage') this.fetchUsagePage(); if (this.page === 'audit-logs') this.fetchAuditLog(true); } catch (e) { @@ -63,7 +144,11 @@ }, async fetchUsagePage() { - await Promise.all([this.fetchModelUsage(), this.fetchUsageLog(true)]); + const requests = [this.fetchModelUsage(), this.fetchUsageLog(true)]; + if (this.cacheAnalyticsEnabled()) { + requests.push(this.fetchCacheOverview()); + } + await Promise.all(requests); this.renderBarChart(); }, diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index cf43e2e1..e096fa78 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -39,6 +39,18 @@

Usage Overview

Estimated Cost
-
+
+
Cache Hits
+
-
+
+
+
Local Cache Input Tokens
+
-
+
+
+
Local Cache Output Tokens
+
-
+
@@ -127,6 +139,17 @@

Usage Analytics

{{template "auth-banner" .}} +
+
+
Cache Saved
+
-
+
+
+
Cache Hits
+
-
+
+
+

diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 605979d2..3c181575 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -44,6 +44,7 @@ const ( DashboardConfigLoggingEnabled = "LOGGING_ENABLED" DashboardConfigUsageEnabled = "USAGE_ENABLED" DashboardConfigGuardrailsEnabled = "GUARDRAILS_ENABLED" + DashboardConfigCacheEnabled = "CACHE_ENABLED" DashboardConfigRedisURL = "REDIS_URL" DashboardConfigSemanticCacheEnabled = "SEMANTIC_CACHE_ENABLED" ) @@ -54,6 +55,7 @@ type DashboardConfigResponse struct { LoggingEnabled string `json:"LOGGING_ENABLED,omitempty"` UsageEnabled string `json:"USAGE_ENABLED,omitempty"` GuardrailsEnabled string `json:"GUARDRAILS_ENABLED,omitempty"` + CacheEnabled string `json:"CACHE_ENABLED,omitempty"` RedisURL string `json:"REDIS_URL,omitempty"` SemanticCacheEnabled string `json:"SEMANTIC_CACHE_ENABLED,omitempty"` } @@ -124,6 +126,7 @@ func normalizeDashboardRuntimeConfig(values DashboardConfigResponse) DashboardCo LoggingEnabled: strings.TrimSpace(values.LoggingEnabled), UsageEnabled: strings.TrimSpace(values.UsageEnabled), GuardrailsEnabled: strings.TrimSpace(values.GuardrailsEnabled), + CacheEnabled: strings.TrimSpace(values.CacheEnabled), RedisURL: strings.TrimSpace(values.RedisURL), SemanticCacheEnabled: strings.TrimSpace(values.SemanticCacheEnabled), } @@ -160,6 +163,7 @@ func parseUsageParams(c *echo.Context) (usage.UsageQueryParams, error) { if !validIntervals[params.Interval] { params.Interval = "daily" } + params.CacheMode = c.QueryParam("cache_mode") userPath, err := normalizeUserPathQueryParam("user_path", c.QueryParam("user_path")) if err != nil { @@ -419,6 +423,37 @@ func (h *Handler) UsageLog(c *echo.Context) error { return c.JSON(http.StatusOK, result) } +// CacheOverview handles GET /admin/api/v1/cache/overview +func (h *Handler) CacheOverview(c *echo.Context) error { + if strings.TrimSpace(h.runtimeConfig.CacheEnabled) != "on" { + return handleError(c, featureUnavailableError("cache analytics is unavailable")) + } + if h.usageReader == nil { + return c.JSON(http.StatusOK, usage.CacheOverview{ + Daily: []usage.CacheOverviewDaily{}, + }) + } + + params, err := parseUsageParams(c) + if err != nil { + return handleError(c, err) + } + params.CacheMode = usage.CacheModeCached + + overview, err := h.usageReader.GetCacheOverview(c.Request().Context(), params) + if err != nil { + return handleError(c, err) + } + if overview == nil { + overview = &usage.CacheOverview{} + } + if overview.Daily == nil { + overview.Daily = []usage.CacheOverviewDaily{} + } + + return c.JSON(http.StatusOK, overview) +} + // AuditLog handles GET /admin/api/v1/audit/log // // @Summary Get paginated audit log entries diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index 617ceb2b..9b4e213c 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -25,11 +25,13 @@ type mockUsageReader struct { daily []usage.DailyUsage modelUsage []usage.ModelUsage usageLog *usage.UsageLogResult + cacheOverview *usage.CacheOverview lastUsageLog usage.UsageLogParams summaryErr error dailyErr error modelUsageErr error usageLogErr error + cacheErr error } type mockAuditReader struct { @@ -73,6 +75,13 @@ func (m *mockUsageReader) GetUsageLog(_ context.Context, params usage.UsageLogPa return m.usageLog, nil } +func (m *mockUsageReader) GetCacheOverview(_ context.Context, _ usage.UsageQueryParams) (*usage.CacheOverview, error) { + if m.cacheErr != nil { + return nil, m.cacheErr + } + return m.cacheOverview, nil +} + func (m *mockAuditReader) GetLogs(_ context.Context, params auditlog.LogQueryParams) (*auditlog.LogListResult, error) { m.lastQuery = params if m.logErr != nil { @@ -1155,6 +1164,7 @@ func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) { LoggingEnabled: "on", UsageEnabled: "off", GuardrailsEnabled: "on", + CacheEnabled: "on", RedisURL: "on", SemanticCacheEnabled: "off", })) @@ -1183,6 +1193,9 @@ func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) { if got := body.GuardrailsEnabled; got != "on" { t.Fatalf("GUARDRAILS_ENABLED = %q, want on", got) } + if got := body.CacheEnabled; got != "on" { + t.Fatalf("CACHE_ENABLED = %q, want on", got) + } if got := body.RedisURL; got != "on" { t.Fatalf("REDIS_URL = %q, want on", got) } @@ -1194,6 +1207,60 @@ func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) { } } +func TestCacheOverview_FeatureUnavailableWhenCacheDisabled(t *testing.T) { + h := NewHandler(&mockUsageReader{}, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{ + CacheEnabled: "off", + })) + c, rec := newHandlerContext("/admin/api/v1/cache/overview") + + if err := h.CacheOverview(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} + +func TestCacheOverview_ReturnsPayloadWhenEnabled(t *testing.T) { + reader := &mockUsageReader{ + cacheOverview: &usage.CacheOverview{ + Summary: usage.CacheOverviewSummary{ + TotalHits: 4, + ExactHits: 3, + SemanticHits: 1, + TotalInput: 120, + TotalOutput: 60, + TotalTokens: 180, + }, + Daily: []usage.CacheOverviewDaily{ + {Date: "2026-03-31", Hits: 4, ExactHits: 3, SemanticHits: 1, InputTokens: 120, OutputTokens: 60, TotalTokens: 180}, + }, + }, + } + h := NewHandler(reader, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{ + CacheEnabled: "on", + })) + c, rec := newHandlerContext("/admin/api/v1/cache/overview?days=30") + + if err := h.CacheOverview(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body usage.CacheOverview + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if body.Summary.TotalHits != 4 { + t.Fatalf("total_hits = %d, want 4", body.Summary.TotalHits) + } + if len(body.Daily) != 1 || body.Daily[0].ExactHits != 3 { + t.Fatalf("unexpected daily payload: %+v", body.Daily) + } +} + // --- handleError tests --- func TestHandleError_GatewayErrors(t *testing.T) { diff --git a/internal/app/app.go b/internal/app/app.go index 9357ee86..779ae6d6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -338,7 +338,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { slog.Info("provider passthrough disabled") } - rcm, err := responsecache.NewResponseCacheMiddleware(appCfg.Cache.Response, cfg.AppConfig.RawProviders) + rcm, err := responsecache.NewResponseCacheMiddleware(appCfg.Cache.Response, cfg.AppConfig.RawProviders, usageResult.Logger, providerResult.Registry) if err != nil { var ( executionPlansCloseErr error @@ -774,6 +774,7 @@ func dashboardRuntimeConfig(cfg *config.Config) admin.DashboardConfigResponse { LoggingEnabled: dashboardEnabledValue(cfg != nil && cfg.Logging.Enabled), UsageEnabled: dashboardEnabledValue(cfg != nil && cfg.Usage.Enabled), GuardrailsEnabled: dashboardEnabledValue(cfg != nil && cfg.Guardrails.Enabled), + CacheEnabled: dashboardEnabledValue(cfg != nil && responseCacheConfigured(cfg.Cache.Response)), RedisURL: dashboardEnabledValue(simpleResponseCacheConfigured(cfg)), SemanticCacheEnabled: dashboardEnabledValue(semanticResponseCacheConfigured(cfg)), } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6fba2d32..a68bc2e4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -116,6 +116,9 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) { if got := values.GuardrailsEnabled; got != "on" { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigGuardrailsEnabled, got) } + if got := values.CacheEnabled; got != "on" { + t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigCacheEnabled, got) + } if got := values.RedisURL; got != "on" { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigRedisURL, got) } diff --git a/internal/responsecache/handle_request_test.go b/internal/responsecache/handle_request_test.go index 34f9f874..d509c547 100644 --- a/internal/responsecache/handle_request_test.go +++ b/internal/responsecache/handle_request_test.go @@ -13,8 +13,27 @@ import ( "gomodel/internal/auditlog" "gomodel/internal/cache" "gomodel/internal/core" + "gomodel/internal/usage" ) +type recordingUsageLogger struct { + entries []*usage.UsageEntry +} + +func (l *recordingUsageLogger) Write(entry *usage.UsageEntry) { + if entry != nil { + l.entries = append(l.entries, entry) + } +} + +func (l *recordingUsageLogger) Config() usage.Config { + return usage.Config{Enabled: true} +} + +func (l *recordingUsageLogger) Close() error { + return nil +} + func TestHandleRequest_SemanticMissPopulatesExactCache(t *testing.T) { store := cache.NewMapStore() defer store.Close() @@ -29,8 +48,8 @@ func TestHandleRequest_SemanticMissPopulatesExactCache(t *testing.T) { } m := &ResponseCacheMiddleware{ - simple: newSimpleCacheMiddleware(store, time.Hour), - semantic: newSemanticCacheMiddleware(emb, vecStore, semCfg), + simple: newSimpleCacheMiddleware(store, time.Hour, nil), + semantic: newSemanticCacheMiddleware(emb, vecStore, semCfg, nil), } body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"handle-request-exact-backfill"}]}`) @@ -86,8 +105,8 @@ func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { } m := &ResponseCacheMiddleware{ - simple: newSimpleCacheMiddleware(store, time.Hour), - semantic: newSemanticCacheMiddleware(emb, vecStore, semCfg), + simple: newSimpleCacheMiddleware(store, time.Hour, nil), + semantic: newSemanticCacheMiddleware(emb, vecStore, semCfg, nil), } body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"fallback-skip-cache"}]}`) @@ -137,7 +156,7 @@ func TestHandleRequest_ExactHitMarksAuditEntryCacheType(t *testing.T) { defer store.Close() m := &ResponseCacheMiddleware{ - simple: newSimpleCacheMiddleware(store, time.Hour), + simple: newSimpleCacheMiddleware(store, time.Hour, nil), } body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"mark-exact-cache-type"}]}`) @@ -177,3 +196,68 @@ func TestHandleRequest_ExactHitMarksAuditEntryCacheType(t *testing.T) { t.Fatalf("second request CacheType = %q, want %q", entry2.CacheType, auditlog.CacheTypeExact) } } + +func TestHandleRequest_ExactHitWritesSyntheticUsageEntry(t *testing.T) { + store := cache.NewMapStore() + defer store.Close() + + logger := &recordingUsageLogger{} + m := &ResponseCacheMiddleware{ + simple: newSimpleCacheMiddleware(store, time.Hour, newUsageHitRecorder(logger, nil)), + } + + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"cache-usage-hit"}]}`) + e := echo.New() + + run := func() *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + plan := &core.ExecutionPlan{ + Mode: core.ExecutionModeTranslated, + ProviderType: "openai", + Resolution: &core.RequestModelResolution{ + ResolvedSelector: core.ModelSelector{Provider: "openai", Model: "gpt-4"}, + }, + } + c.SetRequest(req.WithContext(core.WithExecutionPlan(req.Context(), plan))) + if err := m.HandleRequest(c, body, func() error { + return c.JSON(http.StatusOK, &core.ChatResponse{ + ID: "chatcmpl-cache-hit", + Model: "gpt-4", + Usage: core.Usage{ + PromptTokens: 11, + CompletionTokens: 5, + TotalTokens: 16, + }, + }) + }); err != nil { + t.Fatalf("HandleRequest: %v", err) + } + return rec + } + + rec1 := run() + if rec1.Header().Get("X-Cache") != "" { + t.Fatalf("first request should miss exact cache, got X-Cache=%q", rec1.Header().Get("X-Cache")) + } + + m.simple.wg.Wait() + + rec2 := run() + if rec2.Header().Get("X-Cache") != "HIT (exact)" { + t.Fatalf("second request should be exact hit, got X-Cache=%q", rec2.Header().Get("X-Cache")) + } + if len(logger.entries) != 1 { + t.Fatalf("expected 1 synthetic usage entry, got %d", len(logger.entries)) + } + entry := logger.entries[0] + if entry.CacheType != usage.CacheTypeExact { + t.Fatalf("CacheType = %q, want %q", entry.CacheType, usage.CacheTypeExact) + } + if entry.InputTokens != 11 || entry.OutputTokens != 5 || entry.TotalTokens != 16 { + t.Fatalf("unexpected tokens: %+v", entry) + } +} diff --git a/internal/responsecache/responsecache.go b/internal/responsecache/responsecache.go index d7c31450..9b66120a 100644 --- a/internal/responsecache/responsecache.go +++ b/internal/responsecache/responsecache.go @@ -10,6 +10,7 @@ import ( "gomodel/config" "gomodel/internal/cache" "gomodel/internal/embedding" + "gomodel/internal/usage" ) const responseCachePrefix = "gomodel:response:" @@ -23,8 +24,14 @@ type ResponseCacheMiddleware struct { // NewResponseCacheMiddleware creates middleware from config. // If neither simple nor semantic cache is configured, returns a no-op middleware. // rawProviders is threaded through to NewEmbedder for API-key credential resolution. -func NewResponseCacheMiddleware(cfg config.ResponseCacheConfig, rawProviders map[string]config.RawProviderConfig) (*ResponseCacheMiddleware, error) { +func NewResponseCacheMiddleware( + cfg config.ResponseCacheConfig, + rawProviders map[string]config.RawProviderConfig, + usageLogger usage.LoggerInterface, + pricingResolver usage.PricingResolver, +) (*ResponseCacheMiddleware, error) { m := &ResponseCacheMiddleware{} + hitRecorder := newUsageHitRecorder(usageLogger, pricingResolver) if cfg.Simple.Redis != nil && cfg.Simple.Redis.URL != "" { ttl := time.Duration(cfg.Simple.Redis.TTL) * time.Second @@ -43,7 +50,7 @@ func NewResponseCacheMiddleware(cfg config.ResponseCacheConfig, rawProviders map if err != nil { return nil, err } - m.simple = newSimpleCacheMiddleware(store, ttl) + m.simple = newSimpleCacheMiddleware(store, ttl, hitRecorder) slog.Info("response cache (simple/exact) enabled", "ttl_seconds", cfg.Simple.Redis.TTL, "prefix", prefix) } else { slog.Warn("response cache (simple/exact) is disabled; set cache.response.simple.redis.url to enable it") @@ -60,7 +67,7 @@ func NewResponseCacheMiddleware(cfg config.ResponseCacheConfig, rawProviders map _ = emb.Close() return nil, err } - m.semantic = newSemanticCacheMiddleware(emb, vs, sem) + m.semantic = newSemanticCacheMiddleware(emb, vs, sem, hitRecorder) slog.Info("response cache (semantic) enabled", "threshold", sem.SimilarityThreshold, "ttl_seconds", sem.TTL, @@ -141,6 +148,6 @@ func (m *ResponseCacheMiddleware) Close() error { // NewResponseCacheMiddlewareWithStore creates middleware with a custom store (for testing). func NewResponseCacheMiddlewareWithStore(store cache.Store, ttl time.Duration) *ResponseCacheMiddleware { return &ResponseCacheMiddleware{ - simple: newSimpleCacheMiddleware(store, ttl), + simple: newSimpleCacheMiddleware(store, ttl, nil), } } diff --git a/internal/responsecache/semantic.go b/internal/responsecache/semantic.go index 8083237f..8dae23e3 100644 --- a/internal/responsecache/semantic.go +++ b/internal/responsecache/semantic.go @@ -34,15 +34,17 @@ type semanticCacheMiddleware struct { cfg config.SemanticCacheConfig embedderIdentity string wg sync.WaitGroup + hitRecorder func(*echo.Context, []byte, string) } -func newSemanticCacheMiddleware(emb embedding.Embedder, store VecStore, cfg config.SemanticCacheConfig) *semanticCacheMiddleware { +func newSemanticCacheMiddleware(emb embedding.Embedder, store VecStore, cfg config.SemanticCacheConfig, hitRecorder func(*echo.Context, []byte, string)) *semanticCacheMiddleware { e := cfg.Embedder return &semanticCacheMiddleware{ embedder: emb, store: store, cfg: cfg, embedderIdentity: e.Provider + "\x00" + e.Model + "\x00" + e.ModelPath, + hitRecorder: hitRecorder, } } @@ -108,6 +110,9 @@ func (m *semanticCacheMiddleware) Handle(c *echo.Context, body []byte, next func c.Response().Header().Set("X-Cache", "HIT (semantic)") c.Response().WriteHeader(http.StatusOK) _, _ = c.Response().Write(results[0].Response) + if m.hitRecorder != nil { + m.hitRecorder(c, results[0].Response, CacheTypeSemantic) + } slog.Info("semantic cache hit", "path", path, "score", results[0].Score, diff --git a/internal/responsecache/semantic_test.go b/internal/responsecache/semantic_test.go index af9f1b33..081460b7 100644 --- a/internal/responsecache/semantic_test.go +++ b/internal/responsecache/semantic_test.go @@ -40,7 +40,7 @@ func newTestSemanticMiddleware(threshold float64, maxConvMessages int, excludeSy MaxConversationMessages: maxConvMessages, ExcludeSystemPrompt: excludeSystem, } - m := newSemanticCacheMiddleware(emb, store, cfg) + m := newSemanticCacheMiddleware(emb, store, cfg, nil) return m, store, emb } @@ -163,7 +163,7 @@ func TestSemanticCacheMiddleware_CacheMissOnLowScore(t *testing.T) { SimilarityThreshold: 0.99, TTL: 3600, MaxConversationMessages: 10, - }) + }, nil) body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}`) @@ -334,7 +334,7 @@ func TestSemanticCacheMiddleware_HeaderThresholdOverride(t *testing.T) { SimilarityThreshold: 0.99, TTL: 3600, MaxConversationMessages: 10, - }) + }, nil) body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}`) @@ -370,7 +370,7 @@ func TestSemanticCacheMiddleware_TTLExpiry(t *testing.T) { SimilarityThreshold: 0.90, TTL: 1, MaxConversationMessages: 10, - }) + }, nil) body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"expiry test"}]}`) serveSemanticRequest(t, m, body, "") diff --git a/internal/responsecache/simple.go b/internal/responsecache/simple.go index ea91b2f7..569cfad4 100644 --- a/internal/responsecache/simple.go +++ b/internal/responsecache/simple.go @@ -42,16 +42,19 @@ type simpleCacheMiddleware struct { wg sync.WaitGroup jobs chan cacheWriteJob + hitRecorder func(*echo.Context, []byte, string) + workers sync.WaitGroup mu sync.RWMutex closed bool } -func newSimpleCacheMiddleware(store cache.Store, ttl time.Duration) *simpleCacheMiddleware { +func newSimpleCacheMiddleware(store cache.Store, ttl time.Duration, hitRecorder func(*echo.Context, []byte, string)) *simpleCacheMiddleware { m := &simpleCacheMiddleware{ - store: store, - ttl: ttl, - jobs: make(chan cacheWriteJob, cacheWriteQueueSize), + store: store, + ttl: ttl, + jobs: make(chan cacheWriteJob, cacheWriteQueueSize), + hitRecorder: hitRecorder, } m.startWorkers() return m @@ -112,6 +115,9 @@ func (m *simpleCacheMiddleware) TryHit(c *echo.Context, body []byte) (bool, erro c.Response().Header().Set("X-Cache", "HIT (exact)") c.Response().WriteHeader(http.StatusOK) _, _ = c.Response().Write(cached) + if m.hitRecorder != nil { + m.hitRecorder(c, cached, CacheTypeExact) + } slog.Info("response cache hit (exact)", "path", path, "request_id", c.Request().Header.Get("X-Request-ID"), diff --git a/internal/responsecache/usage_hit.go b/internal/responsecache/usage_hit.go new file mode 100644 index 00000000..4d22c629 --- /dev/null +++ b/internal/responsecache/usage_hit.go @@ -0,0 +1,60 @@ +package responsecache + +import ( + "log/slog" + "strings" + + "github.com/labstack/echo/v5" + + "gomodel/internal/core" + "gomodel/internal/usage" +) + +func newUsageHitRecorder(logger usage.LoggerInterface, pricingResolver usage.PricingResolver) func(*echo.Context, []byte, string) { + if logger == nil || !logger.Config().Enabled { + return nil + } + + return func(c *echo.Context, body []byte, cacheType string) { + if c == nil { + return + } + + ctx := c.Request().Context() + plan := core.GetExecutionPlan(ctx) + if plan != nil && !plan.UsageEnabled() { + return + } + + model := "" + provider := "" + if plan != nil { + provider = strings.TrimSpace(plan.ProviderType) + if plan.Resolution != nil { + model = strings.TrimSpace(plan.Resolution.ResolvedSelector.Model) + } + } + if provider == "" { + slog.Debug("cache hit usage skipped: missing provider type") + return + } + + endpoint := c.Request().URL.Path + requestID := core.GetRequestID(ctx) + if requestID == "" { + requestID = c.Request().Header.Get("X-Request-ID") + } + + var pricing *core.ModelPricing + if pricingResolver != nil { + pricing = pricingResolver.ResolvePricing(model, provider) + } + + entry := usage.ExtractFromCachedResponseBody(body, requestID, model, provider, endpoint, cacheType, pricing) + if entry == nil { + return + } + entry.UserPath = core.UserPathFromContext(ctx) + logger.Write(entry) + } +} diff --git a/internal/server/http.go b/internal/server/http.go index 067d5fc1..602577f3 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -277,6 +277,7 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { if cfg != nil && cfg.AdminEndpointsEnabled && cfg.AdminHandler != nil { adminAPI := e.Group("/admin/api/v1") adminAPI.GET("/dashboard/config", cfg.AdminHandler.DashboardConfig) + adminAPI.GET("/cache/overview", cfg.AdminHandler.CacheOverview) adminAPI.GET("/usage/summary", cfg.AdminHandler.UsageSummary) adminAPI.GET("/usage/daily", cfg.AdminHandler.DailyUsage) adminAPI.GET("/usage/models", cfg.AdminHandler.UsageByModel) diff --git a/internal/usage/cache_type.go b/internal/usage/cache_type.go new file mode 100644 index 00000000..7a919db9 --- /dev/null +++ b/internal/usage/cache_type.go @@ -0,0 +1,41 @@ +package usage + +import "strings" + +const ( + CacheTypeExact = "exact" + CacheTypeSemantic = "semantic" + + CacheModeUncached = "uncached" + CacheModeCached = "cached" + CacheModeAll = "all" +) + +func normalizeCacheType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case CacheTypeExact: + return CacheTypeExact + case CacheTypeSemantic: + return CacheTypeSemantic + default: + return "" + } +} + +func normalizeCacheMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case CacheModeCached: + return CacheModeCached + case CacheModeAll: + return CacheModeAll + default: + return CacheModeUncached + } +} + +func cacheTypeValue(value string) any { + if normalized := normalizeCacheType(value); normalized != "" { + return normalized + } + return nil +} diff --git a/internal/usage/extractor.go b/internal/usage/extractor.go index ee30f8ab..8a78e649 100644 --- a/internal/usage/extractor.go +++ b/internal/usage/extractor.go @@ -1,6 +1,7 @@ package usage import ( + "encoding/json" "maps" "strings" "time" @@ -232,6 +233,62 @@ func ExtractFromSSEUsage( return entry } +// ExtractFromCachedResponseBody converts a cached OpenAI-compatible response body into +// a synthetic usage entry for a cache hit. If the response body cannot be parsed, it +// still returns a minimal zero-token entry so cache-hit counts remain observable. +func ExtractFromCachedResponseBody( + body []byte, + requestID, model, provider, endpoint, cacheType string, + pricing ...*core.ModelPricing, +) *UsageEntry { + cacheType = normalizeCacheType(cacheType) + if cacheType == "" { + cacheType = CacheTypeExact + } + + var entry *UsageEntry + switch endpoint { + case "/v1/chat/completions": + var resp core.ChatResponse + if err := json.Unmarshal(body, &resp); err == nil { + entry = ExtractFromChatResponse(&resp, requestID, provider, endpoint, pricing...) + } + case "/v1/responses": + var resp core.ResponsesResponse + if err := json.Unmarshal(body, &resp); err == nil { + entry = ExtractFromResponsesResponse(&resp, requestID, provider, endpoint, pricing...) + } + case "/v1/embeddings": + var resp core.EmbeddingResponse + if err := json.Unmarshal(body, &resp); err == nil { + entry = ExtractFromEmbeddingResponse(&resp, requestID, provider, endpoint, pricing...) + } + } + + if entry == nil { + entry = &UsageEntry{ + ID: uuid.New().String(), + RequestID: requestID, + Timestamp: time.Now().UTC(), + Model: model, + Provider: provider, + Endpoint: endpoint, + CacheType: cacheType, + ProviderID: "", + } + return entry + } + + entry.CacheType = cacheType + if strings.TrimSpace(entry.Model) == "" { + entry.Model = strings.TrimSpace(model) + } + if strings.TrimSpace(entry.Provider) == "" { + entry.Provider = strings.TrimSpace(provider) + } + return entry +} + func pricingForEndpoint(pricing *core.ModelPricing, endpoint string) *core.ModelPricing { if pricing == nil { return nil diff --git a/internal/usage/extractor_test.go b/internal/usage/extractor_test.go index 471bed11..c19e6ee5 100644 --- a/internal/usage/extractor_test.go +++ b/internal/usage/extractor_test.go @@ -1,6 +1,7 @@ package usage import ( + "encoding/json" "math" "testing" @@ -480,6 +481,33 @@ func TestExtractFromSSEUsageEmptyRawData(t *testing.T) { } } +func TestExtractFromCachedResponseBody(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-cache", + Model: "gpt-4o", + Usage: core.Usage{ + PromptTokens: 42, + CompletionTokens: 18, + TotalTokens: 60, + }, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + entry := ExtractFromCachedResponseBody(body, "req-cache", "gpt-4o", "openai", "/v1/chat/completions", CacheTypeExact) + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.CacheType != CacheTypeExact { + t.Fatalf("CacheType = %q, want %q", entry.CacheType, CacheTypeExact) + } + if entry.InputTokens != 42 || entry.OutputTokens != 18 || entry.TotalTokens != 60 { + t.Fatalf("unexpected token counts: %+v", entry) + } +} + func TestExtractFromChatResponse_WithBatchPricingEndpoint(t *testing.T) { pricing := &core.ModelPricing{ InputPerMtok: new(4.0), diff --git a/internal/usage/reader.go b/internal/usage/reader.go index 3ba814e0..0d42a824 100644 --- a/internal/usage/reader.go +++ b/internal/usage/reader.go @@ -12,6 +12,7 @@ type UsageQueryParams struct { Interval string // "daily", "weekly", "monthly", "yearly" TimeZone string // IANA timezone used for day-boundary interpretation and grouping UserPath string // subtree filter on tracked user path + CacheMode string // "uncached" (default), "cached", or "all" } // UsageSummary holds aggregated usage statistics over a time period. @@ -70,6 +71,7 @@ type UsageLogEntry struct { Provider string `json:"provider"` Endpoint string `json:"endpoint"` UserPath string `json:"user_path,omitempty"` + CacheType string `json:"cache_type,omitempty"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` @@ -88,6 +90,35 @@ type UsageLogResult struct { Offset int `json:"offset"` } +// CacheOverviewSummary holds cached-only aggregate statistics over a time period. +type CacheOverviewSummary struct { + TotalHits int `json:"total_hits"` + ExactHits int `json:"exact_hits"` + SemanticHits int `json:"semantic_hits"` + TotalInput int64 `json:"total_input_tokens"` + TotalOutput int64 `json:"total_output_tokens"` + TotalTokens int64 `json:"total_tokens"` + TotalSavedCost *float64 `json:"total_saved_cost"` +} + +// CacheOverviewDaily holds cached-only statistics for a single period. +type CacheOverviewDaily struct { + Date string `json:"date"` + Hits int `json:"hits"` + ExactHits int `json:"exact_hits"` + SemanticHits int `json:"semantic_hits"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + SavedCost *float64 `json:"saved_cost"` +} + +// CacheOverview aggregates cached-only summary and daily series for the dashboard. +type CacheOverview struct { + Summary CacheOverviewSummary `json:"summary"` + Daily []CacheOverviewDaily `json:"daily"` +} + // UsageReader provides read access to usage data for the admin API. type UsageReader interface { // GetSummary returns aggregated usage statistics for the given date range. @@ -103,4 +134,7 @@ type UsageReader interface { // GetUsageLog returns a paginated list of individual usage entries with optional filtering. GetUsageLog(ctx context.Context, params UsageLogParams) (*UsageLogResult, error) + + // GetCacheOverview returns cached-only aggregates for the admin dashboard. + GetCacheOverview(ctx context.Context, params UsageQueryParams) (*CacheOverview, error) } diff --git a/internal/usage/reader_mongodb.go b/internal/usage/reader_mongodb.go index 02318f35..ea47b85d 100644 --- a/internal/usage/reader_mongodb.go +++ b/internal/usage/reader_mongodb.go @@ -25,20 +25,9 @@ func NewMongoDBReader(database *mongo.Database) (*MongoDBReader, error) { // GetSummary returns aggregated usage statistics for the given query parameters. func (r *MongoDBReader) GetSummary(ctx context.Context, params UsageQueryParams) (*UsageSummary, error) { pipeline := bson.A{} - matchFilters := bson.D{} - - if tsFilter := mongoDateRangeFilter(params); tsFilter != nil { - matchFilters = append(matchFilters, bson.E{Key: "timestamp", Value: tsFilter}) - } - if userPath, err := normalizeUsageUserPathFilter(params.UserPath); err != nil { + matchFilters, err := mongoUsageMatchFilters(params) + if err != nil { return nil, err - } else if userPath != "" { - matchFilters = append(matchFilters, bson.E{ - Key: "user_path", - Value: bson.D{ - {Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)}, - }, - }) } if len(matchFilters) > 0 { pipeline = append(pipeline, bson.D{{Key: "$match", Value: matchFilters}}) @@ -98,20 +87,9 @@ func (r *MongoDBReader) GetSummary(ctx context.Context, params UsageQueryParams) // GetUsageByModel returns token and cost totals grouped by model and provider. func (r *MongoDBReader) GetUsageByModel(ctx context.Context, params UsageQueryParams) ([]ModelUsage, error) { pipeline := bson.A{} - matchFilters := bson.D{} - - if tsFilter := mongoDateRangeFilter(params); tsFilter != nil { - matchFilters = append(matchFilters, bson.E{Key: "timestamp", Value: tsFilter}) - } - if userPath, err := normalizeUsageUserPathFilter(params.UserPath); err != nil { + matchFilters, err := mongoUsageMatchFilters(params) + if err != nil { return nil, err - } else if userPath != "" { - matchFilters = append(matchFilters, bson.E{ - Key: "user_path", - Value: bson.D{ - {Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)}, - }, - }) } if len(matchFilters) > 0 { pipeline = append(pipeline, bson.D{{Key: "$match", Value: matchFilters}}) @@ -178,36 +156,9 @@ func (r *MongoDBReader) GetUsageByModel(ctx context.Context, params UsageQueryPa func (r *MongoDBReader) GetUsageLog(ctx context.Context, params UsageLogParams) (*UsageLogResult, error) { limit, offset := clampLimitOffset(params.Limit, params.Offset) - matchFilters := bson.D{} - - if tsFilter := mongoDateRangeFilter(params.UsageQueryParams); tsFilter != nil { - matchFilters = append(matchFilters, bson.E{Key: "timestamp", Value: tsFilter}) - } - - if params.Model != "" { - matchFilters = append(matchFilters, bson.E{Key: "model", Value: params.Model}) - } - if params.Provider != "" { - matchFilters = append(matchFilters, bson.E{Key: "provider", Value: params.Provider}) - } - if userPath, err := normalizeUsageUserPathFilter(params.UserPath); err != nil { + matchFilters, err := mongoUsageLogMatchFilters(params) + if err != nil { return nil, err - } else if userPath != "" { - matchFilters = append(matchFilters, bson.E{ - Key: "user_path", - Value: bson.D{ - {Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)}, - }, - }) - } - if params.Search != "" { - regex := bson.D{{Key: "$regex", Value: params.Search}, {Key: "$options", Value: "i"}} - matchFilters = append(matchFilters, bson.E{Key: "$or", Value: bson.A{ - bson.D{{Key: "model", Value: regex}}, - bson.D{{Key: "provider", Value: regex}}, - bson.D{{Key: "request_id", Value: regex}}, - bson.D{{Key: "provider_id", Value: regex}}, - }}) } pipeline := bson.A{} @@ -242,6 +193,7 @@ func (r *MongoDBReader) GetUsageLog(ctx context.Context, params UsageLogParams) Provider string `bson:"provider"` Endpoint string `bson:"endpoint"` UserPath string `bson:"user_path"` + CacheType string `bson:"cache_type"` InputTokens int `bson:"input_tokens"` OutputTokens int `bson:"output_tokens"` TotalTokens int `bson:"total_tokens"` @@ -282,6 +234,7 @@ func (r *MongoDBReader) GetUsageLog(ctx context.Context, params UsageLogParams) Provider: row.Provider, Endpoint: row.Endpoint, UserPath: row.UserPath, + CacheType: normalizeCacheType(row.CacheType), InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, TotalTokens: row.TotalTokens, @@ -340,20 +293,9 @@ func (r *MongoDBReader) GetDailyUsage(ctx context.Context, params UsageQueryPara } pipeline := bson.A{} - matchFilters := bson.D{} - - if tsFilter := mongoDateRangeFilter(params); tsFilter != nil { - matchFilters = append(matchFilters, bson.E{Key: "timestamp", Value: tsFilter}) - } - if userPath, err := normalizeUsageUserPathFilter(params.UserPath); err != nil { + matchFilters, err := mongoUsageMatchFilters(params) + if err != nil { return nil, err - } else if userPath != "" { - matchFilters = append(matchFilters, bson.E{ - Key: "user_path", - Value: bson.D{ - {Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)}, - }, - }) } if len(matchFilters) > 0 { pipeline = append(pipeline, bson.D{{Key: "$match", Value: matchFilters}}) @@ -423,3 +365,220 @@ func (r *MongoDBReader) GetDailyUsage(ctx context.Context, params UsageQueryPara return result, nil } + +// GetCacheOverview returns cached-only aggregates for the admin dashboard. +func (r *MongoDBReader) GetCacheOverview(ctx context.Context, params UsageQueryParams) (*CacheOverview, error) { + params.CacheMode = CacheModeCached + + matchFilters, err := mongoUsageMatchFilters(params) + if err != nil { + return nil, err + } + + summaryPipeline := bson.A{} + if len(matchFilters) > 0 { + summaryPipeline = append(summaryPipeline, bson.D{{Key: "$match", Value: matchFilters}}) + } + summaryPipeline = append(summaryPipeline, bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: nil}, + {Key: "total_hits", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, + {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, + {Key: "total_input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, + {Key: "total_output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, + {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, + {Key: "total_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, + {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, + }}}) + + overview := &CacheOverview{Daily: []CacheOverviewDaily{}} + cursor, err := r.collection.Aggregate(ctx, summaryPipeline) + if err != nil { + return nil, fmt.Errorf("failed to aggregate cache overview summary: %w", err) + } + defer cursor.Close(ctx) + + if cursor.Next(ctx) { + var row struct { + TotalHits int `bson:"total_hits"` + ExactHits int `bson:"exact_hits"` + SemanticHits int `bson:"semantic_hits"` + TotalInput int64 `bson:"total_input_tokens"` + TotalOutput int64 `bson:"total_output_tokens"` + TotalTokens int64 `bson:"total_tokens"` + TotalSavedCost float64 `bson:"total_saved_cost"` + HasSavedCost int `bson:"has_saved_cost"` + } + if err := cursor.Decode(&row); err != nil { + return nil, fmt.Errorf("failed to decode cache overview summary: %w", err) + } + overview.Summary = CacheOverviewSummary{ + TotalHits: row.TotalHits, + ExactHits: row.ExactHits, + SemanticHits: row.SemanticHits, + TotalInput: row.TotalInput, + TotalOutput: row.TotalOutput, + TotalTokens: row.TotalTokens, + } + if row.HasSavedCost > 0 { + overview.Summary.TotalSavedCost = &row.TotalSavedCost + } + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache overview summary cursor: %w", err) + } + + interval := params.Interval + if interval == "" { + interval = "daily" + } + dailyPipeline := bson.A{} + if len(matchFilters) > 0 { + dailyPipeline = append(dailyPipeline, bson.D{{Key: "$match", Value: matchFilters}}) + } + dailyPipeline = append(dailyPipeline, + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{{Key: "$dateToString", Value: bson.D{ + {Key: "format", Value: mongoDateFormat(interval)}, + {Key: "date", Value: "$timestamp"}, + {Key: "timezone", Value: usageTimeZone(params)}, + }}}}, + {Key: "hits", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, + {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, + {Key: "input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, + {Key: "output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, + {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, + {Key: "saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, + {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, + }}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + ) + + cursor, err = r.collection.Aggregate(ctx, dailyPipeline) + if err != nil { + return nil, fmt.Errorf("failed to aggregate cache overview daily: %w", err) + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var row struct { + Date string `bson:"_id"` + Hits int `bson:"hits"` + ExactHits int `bson:"exact_hits"` + SemanticHits int `bson:"semantic_hits"` + InputTokens int64 `bson:"input_tokens"` + OutputTokens int64 `bson:"output_tokens"` + TotalTokens int64 `bson:"total_tokens"` + SavedCost float64 `bson:"saved_cost"` + HasSavedCost int `bson:"has_saved_cost"` + } + if err := cursor.Decode(&row); err != nil { + return nil, fmt.Errorf("failed to decode cache overview daily row: %w", err) + } + entry := CacheOverviewDaily{ + Date: row.Date, + Hits: row.Hits, + ExactHits: row.ExactHits, + SemanticHits: row.SemanticHits, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + TotalTokens: row.TotalTokens, + } + if row.HasSavedCost > 0 { + entry.SavedCost = &row.SavedCost + } + overview.Daily = append(overview.Daily, entry) + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache overview daily cursor: %w", err) + } + + return overview, nil +} + +func mongoUsageMatchFilters(params UsageQueryParams) (bson.D, error) { + matchFilters := bson.D{} + if tsFilter := mongoDateRangeFilter(params); tsFilter != nil { + matchFilters = append(matchFilters, bson.E{Key: "timestamp", Value: tsFilter}) + } + userPath, err := normalizeUsageUserPathFilter(params.UserPath) + if err != nil { + return nil, err + } + if userPath != "" { + matchFilters = append(matchFilters, bson.E{ + Key: "user_path", + Value: bson.D{ + {Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)}, + }, + }) + } + if filter := mongoCacheModeFilter(params.CacheMode); len(filter) > 0 { + matchFilters = append(matchFilters, filter...) + } + return matchFilters, nil +} + +func mongoUsageLogMatchFilters(params UsageLogParams) (bson.D, error) { + matchFilters, err := mongoUsageMatchFilters(params.UsageQueryParams) + if err != nil { + return nil, err + } + + if params.Model != "" { + matchFilters = append(matchFilters, bson.E{Key: "model", Value: params.Model}) + } + if params.Provider != "" { + matchFilters = append(matchFilters, bson.E{Key: "provider", Value: params.Provider}) + } + if params.Search != "" { + regex := bson.D{{Key: "$regex", Value: params.Search}, {Key: "$options", Value: "i"}} + searchFilter := bson.D{{Key: "$or", Value: bson.A{ + bson.D{{Key: "model", Value: regex}}, + bson.D{{Key: "provider", Value: regex}}, + bson.D{{Key: "request_id", Value: regex}}, + bson.D{{Key: "provider_id", Value: regex}}, + }}} + matchFilters = mongoAndFilters(matchFilters, searchFilter) + } + + return matchFilters, nil +} + +func mongoAndFilters(filters ...bson.D) bson.D { + nonEmpty := make([]bson.D, 0, len(filters)) + for _, filter := range filters { + if len(filter) > 0 { + nonEmpty = append(nonEmpty, filter) + } + } + + switch len(nonEmpty) { + case 0: + return nil + case 1: + return nonEmpty[0] + default: + clauses := make(bson.A, 0, len(nonEmpty)) + for _, filter := range nonEmpty { + clauses = append(clauses, filter) + } + return bson.D{{Key: "$and", Value: clauses}} + } +} + +func mongoCacheModeFilter(mode string) bson.D { + switch normalizeCacheMode(mode) { + case CacheModeCached: + return bson.D{{Key: "cache_type", Value: bson.D{{Key: "$in", Value: bson.A{CacheTypeExact, CacheTypeSemantic}}}}} + case CacheModeAll: + return nil + default: + return bson.D{{Key: "$or", Value: bson.A{ + bson.D{{Key: "cache_type", Value: bson.D{{Key: "$exists", Value: false}}}}, + bson.D{{Key: "cache_type", Value: nil}}, + bson.D{{Key: "cache_type", Value: ""}}, + }}} + } +} diff --git a/internal/usage/reader_mongodb_test.go b/internal/usage/reader_mongodb_test.go new file mode 100644 index 00000000..2a0e99ba --- /dev/null +++ b/internal/usage/reader_mongodb_test.go @@ -0,0 +1,39 @@ +package usage + +import ( + "reflect" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestMongoUsageLogMatchFiltersAndsSearchWithCacheMode(t *testing.T) { + got, err := mongoUsageLogMatchFilters(UsageLogParams{ + UsageQueryParams: UsageQueryParams{ + CacheMode: CacheModeUncached, + }, + Search: "gpt", + }) + if err != nil { + t.Fatalf("mongoUsageLogMatchFilters() error = %v", err) + } + + regex := bson.D{{Key: "$regex", Value: "gpt"}, {Key: "$options", Value: "i"}} + want := bson.D{{Key: "$and", Value: bson.A{ + bson.D{{Key: "$or", Value: bson.A{ + bson.D{{Key: "cache_type", Value: bson.D{{Key: "$exists", Value: false}}}}, + bson.D{{Key: "cache_type", Value: nil}}, + bson.D{{Key: "cache_type", Value: ""}}, + }}}, + bson.D{{Key: "$or", Value: bson.A{ + bson.D{{Key: "model", Value: regex}}, + bson.D{{Key: "provider", Value: regex}}, + bson.D{{Key: "request_id", Value: regex}}, + bson.D{{Key: "provider_id", Value: regex}}, + }}}, + }}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("mongoUsageLogMatchFilters() = %#v, want %#v", got, want) + } +} diff --git a/internal/usage/reader_postgresql.go b/internal/usage/reader_postgresql.go index ccf8bb2b..2dd7b285 100644 --- a/internal/usage/reader_postgresql.go +++ b/internal/usage/reader_postgresql.go @@ -25,15 +25,10 @@ func NewPostgreSQLReader(pool *pgxpool.Pool) (*PostgreSQLReader, error) { // GetSummary returns aggregated usage statistics for the given query parameters. func (r *PostgreSQLReader) GetSummary(ctx context.Context, params UsageQueryParams) (*UsageSummary, error) { - conditions, args, nextIdx := pgDateRangeConditions(params, 1) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, _, err := pgUsageConditions(params, 1) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, fmt.Sprintf("(user_path = $%d OR user_path LIKE $%d ESCAPE '\\')", nextIdx, nextIdx+1)) - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -54,15 +49,10 @@ func (r *PostgreSQLReader) GetSummary(ctx context.Context, params UsageQueryPara // GetUsageByModel returns token and cost totals grouped by model and provider. func (r *PostgreSQLReader) GetUsageByModel(ctx context.Context, params UsageQueryParams) ([]ModelUsage, error) { - conditions, args, nextIdx := pgDateRangeConditions(params, 1) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, _, err := pgUsageConditions(params, 1) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, fmt.Sprintf("(user_path = $%d OR user_path LIKE $%d ESCAPE '\\')", nextIdx, nextIdx+1)) - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -95,8 +85,7 @@ func (r *PostgreSQLReader) GetUsageByModel(ctx context.Context, params UsageQuer func (r *PostgreSQLReader) GetUsageLog(ctx context.Context, params UsageLogParams) (*UsageLogResult, error) { limit, offset := clampLimitOffset(params.Limit, params.Offset) - conditions, args, argIdx := pgDateRangeConditions(params.UsageQueryParams, 1) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, argIdx, err := pgUsageConditions(params.UsageQueryParams, 1) if err != nil { return nil, err } @@ -111,11 +100,6 @@ func (r *PostgreSQLReader) GetUsageLog(ctx context.Context, params UsageLogParam args = append(args, params.Provider) argIdx++ } - if userPath != "" { - conditions = append(conditions, fmt.Sprintf("(user_path = $%d OR user_path LIKE $%d ESCAPE '\\')", argIdx, argIdx+1)) - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - argIdx += 2 - } if params.Search != "" { s := "%" + escapeLikeWildcards(params.Search) + "%" conditions = append(conditions, fmt.Sprintf("(model ILIKE $%d ESCAPE '\\' OR provider ILIKE $%d ESCAPE '\\' OR request_id ILIKE $%d ESCAPE '\\' OR provider_id ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx, argIdx)) @@ -133,7 +117,7 @@ func (r *PostgreSQLReader) GetUsageLog(ctx context.Context, params UsageLogParam } // Fetch page - dataQuery := fmt.Sprintf(`SELECT id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, + dataQuery := fmt.Sprintf(`SELECT id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, cache_type, input_tokens, output_tokens, total_tokens, COALESCE(input_cost, 0), COALESCE(output_cost, 0), COALESCE(total_cost, 0), raw_data, COALESCE(costs_calculation_caveat, '') FROM "usage"%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1) dataArgs := append(append([]any(nil), args...), limit, offset) @@ -149,7 +133,8 @@ func (r *PostgreSQLReader) GetUsageLog(ctx context.Context, params UsageLogParam var e UsageLogEntry var rawDataJSON *string var userPath *string - if err := rows.Scan(&e.ID, &e.RequestID, &e.ProviderID, &e.Timestamp, &e.Model, &e.Provider, &e.Endpoint, &userPath, + var cacheType *string + if err := rows.Scan(&e.ID, &e.RequestID, &e.ProviderID, &e.Timestamp, &e.Model, &e.Provider, &e.Endpoint, &userPath, &cacheType, &e.InputTokens, &e.OutputTokens, &e.TotalTokens, &e.InputCost, &e.OutputCost, &e.TotalCost, &rawDataJSON, &e.CostsCalculationCaveat); err != nil { return nil, fmt.Errorf("failed to scan usage log row: %w", err) } @@ -161,6 +146,9 @@ func (r *PostgreSQLReader) GetUsageLog(ctx context.Context, params UsageLogParam if userPath != nil { e.UserPath = *userPath } + if cacheType != nil { + e.CacheType = normalizeCacheType(*cacheType) + } entries = append(entries, e) } @@ -216,15 +204,10 @@ func (r *PostgreSQLReader) GetDailyUsage(ctx context.Context, params UsageQueryP } groupExpr := pgGroupExpr(interval, usageTimeZone(params)) - conditions, args, nextIdx := pgDateRangeConditions(params, 1) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, _, err := pgUsageConditions(params, 1) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, fmt.Sprintf("(user_path = $%d OR user_path LIKE $%d ESCAPE '\\')", nextIdx, nextIdx+1)) - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -253,6 +236,102 @@ func (r *PostgreSQLReader) GetDailyUsage(ctx context.Context, params UsageQueryP return result, nil } +// GetCacheOverview returns cached-only aggregates for the admin dashboard. +func (r *PostgreSQLReader) GetCacheOverview(ctx context.Context, params UsageQueryParams) (*CacheOverview, error) { + params.CacheMode = CacheModeCached + + conditions, args, _, err := pgUsageConditions(params, 1) + if err != nil { + return nil, err + } + where := buildWhereClause(conditions) + + summaryQuery := `SELECT COUNT(*), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeExact + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeSemantic + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(total_tokens), 0), + SUM(total_cost) + FROM "usage"` + where + + overview := &CacheOverview{} + if err := r.pool.QueryRow(ctx, summaryQuery, args...).Scan( + &overview.Summary.TotalHits, + &overview.Summary.ExactHits, + &overview.Summary.SemanticHits, + &overview.Summary.TotalInput, + &overview.Summary.TotalOutput, + &overview.Summary.TotalTokens, + &overview.Summary.TotalSavedCost, + ); err != nil { + return nil, fmt.Errorf("failed to query cache overview summary: %w", err) + } + + interval := params.Interval + if interval == "" { + interval = "daily" + } + groupExpr := pgGroupExpr(interval, usageTimeZone(params)) + dailyQuery := fmt.Sprintf(`SELECT %s as period, + COUNT(*), + COALESCE(SUM(CASE WHEN cache_type = '%s' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = '%s' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(total_tokens), 0), + SUM(total_cost) + FROM "usage"%s GROUP BY %s ORDER BY period`, groupExpr, CacheTypeExact, CacheTypeSemantic, where, groupExpr) + + rows, err := r.pool.Query(ctx, dailyQuery, args...) + if err != nil { + return nil, fmt.Errorf("failed to query cache overview daily: %w", err) + } + defer rows.Close() + + overview.Daily = make([]CacheOverviewDaily, 0) + for rows.Next() { + var d CacheOverviewDaily + if err := rows.Scan(&d.Date, &d.Hits, &d.ExactHits, &d.SemanticHits, &d.InputTokens, &d.OutputTokens, &d.TotalTokens, &d.SavedCost); err != nil { + return nil, fmt.Errorf("failed to scan cache overview daily row: %w", err) + } + overview.Daily = append(overview.Daily, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache overview daily rows: %w", err) + } + + return overview, nil +} + func pgQuoteLiteral(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } + +func pgUsageConditions(params UsageQueryParams, argIdx int) (conditions []string, args []any, nextIdx int, err error) { + conditions, args, nextIdx = pgDateRangeConditions(params, argIdx) + userPath, err := normalizeUsageUserPathFilter(params.UserPath) + if err != nil { + return nil, nil, 0, err + } + if userPath != "" { + conditions = append(conditions, fmt.Sprintf("(user_path = $%d OR user_path LIKE $%d ESCAPE '\\')", nextIdx, nextIdx+1)) + args = append(args, userPath, usageUserPathSubtreePattern(userPath)) + nextIdx += 2 + } + if condition := pgCacheModeCondition(params.CacheMode); condition != "" { + conditions = append(conditions, condition) + } + return conditions, args, nextIdx, nil +} + +func pgCacheModeCondition(mode string) string { + switch normalizeCacheMode(mode) { + case CacheModeCached: + return "(cache_type = '" + CacheTypeExact + "' OR cache_type = '" + CacheTypeSemantic + "')" + case CacheModeAll: + return "" + default: + return "(cache_type IS NULL OR cache_type = '')" + } +} diff --git a/internal/usage/reader_sqlite.go b/internal/usage/reader_sqlite.go index 3c74ebbc..593e7c9d 100644 --- a/internal/usage/reader_sqlite.go +++ b/internal/usage/reader_sqlite.go @@ -25,15 +25,10 @@ func NewSQLiteReader(db *sql.DB) (*SQLiteReader, error) { // GetSummary returns aggregated usage statistics for the given query parameters. func (r *SQLiteReader) GetSummary(ctx context.Context, params UsageQueryParams) (*UsageSummary, error) { - conditions, args := sqliteDateRangeConditions(params) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, err := sqliteUsageConditions(params) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, "(user_path = ? OR user_path LIKE ? ESCAPE '\\')") - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -54,15 +49,10 @@ func (r *SQLiteReader) GetSummary(ctx context.Context, params UsageQueryParams) // GetUsageByModel returns token and cost totals grouped by model and provider. func (r *SQLiteReader) GetUsageByModel(ctx context.Context, params UsageQueryParams) ([]ModelUsage, error) { - conditions, args := sqliteDateRangeConditions(params) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, err := sqliteUsageConditions(params) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, "(user_path = ? OR user_path LIKE ? ESCAPE '\\')") - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -95,8 +85,7 @@ func (r *SQLiteReader) GetUsageByModel(ctx context.Context, params UsageQueryPar func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) (*UsageLogResult, error) { limit, offset := clampLimitOffset(params.Limit, params.Offset) - conditions, args := sqliteDateRangeConditions(params.UsageQueryParams) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, err := sqliteUsageConditions(params.UsageQueryParams) if err != nil { return nil, err } @@ -109,10 +98,6 @@ func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) ( conditions = append(conditions, "provider = ?") args = append(args, params.Provider) } - if userPath != "" { - conditions = append(conditions, "(user_path = ? OR user_path LIKE ? ESCAPE '\\')") - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } if params.Search != "" { conditions = append(conditions, "(model LIKE ? ESCAPE '\\' OR provider LIKE ? ESCAPE '\\' OR request_id LIKE ? ESCAPE '\\' OR provider_id LIKE ? ESCAPE '\\')") s := "%" + escapeLikeWildcards(params.Search) + "%" @@ -129,7 +114,7 @@ func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) ( } // Fetch page - dataQuery := `SELECT id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, + dataQuery := `SELECT id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, cache_type, input_tokens, output_tokens, total_tokens, COALESCE(input_cost, 0), COALESCE(output_cost, 0), COALESCE(total_cost, 0), raw_data, COALESCE(costs_calculation_caveat, '') FROM usage` + where + ` ORDER BY ` + sqliteTimestampEpochExpr() + ` DESC, id DESC LIMIT ? OFFSET ?` dataArgs := append(append([]any(nil), args...), limit, offset) @@ -147,7 +132,8 @@ func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) ( var caveat *string var rawDataJSON *string var userPath sql.NullString - if err := rows.Scan(&e.ID, &e.RequestID, &e.ProviderID, &ts, &e.Model, &e.Provider, &e.Endpoint, &userPath, + var cacheType sql.NullString + if err := rows.Scan(&e.ID, &e.RequestID, &e.ProviderID, &ts, &e.Model, &e.Provider, &e.Endpoint, &userPath, &cacheType, &e.InputTokens, &e.OutputTokens, &e.TotalTokens, &e.InputCost, &e.OutputCost, &e.TotalCost, &rawDataJSON, &caveat); err != nil { return nil, fmt.Errorf("failed to scan usage log row: %w", err) } @@ -168,6 +154,9 @@ func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) ( if userPath.Valid { e.UserPath = userPath.String } + if cacheType.Valid { + e.CacheType = normalizeCacheType(cacheType.String) + } if caveat != nil { e.CostsCalculationCaveat = *caveat } @@ -248,15 +237,10 @@ func (r *SQLiteReader) GetDailyUsage(ctx context.Context, params UsageQueryParam return nil, err } - conditions, args := sqliteDateRangeConditions(params) - userPath, err := normalizeUsageUserPathFilter(params.UserPath) + conditions, args, err := sqliteUsageConditions(params) if err != nil { return nil, err } - if userPath != "" { - conditions = append(conditions, "(user_path = ? OR user_path LIKE ? ESCAPE '\\')") - args = append(args, userPath, usageUserPathSubtreePattern(userPath)) - } where := buildWhereClause(conditions) costCols := `, SUM(input_cost), SUM(output_cost), SUM(total_cost)` @@ -292,6 +276,79 @@ func (r *SQLiteReader) GetDailyUsage(ctx context.Context, params UsageQueryParam return result, nil } +// GetCacheOverview returns cached-only aggregates for the admin dashboard. +func (r *SQLiteReader) GetCacheOverview(ctx context.Context, params UsageQueryParams) (*CacheOverview, error) { + params.CacheMode = CacheModeCached + + conditions, args, err := sqliteUsageConditions(params) + if err != nil { + return nil, err + } + where := buildWhereClause(conditions) + + summaryQuery := `SELECT COUNT(*), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeExact + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeSemantic + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(total_tokens), 0), + SUM(total_cost) + FROM usage` + where + + overview := &CacheOverview{} + if err := r.db.QueryRowContext(ctx, summaryQuery, args...).Scan( + &overview.Summary.TotalHits, + &overview.Summary.ExactHits, + &overview.Summary.SemanticHits, + &overview.Summary.TotalInput, + &overview.Summary.TotalOutput, + &overview.Summary.TotalTokens, + &overview.Summary.TotalSavedCost, + ); err != nil { + return nil, fmt.Errorf("failed to query cache overview summary: %w", err) + } + + groupExpr, groupArgs, err := r.sqliteGroupExpr(ctx, params) + if err != nil { + return nil, err + } + dailyQuery := `WITH usage_periods AS ( + SELECT ` + groupExpr + ` AS period, + cache_type, input_tokens, output_tokens, total_tokens, total_cost + FROM usage` + where + ` + ) + SELECT period, + COUNT(*), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeExact + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeSemantic + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(total_tokens), 0), + SUM(total_cost) + FROM usage_periods GROUP BY period ORDER BY period` + queryArgs := append(groupArgs, args...) + + rows, err := r.db.QueryContext(ctx, dailyQuery, queryArgs...) + if err != nil { + return nil, fmt.Errorf("failed to query cache overview daily: %w", err) + } + defer rows.Close() + + overview.Daily = make([]CacheOverviewDaily, 0) + for rows.Next() { + var d CacheOverviewDaily + if err := rows.Scan(&d.Date, &d.Hits, &d.ExactHits, &d.SemanticHits, &d.InputTokens, &d.OutputTokens, &d.TotalTokens, &d.SavedCost); err != nil { + return nil, fmt.Errorf("failed to scan cache overview daily row: %w", err) + } + overview.Daily = append(overview.Daily, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache overview daily rows: %w", err) + } + + return overview, nil +} + func sqliteOffsetModifier(offsetMinutes int) string { if offsetMinutes == 0 { return "" @@ -299,6 +356,33 @@ func sqliteOffsetModifier(offsetMinutes int) string { return fmt.Sprintf("%+d minutes", offsetMinutes) } +func sqliteUsageConditions(params UsageQueryParams) ([]string, []any, error) { + conditions, args := sqliteDateRangeConditions(params) + userPath, err := normalizeUsageUserPathFilter(params.UserPath) + if err != nil { + return nil, nil, err + } + if userPath != "" { + conditions = append(conditions, "(user_path = ? OR user_path LIKE ? ESCAPE '\\')") + args = append(args, userPath, usageUserPathSubtreePattern(userPath)) + } + if condition := sqliteCacheModeCondition(params.CacheMode); condition != "" { + conditions = append(conditions, condition) + } + return conditions, args, nil +} + +func sqliteCacheModeCondition(mode string) string { + switch normalizeCacheMode(mode) { + case CacheModeCached: + return "(cache_type = '" + CacheTypeExact + "' OR cache_type = '" + CacheTypeSemantic + "')" + case CacheModeAll: + return "" + default: + return "(cache_type IS NULL OR cache_type = '')" + } +} + type sqliteTimeZoneSegment struct { Until time.Time OffsetMinutes int diff --git a/internal/usage/store_mongodb.go b/internal/usage/store_mongodb.go index b59a7db8..67ca2be9 100644 --- a/internal/usage/store_mongodb.go +++ b/internal/usage/store_mongodb.go @@ -82,6 +82,9 @@ func NewMongoDBStore(database *mongo.Database, retentionDays int) (*MongoDBStore { Keys: bson.D{{Key: "user_path", Value: 1}}, }, + { + Keys: bson.D{{Key: "cache_type", Value: 1}}, + }, } // Add timestamp index - use TTL index if retention is configured, diff --git a/internal/usage/store_postgresql.go b/internal/usage/store_postgresql.go index af33ea77..f9db0324 100644 --- a/internal/usage/store_postgresql.go +++ b/internal/usage/store_postgresql.go @@ -14,14 +14,14 @@ import ( ) const ( - usageInsertColumnCount = 16 + usageInsertColumnCount = 17 postgresMaxBindParameters = 65535 usageInsertMaxRowsPerQuery = postgresMaxBindParameters / usageInsertColumnCount ) const usageInsertPrefix = ` INSERT INTO usage (id, request_id, provider_id, timestamp, model, provider, - endpoint, user_path, input_tokens, output_tokens, total_tokens, raw_data, + endpoint, user_path, cache_type, input_tokens, output_tokens, total_tokens, raw_data, input_cost, output_cost, total_cost, costs_calculation_caveat) VALUES ` @@ -62,6 +62,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore provider TEXT NOT NULL, endpoint TEXT NOT NULL, user_path TEXT, + cache_type TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0, @@ -79,6 +80,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore "ALTER TABLE usage ADD COLUMN IF NOT EXISTS total_cost DOUBLE PRECISION", "ALTER TABLE usage ADD COLUMN IF NOT EXISTS costs_calculation_caveat TEXT DEFAULT ''", "ALTER TABLE usage ADD COLUMN IF NOT EXISTS user_path TEXT", + "ALTER TABLE usage ADD COLUMN IF NOT EXISTS cache_type TEXT", } for _, migration := range costMigrations { if _, err := pool.Exec(ctx, migration); err != nil { @@ -94,6 +96,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore "CREATE INDEX IF NOT EXISTS idx_usage_model ON usage(model)", "CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider)", "CREATE INDEX IF NOT EXISTS idx_usage_user_path ON usage(user_path)", + "CREATE INDEX IF NOT EXISTS idx_usage_cache_type ON usage(cache_type)", "CREATE INDEX IF NOT EXISTS idx_usage_raw_data_gin ON usage USING GIN (raw_data)", } for _, idx := range indexes { @@ -204,6 +207,7 @@ func buildUsageInsert(entries []*UsageEntry) (string, []any) { entry.Provider, entry.Endpoint, entry.UserPath, + cacheTypeValue(entry.CacheType), entry.InputTokens, entry.OutputTokens, entry.TotalTokens, diff --git a/internal/usage/store_postgresql_test.go b/internal/usage/store_postgresql_test.go index bc36ecbd..6a677eee 100644 --- a/internal/usage/store_postgresql_test.go +++ b/internal/usage/store_postgresql_test.go @@ -21,6 +21,7 @@ func TestBuildUsageInsert(t *testing.T) { Model: "gpt-4o-mini", Provider: "openai", Endpoint: "/v1/chat/completions", + CacheType: CacheTypeExact, InputTokens: 10, OutputTokens: 5, TotalTokens: 15, @@ -50,29 +51,35 @@ func TestBuildUsageInsert(t *testing.T) { }) normalized := strings.Join(strings.Fields(query), " ") - wantQuery := "INSERT INTO usage (id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, input_tokens, output_tokens, total_tokens, raw_data, input_cost, output_cost, total_cost, costs_calculation_caveat) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16), ($17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) ON CONFLICT (id) DO NOTHING" + wantQuery := "INSERT INTO usage (id, request_id, provider_id, timestamp, model, provider, endpoint, user_path, cache_type, input_tokens, output_tokens, total_tokens, raw_data, input_cost, output_cost, total_cost, costs_calculation_caveat) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17), ($18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34) ON CONFLICT (id) DO NOTHING" if normalized != wantQuery { t.Fatalf("query = %q, want %q", normalized, wantQuery) } - if got, want := len(args), 32; got != want { + if got, want := len(args), 34; got != want { t.Fatalf("len(args) = %d, want %d", got, want) } if got := args[0]; got != "usage-1" { t.Fatalf("args[0] = %v, want usage-1", got) } - if got := args[16]; got != "usage-2" { - t.Fatalf("args[16] = %v, want usage-2", got) + if got := args[17]; got != "usage-2" { + t.Fatalf("args[17] = %v, want usage-2", got) } - if got := string(args[11].([]byte)); got != `{"cached_tokens":3}` { - t.Fatalf("args[11] = %q, want %q", got, `{"cached_tokens":3}`) + if got := args[8]; got != CacheTypeExact { + t.Fatalf("args[8] = %v, want %q", got, CacheTypeExact) } - rawData, ok := args[27].([]byte) + if got := string(args[12].([]byte)); got != `{"cached_tokens":3}` { + t.Fatalf("args[12] = %q, want %q", got, `{"cached_tokens":3}`) + } + if got := args[25]; got != nil { + t.Fatalf("args[25] = %v, want nil cache_type", got) + } + rawData, ok := args[29].([]byte) if !ok { - t.Fatalf("args[27] has type %T, want []byte", args[27]) + t.Fatalf("args[29] has type %T, want []byte", args[29]) } if rawData != nil { - t.Fatalf("args[27] = %v, want nil raw_data", rawData) + t.Fatalf("args[29] = %v, want nil raw_data", rawData) } } diff --git a/internal/usage/store_sqlite.go b/internal/usage/store_sqlite.go index feebfe83..1b1eb1d2 100644 --- a/internal/usage/store_sqlite.go +++ b/internal/usage/store_sqlite.go @@ -12,10 +12,10 @@ import ( ) // SQLite has a default limit of 999 bindable parameters per query (SQLITE_MAX_VARIABLE_NUMBER). -// With 16 columns per usage entry, we can safely insert up to 62 entries per batch (62 * 16 = 992). +// With 17 columns per usage entry, we can safely insert up to 58 entries per batch (58 * 17 = 986). const ( maxSQLiteParams = 999 - columnsPerUsageEntry = 16 + columnsPerUsageEntry = 17 maxEntriesPerBatch = maxSQLiteParams / columnsPerUsageEntry // 62 entries ) @@ -46,6 +46,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { provider TEXT NOT NULL, endpoint TEXT NOT NULL, user_path TEXT, + cache_type TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0, @@ -63,6 +64,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { "ALTER TABLE usage ADD COLUMN total_cost REAL", "ALTER TABLE usage ADD COLUMN costs_calculation_caveat TEXT DEFAULT ''", "ALTER TABLE usage ADD COLUMN user_path TEXT", + "ALTER TABLE usage ADD COLUMN cache_type TEXT", } for _, migration := range costMigrations { if _, err := db.Exec(migration); err != nil { @@ -82,6 +84,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { "CREATE INDEX IF NOT EXISTS idx_usage_model ON usage(model)", "CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider)", "CREATE INDEX IF NOT EXISTS idx_usage_user_path ON usage(user_path)", + "CREATE INDEX IF NOT EXISTS idx_usage_cache_type ON usage(cache_type)", } for _, idx := range indexes { if _, err := db.Exec(idx); err != nil { @@ -120,7 +123,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*UsageEntry) err values := make([]any, 0, len(chunk)*columnsPerUsageEntry) for j, e := range chunk { - placeholders[j] = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + placeholders[j] = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" rawDataJSON := marshalRawData(e.RawData, e.ID) @@ -139,6 +142,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*UsageEntry) err e.Provider, e.Endpoint, e.UserPath, + cacheTypeValue(e.CacheType), e.InputTokens, e.OutputTokens, e.TotalTokens, @@ -151,7 +155,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*UsageEntry) err } query := `INSERT OR IGNORE INTO usage (id, request_id, provider_id, timestamp, model, provider, - endpoint, user_path, input_tokens, output_tokens, total_tokens, raw_data, + endpoint, user_path, cache_type, input_tokens, output_tokens, total_tokens, raw_data, input_cost, output_cost, total_cost, costs_calculation_caveat) VALUES ` + strings.Join(placeholders, ",") diff --git a/internal/usage/usage.go b/internal/usage/usage.go index ae62d22a..a0dea7bc 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -37,10 +37,11 @@ type UsageEntry struct { Timestamp time.Time `json:"timestamp" bson:"timestamp"` // Request context - Model string `json:"model" bson:"model"` - Provider string `json:"provider" bson:"provider"` - Endpoint string `json:"endpoint" bson:"endpoint"` - UserPath string `json:"user_path,omitempty" bson:"user_path,omitempty"` + Model string `json:"model" bson:"model"` + Provider string `json:"provider" bson:"provider"` + Endpoint string `json:"endpoint" bson:"endpoint"` + UserPath string `json:"user_path,omitempty" bson:"user_path,omitempty"` + CacheType string `json:"cache_type,omitempty" bson:"cache_type,omitempty"` // Standard token counts (normalized across providers) InputTokens int `json:"input_tokens" bson:"input_tokens"` From 12911101e7f57b9e1043fc3cdafb6e75e5ebd89a Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 01:20:35 +0200 Subject: [PATCH 2/4] fix(usage): harden cache analytics followups --- PossibleRefactoring.md | 83 ++++++++++++++ internal/admin/handler.go | 23 ++++ internal/admin/handler_test.go | 23 ++++ internal/app/app.go | 18 ++- internal/app/app_test.go | 42 +++++-- internal/usage/cache_type.go | 15 +++ internal/usage/cache_type_test.go | 22 ++++ internal/usage/extractor.go | 14 ++- internal/usage/extractor_test.go | 14 ++- internal/usage/reader_mongodb.go | 144 ++++++++++++------------ internal/usage/reader_mongodb_test.go | 26 ++++- internal/usage/reader_postgresql.go | 17 +-- internal/usage/store_mongodb.go | 4 +- internal/usage/store_postgresql.go | 1 + internal/usage/store_postgresql_test.go | 1 + internal/usage/store_sqlite.go | 5 +- 16 files changed, 350 insertions(+), 102 deletions(-) create mode 100644 PossibleRefactoring.md create mode 100644 internal/usage/cache_type_test.go diff --git a/PossibleRefactoring.md b/PossibleRefactoring.md new file mode 100644 index 00000000..010e988a --- /dev/null +++ b/PossibleRefactoring.md @@ -0,0 +1,83 @@ +# Possible Refactoring + +Ordered by lowest effort and lowest risk first. + +## 1. Remove dead `CacheTypeBoth` + +Effort: very low +Risk: very low + +Why: +- Defined in `internal/responsecache/semantic.go`. +- No call sites found in the repo. + +Suggested action: +- Delete the constant and let tests confirm nothing depended on it. + +## 2. Deduplicate the dashboard's empty `cacheOverview` object + +Effort: low +Risk: very low + +Why: +- The same shape is repeated in: + - `internal/admin/dashboard/static/js/dashboard.js` + - `internal/admin/dashboard/static/js/modules/usage.js` + - `internal/admin/dashboard/static/js/modules/execution-plans.js` + +Suggested action: +- Keep a single `emptyCacheOverview()` factory and reuse it everywhere. + +## 3. Pick one owner for "cache overview is cached-only" + +Effort: low +Risk: low + +Why: +- The handler sets `CacheModeCached` in `internal/admin/handler.go`. +- Each reader sets it again in: + - `internal/usage/reader_sqlite.go` + - `internal/usage/reader_postgresql.go` + - `internal/usage/reader_mongodb.go` +- `GetCacheOverview()` already implies cached-only behavior. + +Suggested action: +- Keep the override in one place only. +- Prefer reader ownership so the behavior stays correct regardless of caller. + +## 4. Remove the legacy `ResponseCacheMiddleware.Middleware()` path + +Effort: medium +Risk: medium + +Why: +- Production flow now uses `HandleRequest()` from `internal/server/translated_inference_service.go`. +- `.Middleware()` in `internal/responsecache/responsecache.go` is only referenced by tests. + +Suggested action: +- Delete the compatibility wrapper. +- Migrate or remove the old middleware-focused tests in `internal/responsecache/middleware_test.go`. + +## 5. Centralize cache-type vocabulary across packages + +Effort: medium to high +Risk: medium + +Why: +- Overlapping cache constants and normalization logic exist in: + - `internal/usage/cache_type.go` + - `internal/auditlog/auditlog.go` + - `internal/responsecache/semantic.go` +- This increases the chance of drift when new cache types or modes are added. + +Suggested action: +- Introduce a small shared internal package for cache semantics. +- Do it only if it can be done without creating import cycles. + +## Recommended order + +1. Remove `CacheTypeBoth`. +2. Deduplicate the dashboard empty-state object. +3. Keep cached-only policy in one layer. +4. Remove the legacy middleware path. +5. Centralize cache semantics in a shared package. diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 3c181575..2ec611ae 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -277,6 +277,8 @@ func handleError(c *echo.Context, err error) error { // @Param days query int false "Number of days (default 30)" // @Param start_date query string false "Start date (YYYY-MM-DD)" // @Param end_date query string false "End date (YYYY-MM-DD)" +// @Param user_path query string false "Filter by tracked user path subtree" +// @Param cache_mode query string false "Cache mode filter: uncached, cached, all (default uncached)" // @Success 200 {object} usage.UsageSummary // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError @@ -333,6 +335,8 @@ func usageSliceResponse[T any]( // @Param start_date query string false "Start date (YYYY-MM-DD)" // @Param end_date query string false "End date (YYYY-MM-DD)" // @Param interval query string false "Grouping interval: daily, weekly, monthly, yearly (default daily)" +// @Param user_path query string false "Filter by tracked user path subtree" +// @Param cache_mode query string false "Cache mode filter: uncached, cached, all (default uncached)" // @Success 200 {array} usage.DailyUsage // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError @@ -352,6 +356,8 @@ func (h *Handler) DailyUsage(c *echo.Context) error { // @Param days query int false "Number of days (default 30)" // @Param start_date query string false "Start date (YYYY-MM-DD)" // @Param end_date query string false "End date (YYYY-MM-DD)" +// @Param user_path query string false "Filter by tracked user path subtree" +// @Param cache_mode query string false "Cache mode filter: uncached, cached, all (default uncached)" // @Success 200 {array} usage.ModelUsage // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError @@ -374,6 +380,7 @@ func (h *Handler) UsageByModel(c *echo.Context) error { // @Param model query string false "Filter by model name" // @Param provider query string false "Filter by provider" // @Param user_path query string false "Filter by tracked user path subtree" +// @Param cache_mode query string false "Cache mode filter: uncached, cached, all (default uncached)" // @Param search query string false "Search across model, provider, request_id, provider_id" // @Param limit query int false "Page size (default 50, max 200)" // @Param offset query int false "Offset for pagination" @@ -424,6 +431,22 @@ func (h *Handler) UsageLog(c *echo.Context) error { } // CacheOverview handles GET /admin/api/v1/cache/overview +// +// @Summary Get cached-only usage overview +// @Tags admin +// @Produce json +// @Security BearerAuth +// @Param days query int false "Number of days (default 30)" +// @Param start_date query string false "Start date (YYYY-MM-DD)" +// @Param end_date query string false "End date (YYYY-MM-DD)" +// @Param interval query string false "Grouping interval: daily, weekly, monthly, yearly (default daily)" +// @Param user_path query string false "Filter by tracked user path subtree" +// @Param cache_mode query string false "Cache mode filter: uncached, cached, all (cache overview always uses cached mode)" +// @Success 200 {object} usage.CacheOverview +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 503 {object} core.GatewayError +// @Router /admin/api/v1/cache/overview [get] func (h *Handler) CacheOverview(c *echo.Context) error { if strings.TrimSpace(h.runtimeConfig.CacheEnabled) != "on" { return handleError(c, featureUnavailableError("cache analytics is unavailable")) diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index 9b4e213c..65d547bf 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -1261,6 +1261,29 @@ func TestCacheOverview_ReturnsPayloadWhenEnabled(t *testing.T) { } } +func TestCacheOverview_ReturnsErrorWhenReaderFails(t *testing.T) { + reader := &mockUsageReader{cacheErr: errors.New("boom")} + h := NewHandler(reader, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{ + CacheEnabled: "on", + })) + c, rec := newHandlerContext("/admin/api/v1/cache/overview?days=30") + + if err := h.CacheOverview(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := body["error"]; !ok { + t.Fatalf("expected error payload, got %v", body) + } +} + // --- handleError tests --- func TestHandleError_GatewayErrors(t *testing.T) { diff --git a/internal/app/app.go b/internal/app/app.go index 779ae6d6..69af3b3e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -115,6 +115,13 @@ func New(ctx context.Context, cfg Config) (*App, error) { } return nil, fmt.Errorf("failed to initialize usage tracking: %w", err) } + if usageResult == nil || usageResult.Logger == nil { + closeErr := errors.Join(app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("usage tracking initialization returned nil result (also: close error: %v)", closeErr) + } + return nil, fmt.Errorf("usage tracking initialization returned nil result") + } app.usage = usageResult // Initialize batch lifecycle storage. @@ -298,6 +305,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { slog.Warn("ADMIN_UI_ENABLED=true requires ADMIN_ENDPOINTS_ENABLED=true — forcing UI to disabled") adminCfg.UIEnabled = false } + usageEnabledForDashboard := usageResult.Logger.Config().Enabled if adminCfg.EndpointsEnabled { adminHandler, dashHandler, adminErr := initAdmin( auditResult.Storage, @@ -307,7 +315,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { app.aliases.Service, executionPlanResult.Service, guardrailRegistry, - dashboardRuntimeConfig(appCfg), + dashboardRuntimeConfig(appCfg, usageEnabledForDashboard), adminCfg.UIEnabled, ) if adminErr != nil { @@ -768,18 +776,22 @@ func defaultExecutionPlanInput(cfg *config.Config) executionplans.CreateInput { } } -func dashboardRuntimeConfig(cfg *config.Config) admin.DashboardConfigResponse { +func dashboardRuntimeConfig(cfg *config.Config, usageEnabled bool) admin.DashboardConfigResponse { return admin.DashboardConfigResponse{ FeatureFallbackMode: dashboardFallbackModeValue(cfg), LoggingEnabled: dashboardEnabledValue(cfg != nil && cfg.Logging.Enabled), UsageEnabled: dashboardEnabledValue(cfg != nil && cfg.Usage.Enabled), GuardrailsEnabled: dashboardEnabledValue(cfg != nil && cfg.Guardrails.Enabled), - CacheEnabled: dashboardEnabledValue(cfg != nil && responseCacheConfigured(cfg.Cache.Response)), + CacheEnabled: dashboardEnabledValue(cacheAnalyticsConfigured(cfg, usageEnabled)), RedisURL: dashboardEnabledValue(simpleResponseCacheConfigured(cfg)), SemanticCacheEnabled: dashboardEnabledValue(semanticResponseCacheConfigured(cfg)), } } +func cacheAnalyticsConfigured(cfg *config.Config, usageEnabled bool) bool { + return cfg != nil && usageEnabled && responseCacheConfigured(cfg.Cache.Response) +} + func dashboardEnabledValue(enabled bool) string { if enabled { return "on" diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a68bc2e4..323d5828 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -46,7 +46,7 @@ func TestDashboardRuntimeConfig_ExposesFallbackMode(t *testing.T) { }, } - values := dashboardRuntimeConfig(cfg) + values := dashboardRuntimeConfig(cfg, false) if got := values.FeatureFallbackMode; got != string(config.FallbackModeManual) { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want %q", admin.DashboardConfigFeatureFallbackMode, got, config.FallbackModeManual) } @@ -59,7 +59,7 @@ func TestDashboardRuntimeConfig_InvalidFallbackModeDefaultsOff(t *testing.T) { }, } - values := dashboardRuntimeConfig(cfg) + values := dashboardRuntimeConfig(cfg, false) if got := values.FeatureFallbackMode; got != string(config.FallbackModeOff) { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want %q", admin.DashboardConfigFeatureFallbackMode, got, config.FallbackModeOff) } @@ -75,7 +75,7 @@ func TestDashboardRuntimeConfig_FallbackOverrideEnablesVisibilityWhenDefaultMode }, } - values := dashboardRuntimeConfig(cfg) + values := dashboardRuntimeConfig(cfg, false) if got := values.FeatureFallbackMode; got != string(config.FallbackModeManual) { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want %q", admin.DashboardConfigFeatureFallbackMode, got, config.FallbackModeManual) } @@ -87,7 +87,7 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) { Enabled: true, }, Usage: config.UsageConfig{ - Enabled: false, + Enabled: true, }, Guardrails: config.GuardrailsConfig{ Enabled: true, @@ -106,12 +106,12 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) { }, } - values := dashboardRuntimeConfig(cfg) + values := dashboardRuntimeConfig(cfg, true) if got := values.LoggingEnabled; got != "on" { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigLoggingEnabled, got) } - if got := values.UsageEnabled; got != "off" { - t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigUsageEnabled, got) + if got := values.UsageEnabled; got != "on" { + t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigUsageEnabled, got) } if got := values.GuardrailsEnabled; got != "on" { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigGuardrailsEnabled, got) @@ -126,3 +126,31 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) { t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigSemanticCacheEnabled, got) } } + +func TestDashboardRuntimeConfig_HidesCacheAnalyticsWhenUsageDisabled(t *testing.T) { + cfg := &config.Config{ + Usage: config.UsageConfig{ + Enabled: false, + }, + Cache: config.CacheConfig{ + Response: config.ResponseCacheConfig{ + Simple: config.SimpleCacheConfig{ + Redis: &config.RedisResponseConfig{ + URL: "redis://localhost:6379", + }, + }, + }, + }, + } + + values := dashboardRuntimeConfig(cfg, false) + if got := values.UsageEnabled; got != "off" { + t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigUsageEnabled, got) + } + if got := values.CacheEnabled; got != "off" { + t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigCacheEnabled, got) + } + if got := values.RedisURL; got != "on" { + t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigRedisURL, got) + } +} diff --git a/internal/usage/cache_type.go b/internal/usage/cache_type.go index 7a919db9..1e36ba69 100644 --- a/internal/usage/cache_type.go +++ b/internal/usage/cache_type.go @@ -39,3 +39,18 @@ func cacheTypeValue(value string) any { } return nil } + +func normalizedUsageEntryForStorage(entry *UsageEntry) *UsageEntry { + if entry == nil { + return nil + } + + normalized := normalizeCacheType(entry.CacheType) + if normalized == entry.CacheType { + return entry + } + + cloned := *entry + cloned.CacheType = normalized + return &cloned +} diff --git a/internal/usage/cache_type_test.go b/internal/usage/cache_type_test.go new file mode 100644 index 00000000..c292701a --- /dev/null +++ b/internal/usage/cache_type_test.go @@ -0,0 +1,22 @@ +package usage + +import "testing" + +func TestNormalizedUsageEntryForStorageClearsInvalidCacheTypeWithoutMutatingInput(t *testing.T) { + entry := &UsageEntry{ + ID: "usage-1", + RequestID: "req-1", + CacheType: "invalid-cache-type", + } + + got := normalizedUsageEntryForStorage(entry) + if got == entry { + t.Fatal("expected invalid cache type to clone entry for normalization") + } + if got.CacheType != "" { + t.Fatalf("normalized CacheType = %q, want empty", got.CacheType) + } + if entry.CacheType != "invalid-cache-type" { + t.Fatalf("input CacheType mutated to %q", entry.CacheType) + } +} diff --git a/internal/usage/extractor.go b/internal/usage/extractor.go index 8a78e649..a539e46f 100644 --- a/internal/usage/extractor.go +++ b/internal/usage/extractor.go @@ -280,11 +280,17 @@ func ExtractFromCachedResponseBody( } entry.CacheType = cacheType - if strings.TrimSpace(entry.Model) == "" { - entry.Model = strings.TrimSpace(model) + if normalized := strings.TrimSpace(requestID); normalized != "" { + entry.RequestID = normalized } - if strings.TrimSpace(entry.Provider) == "" { - entry.Provider = strings.TrimSpace(provider) + if normalized := strings.TrimSpace(model); normalized != "" { + entry.Model = normalized + } + if normalized := strings.TrimSpace(provider); normalized != "" { + entry.Provider = normalized + } + if normalized := strings.TrimSpace(endpoint); normalized != "" { + entry.Endpoint = normalized } return entry } diff --git a/internal/usage/extractor_test.go b/internal/usage/extractor_test.go index c19e6ee5..1f6bdf2e 100644 --- a/internal/usage/extractor_test.go +++ b/internal/usage/extractor_test.go @@ -484,7 +484,7 @@ func TestExtractFromSSEUsageEmptyRawData(t *testing.T) { func TestExtractFromCachedResponseBody(t *testing.T) { resp := &core.ChatResponse{ ID: "chatcmpl-cache", - Model: "gpt-4o", + Model: "gpt-4o-body", Usage: core.Usage{ PromptTokens: 42, CompletionTokens: 18, @@ -503,6 +503,18 @@ func TestExtractFromCachedResponseBody(t *testing.T) { if entry.CacheType != CacheTypeExact { t.Fatalf("CacheType = %q, want %q", entry.CacheType, CacheTypeExact) } + if entry.RequestID != "req-cache" { + t.Fatalf("RequestID = %q, want %q", entry.RequestID, "req-cache") + } + if entry.Provider != "openai" { + t.Fatalf("Provider = %q, want %q", entry.Provider, "openai") + } + if entry.Endpoint != "/v1/chat/completions" { + t.Fatalf("Endpoint = %q, want %q", entry.Endpoint, "/v1/chat/completions") + } + if entry.Model != "gpt-4o" { + t.Fatalf("Model = %q, want %q", entry.Model, "gpt-4o") + } if entry.InputTokens != 42 || entry.OutputTokens != 18 || entry.TotalTokens != 60 { t.Fatalf("unexpected token counts: %+v", entry) } diff --git a/internal/usage/reader_mongodb.go b/internal/usage/reader_mongodb.go index ea47b85d..f2a235de 100644 --- a/internal/usage/reader_mongodb.go +++ b/internal/usage/reader_mongodb.go @@ -3,6 +3,7 @@ package usage import ( "context" "fmt" + "regexp" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -375,31 +376,58 @@ func (r *MongoDBReader) GetCacheOverview(ctx context.Context, params UsageQueryP return nil, err } - summaryPipeline := bson.A{} + interval := params.Interval + if interval == "" { + interval = "daily" + } + + pipeline := bson.A{} if len(matchFilters) > 0 { - summaryPipeline = append(summaryPipeline, bson.D{{Key: "$match", Value: matchFilters}}) + pipeline = append(pipeline, bson.D{{Key: "$match", Value: matchFilters}}) } - summaryPipeline = append(summaryPipeline, bson.D{{Key: "$group", Value: bson.D{ - {Key: "_id", Value: nil}, - {Key: "total_hits", Value: bson.D{{Key: "$sum", Value: 1}}}, - {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, - {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, - {Key: "total_input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, - {Key: "total_output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, - {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, - {Key: "total_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, - {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, + pipeline = append(pipeline, bson.D{{Key: "$facet", Value: bson.D{ + {Key: "summary", Value: bson.A{ + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: nil}, + {Key: "total_hits", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, + {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, + {Key: "total_input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, + {Key: "total_output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, + {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, + {Key: "total_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, + {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, + }}}, + }}, + {Key: "daily", Value: bson.A{ + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{{Key: "$dateToString", Value: bson.D{ + {Key: "format", Value: mongoDateFormat(interval)}, + {Key: "date", Value: "$timestamp"}, + {Key: "timezone", Value: usageTimeZone(params)}, + }}}}, + {Key: "hits", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, + {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, + {Key: "input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, + {Key: "output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, + {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, + {Key: "saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, + {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, + }}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + }}, }}}) overview := &CacheOverview{Daily: []CacheOverviewDaily{}} - cursor, err := r.collection.Aggregate(ctx, summaryPipeline) + cursor, err := r.collection.Aggregate(ctx, pipeline) if err != nil { - return nil, fmt.Errorf("failed to aggregate cache overview summary: %w", err) + return nil, fmt.Errorf("failed to aggregate cache overview: %w", err) } defer cursor.Close(ctx) - if cursor.Next(ctx) { - var row struct { + var facetResult struct { + Summary []struct { TotalHits int `bson:"total_hits"` ExactHits int `bson:"exact_hits"` SemanticHits int `bson:"semantic_hits"` @@ -408,10 +436,31 @@ func (r *MongoDBReader) GetCacheOverview(ctx context.Context, params UsageQueryP TotalTokens int64 `bson:"total_tokens"` TotalSavedCost float64 `bson:"total_saved_cost"` HasSavedCost int `bson:"has_saved_cost"` + } `bson:"summary"` + Daily []struct { + Date string `bson:"_id"` + Hits int `bson:"hits"` + ExactHits int `bson:"exact_hits"` + SemanticHits int `bson:"semantic_hits"` + InputTokens int64 `bson:"input_tokens"` + OutputTokens int64 `bson:"output_tokens"` + TotalTokens int64 `bson:"total_tokens"` + SavedCost float64 `bson:"saved_cost"` + HasSavedCost int `bson:"has_saved_cost"` + } `bson:"daily"` + } + + if cursor.Next(ctx) { + if err := cursor.Decode(&facetResult); err != nil { + return nil, fmt.Errorf("failed to decode cache overview facet result: %w", err) } - if err := cursor.Decode(&row); err != nil { - return nil, fmt.Errorf("failed to decode cache overview summary: %w", err) - } + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache overview cursor: %w", err) + } + + if len(facetResult.Summary) > 0 { + row := facetResult.Summary[0] overview.Summary = CacheOverviewSummary{ TotalHits: row.TotalHits, ExactHits: row.ExactHits, @@ -424,58 +473,8 @@ func (r *MongoDBReader) GetCacheOverview(ctx context.Context, params UsageQueryP overview.Summary.TotalSavedCost = &row.TotalSavedCost } } - if err := cursor.Err(); err != nil { - return nil, fmt.Errorf("error iterating cache overview summary cursor: %w", err) - } - interval := params.Interval - if interval == "" { - interval = "daily" - } - dailyPipeline := bson.A{} - if len(matchFilters) > 0 { - dailyPipeline = append(dailyPipeline, bson.D{{Key: "$match", Value: matchFilters}}) - } - dailyPipeline = append(dailyPipeline, - bson.D{{Key: "$group", Value: bson.D{ - {Key: "_id", Value: bson.D{{Key: "$dateToString", Value: bson.D{ - {Key: "format", Value: mongoDateFormat(interval)}, - {Key: "date", Value: "$timestamp"}, - {Key: "timezone", Value: usageTimeZone(params)}, - }}}}, - {Key: "hits", Value: bson.D{{Key: "$sum", Value: 1}}}, - {Key: "exact_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeExact}}}, 1, 0}}}}}}, - {Key: "semantic_hits", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$eq", Value: bson.A{"$cache_type", CacheTypeSemantic}}}, 1, 0}}}}}}, - {Key: "input_tokens", Value: bson.D{{Key: "$sum", Value: "$input_tokens"}}}, - {Key: "output_tokens", Value: bson.D{{Key: "$sum", Value: "$output_tokens"}}}, - {Key: "total_tokens", Value: bson.D{{Key: "$sum", Value: "$total_tokens"}}}, - {Key: "saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$total_cost", 0}}}}}}, - {Key: "has_saved_cost", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{bson.D{{Key: "$gt", Value: bson.A{"$total_cost", nil}}}, 1, 0}}}}}}, - }}}, - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - ) - - cursor, err = r.collection.Aggregate(ctx, dailyPipeline) - if err != nil { - return nil, fmt.Errorf("failed to aggregate cache overview daily: %w", err) - } - defer cursor.Close(ctx) - - for cursor.Next(ctx) { - var row struct { - Date string `bson:"_id"` - Hits int `bson:"hits"` - ExactHits int `bson:"exact_hits"` - SemanticHits int `bson:"semantic_hits"` - InputTokens int64 `bson:"input_tokens"` - OutputTokens int64 `bson:"output_tokens"` - TotalTokens int64 `bson:"total_tokens"` - SavedCost float64 `bson:"saved_cost"` - HasSavedCost int `bson:"has_saved_cost"` - } - if err := cursor.Decode(&row); err != nil { - return nil, fmt.Errorf("failed to decode cache overview daily row: %w", err) - } + for _, row := range facetResult.Daily { entry := CacheOverviewDaily{ Date: row.Date, Hits: row.Hits, @@ -490,9 +489,6 @@ func (r *MongoDBReader) GetCacheOverview(ctx context.Context, params UsageQueryP } overview.Daily = append(overview.Daily, entry) } - if err := cursor.Err(); err != nil { - return nil, fmt.Errorf("error iterating cache overview daily cursor: %w", err) - } return overview, nil } @@ -533,7 +529,7 @@ func mongoUsageLogMatchFilters(params UsageLogParams) (bson.D, error) { matchFilters = append(matchFilters, bson.E{Key: "provider", Value: params.Provider}) } if params.Search != "" { - regex := bson.D{{Key: "$regex", Value: params.Search}, {Key: "$options", Value: "i"}} + regex := bson.D{{Key: "$regex", Value: regexp.QuoteMeta(params.Search)}, {Key: "$options", Value: "i"}} searchFilter := bson.D{{Key: "$or", Value: bson.A{ bson.D{{Key: "model", Value: regex}}, bson.D{{Key: "provider", Value: regex}}, diff --git a/internal/usage/reader_mongodb_test.go b/internal/usage/reader_mongodb_test.go index 2a0e99ba..ca0125f5 100644 --- a/internal/usage/reader_mongodb_test.go +++ b/internal/usage/reader_mongodb_test.go @@ -7,7 +7,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -func TestMongoUsageLogMatchFiltersAndsSearchWithCacheMode(t *testing.T) { +func TestMongoUsageLogMatchFiltersAndSearchWithCacheMode(t *testing.T) { got, err := mongoUsageLogMatchFilters(UsageLogParams{ UsageQueryParams: UsageQueryParams{ CacheMode: CacheModeUncached, @@ -37,3 +37,27 @@ func TestMongoUsageLogMatchFiltersAndsSearchWithCacheMode(t *testing.T) { t.Fatalf("mongoUsageLogMatchFilters() = %#v, want %#v", got, want) } } + +func TestMongoUsageLogMatchFiltersEscapesSearchRegex(t *testing.T) { + got, err := mongoUsageLogMatchFilters(UsageLogParams{ + UsageQueryParams: UsageQueryParams{ + CacheMode: CacheModeAll, + }, + Search: "gpt.4+", + }) + if err != nil { + t.Fatalf("mongoUsageLogMatchFilters() error = %v", err) + } + + regex := bson.D{{Key: "$regex", Value: `gpt\.4\+`}, {Key: "$options", Value: "i"}} + want := bson.D{{Key: "$or", Value: bson.A{ + bson.D{{Key: "model", Value: regex}}, + bson.D{{Key: "provider", Value: regex}}, + bson.D{{Key: "request_id", Value: regex}}, + bson.D{{Key: "provider_id", Value: regex}}, + }}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("mongoUsageLogMatchFilters() = %#v, want %#v", got, want) + } +} diff --git a/internal/usage/reader_postgresql.go b/internal/usage/reader_postgresql.go index 2dd7b285..2e530c2a 100644 --- a/internal/usage/reader_postgresql.go +++ b/internal/usage/reader_postgresql.go @@ -240,15 +240,16 @@ func (r *PostgreSQLReader) GetDailyUsage(ctx context.Context, params UsageQueryP func (r *PostgreSQLReader) GetCacheOverview(ctx context.Context, params UsageQueryParams) (*CacheOverview, error) { params.CacheMode = CacheModeCached - conditions, args, _, err := pgUsageConditions(params, 1) + conditions, args, _, err := pgUsageConditions(params, 3) if err != nil { return nil, err } where := buildWhereClause(conditions) + queryArgs := append([]any{CacheTypeExact, CacheTypeSemantic}, args...) summaryQuery := `SELECT COUNT(*), - COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeExact + `' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN cache_type = '` + CacheTypeSemantic + `' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = $1 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = $2 THEN 1 ELSE 0 END), 0), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), COALESCE(SUM(total_tokens), 0), @@ -256,7 +257,7 @@ func (r *PostgreSQLReader) GetCacheOverview(ctx context.Context, params UsageQue FROM "usage"` + where overview := &CacheOverview{} - if err := r.pool.QueryRow(ctx, summaryQuery, args...).Scan( + if err := r.pool.QueryRow(ctx, summaryQuery, queryArgs...).Scan( &overview.Summary.TotalHits, &overview.Summary.ExactHits, &overview.Summary.SemanticHits, @@ -275,15 +276,15 @@ func (r *PostgreSQLReader) GetCacheOverview(ctx context.Context, params UsageQue groupExpr := pgGroupExpr(interval, usageTimeZone(params)) dailyQuery := fmt.Sprintf(`SELECT %s as period, COUNT(*), - COALESCE(SUM(CASE WHEN cache_type = '%s' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN cache_type = '%s' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = $1 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cache_type = $2 THEN 1 ELSE 0 END), 0), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), COALESCE(SUM(total_tokens), 0), SUM(total_cost) - FROM "usage"%s GROUP BY %s ORDER BY period`, groupExpr, CacheTypeExact, CacheTypeSemantic, where, groupExpr) + FROM "usage"%s GROUP BY %s ORDER BY period`, groupExpr, where, groupExpr) - rows, err := r.pool.Query(ctx, dailyQuery, args...) + rows, err := r.pool.Query(ctx, dailyQuery, queryArgs...) if err != nil { return nil, fmt.Errorf("failed to query cache overview daily: %w", err) } diff --git a/internal/usage/store_mongodb.go b/internal/usage/store_mongodb.go index 67ca2be9..1f626c35 100644 --- a/internal/usage/store_mongodb.go +++ b/internal/usage/store_mongodb.go @@ -83,7 +83,7 @@ func NewMongoDBStore(database *mongo.Database, retentionDays int) (*MongoDBStore Keys: bson.D{{Key: "user_path", Value: 1}}, }, { - Keys: bson.D{{Key: "cache_type", Value: 1}}, + Keys: bson.D{{Key: "cache_type", Value: 1}, {Key: "timestamp", Value: 1}}, }, } @@ -123,7 +123,7 @@ func (s *MongoDBStore) WriteBatch(ctx context.Context, entries []*UsageEntry) er // Convert entries to BSON documents docs := make([]any, len(entries)) for i, e := range entries { - docs[i] = e + docs[i] = normalizedUsageEntryForStorage(e) } // Use unordered insert for better performance (continues on errors) diff --git a/internal/usage/store_postgresql.go b/internal/usage/store_postgresql.go index f9db0324..91205c90 100644 --- a/internal/usage/store_postgresql.go +++ b/internal/usage/store_postgresql.go @@ -183,6 +183,7 @@ func buildUsageInsert(entries []*UsageEntry) (string, []any) { placeholder := 1 for i, entry := range entries { + entry = normalizedUsageEntryForStorage(entry) if i > 0 { builder.WriteString(", ") } diff --git a/internal/usage/store_postgresql_test.go b/internal/usage/store_postgresql_test.go index 6a677eee..ca86d966 100644 --- a/internal/usage/store_postgresql_test.go +++ b/internal/usage/store_postgresql_test.go @@ -39,6 +39,7 @@ func TestBuildUsageInsert(t *testing.T) { Model: "gpt-4.1", Provider: "openai", Endpoint: "/v1/responses", + CacheType: "unexpected-cache-type", InputTokens: 20, OutputTokens: 8, TotalTokens: 28, diff --git a/internal/usage/store_sqlite.go b/internal/usage/store_sqlite.go index 1b1eb1d2..9db886b3 100644 --- a/internal/usage/store_sqlite.go +++ b/internal/usage/store_sqlite.go @@ -12,11 +12,11 @@ import ( ) // SQLite has a default limit of 999 bindable parameters per query (SQLITE_MAX_VARIABLE_NUMBER). -// With 17 columns per usage entry, we can safely insert up to 58 entries per batch (58 * 17 = 986). +// maxEntriesPerBatch derives from maxSQLiteParams / columnsPerUsageEntry. const ( maxSQLiteParams = 999 columnsPerUsageEntry = 17 - maxEntriesPerBatch = maxSQLiteParams / columnsPerUsageEntry // 62 entries + maxEntriesPerBatch = maxSQLiteParams / columnsPerUsageEntry // 58 entries ) // SQLiteStore implements UsageStore for SQLite databases. @@ -123,6 +123,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*UsageEntry) err values := make([]any, 0, len(chunk)*columnsPerUsageEntry) for j, e := range chunk { + e = normalizedUsageEntryForStorage(e) placeholders[j] = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" rawDataJSON := marshalRawData(e.RawData, e.ID) From 27371ea9c9f90309c137dc272b46e692c087e1ac Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 08:59:43 +0200 Subject: [PATCH 3/4] fix(usage): tighten cache analytics followups --- PossibleRefactoring.md | 14 +++- internal/admin/handler_test.go | 15 +++- internal/app/app.go | 6 +- internal/usage/extractor.go | 18 +++++ internal/usage/extractor_test.go | 123 ++++++++++++++++++++++--------- 5 files changed, 137 insertions(+), 39 deletions(-) diff --git a/PossibleRefactoring.md b/PossibleRefactoring.md index 010e988a..22bcc565 100644 --- a/PossibleRefactoring.md +++ b/PossibleRefactoring.md @@ -11,6 +11,10 @@ Why: - Defined in `internal/responsecache/semantic.go`. - No call sites found in the repo. +How verified: +- Symbol searched: `CacheTypeBoth` +- Command: `rg -n "CacheTypeBoth" internal` + Suggested action: - Delete the constant and let tests confirm nothing depended on it. @@ -54,9 +58,17 @@ Why: - Production flow now uses `HandleRequest()` from `internal/server/translated_inference_service.go`. - `.Middleware()` in `internal/responsecache/responsecache.go` is only referenced by tests. +How verified: +- Symbols searched: `Middleware()` and `HandleRequest(` +- Commands: + - `rg -n "\\.Middleware\\(\\)" internal | sort` + - `rg -n "HandleRequest\\(" internal | sort` + Suggested action: +- Before deleting the compatibility wrapper, keep equivalent cache-hit and cache-miss coverage around `HandleRequest()`. +- Existing tests in `internal/responsecache/handle_request_test.go` already cover core hit/miss flows and should be expanded first if wrapper-specific assertions are still needed. - Delete the compatibility wrapper. -- Migrate or remove the old middleware-focused tests in `internal/responsecache/middleware_test.go`. +- Only remove `internal/responsecache/middleware_test.go` after `HandleRequest()`-level coverage fully preserves the hit/miss, response header/status, and cache population assertions currently carried by the middleware wrapper tests. ## 5. Centralize cache-type vocabulary across packages diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index 65d547bf..a819183e 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -1279,9 +1279,22 @@ func TestCacheOverview_ReturnsErrorWhenReaderFails(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("failed to unmarshal: %v", err) } - if _, ok := body["error"]; !ok { + errorBody, ok := body["error"].(map[string]any) + if !ok { t.Fatalf("expected error payload, got %v", body) } + if got, ok := errorBody["type"].(string); !ok || got == "" { + t.Fatalf("error.type = %#v, want non-empty string", errorBody["type"]) + } + if got, ok := errorBody["message"].(string); !ok || got == "" { + t.Fatalf("error.message = %#v, want non-empty string", errorBody["message"]) + } + if _, ok := errorBody["param"]; !ok { + t.Fatalf("error.param missing from payload: %v", errorBody) + } + if _, ok := errorBody["code"]; !ok { + t.Fatalf("error.code missing from payload: %v", errorBody) + } } // --- handleError tests --- diff --git a/internal/app/app.go b/internal/app/app.go index 69af3b3e..cb226484 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -116,7 +116,11 @@ func New(ctx context.Context, cfg Config) (*App, error) { return nil, fmt.Errorf("failed to initialize usage tracking: %w", err) } if usageResult == nil || usageResult.Logger == nil { - closeErr := errors.Join(app.audit.Close(), app.providers.Close()) + var usageCloseErr error + if usageResult != nil { + usageCloseErr = usageResult.Close() + } + closeErr := errors.Join(usageCloseErr, app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("usage tracking initialization returned nil result (also: close error: %v)", closeErr) } diff --git a/internal/usage/extractor.go b/internal/usage/extractor.go index a539e46f..401f84af 100644 --- a/internal/usage/extractor.go +++ b/internal/usage/extractor.go @@ -3,6 +3,7 @@ package usage import ( "encoding/json" "maps" + "path" "strings" "time" @@ -245,6 +246,7 @@ func ExtractFromCachedResponseBody( if cacheType == "" { cacheType = CacheTypeExact } + endpoint = normalizeCachedResponseEndpoint(endpoint) var entry *UsageEntry switch endpoint { @@ -295,6 +297,22 @@ func ExtractFromCachedResponseBody( return entry } +func normalizeCachedResponseEndpoint(endpoint string) string { + normalized := strings.TrimSpace(endpoint) + if normalized == "" { + return "" + } + + cleaned := path.Clean(normalized) + if cleaned == "." { + return "" + } + if strings.HasPrefix(normalized, "/") && !strings.HasPrefix(cleaned, "/") { + return "/" + cleaned + } + return cleaned +} + func pricingForEndpoint(pricing *core.ModelPricing, endpoint string) *core.ModelPricing { if pricing == nil { return nil diff --git a/internal/usage/extractor_test.go b/internal/usage/extractor_test.go index 1f6bdf2e..a17343c8 100644 --- a/internal/usage/extractor_test.go +++ b/internal/usage/extractor_test.go @@ -482,42 +482,93 @@ func TestExtractFromSSEUsageEmptyRawData(t *testing.T) { } func TestExtractFromCachedResponseBody(t *testing.T) { - resp := &core.ChatResponse{ - ID: "chatcmpl-cache", - Model: "gpt-4o-body", - Usage: core.Usage{ - PromptTokens: 42, - CompletionTokens: 18, - TotalTokens: 60, - }, - } - body, err := json.Marshal(resp) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - entry := ExtractFromCachedResponseBody(body, "req-cache", "gpt-4o", "openai", "/v1/chat/completions", CacheTypeExact) - if entry == nil { - t.Fatal("expected non-nil entry") - } - if entry.CacheType != CacheTypeExact { - t.Fatalf("CacheType = %q, want %q", entry.CacheType, CacheTypeExact) - } - if entry.RequestID != "req-cache" { - t.Fatalf("RequestID = %q, want %q", entry.RequestID, "req-cache") - } - if entry.Provider != "openai" { - t.Fatalf("Provider = %q, want %q", entry.Provider, "openai") - } - if entry.Endpoint != "/v1/chat/completions" { - t.Fatalf("Endpoint = %q, want %q", entry.Endpoint, "/v1/chat/completions") - } - if entry.Model != "gpt-4o" { - t.Fatalf("Model = %q, want %q", entry.Model, "gpt-4o") - } - if entry.InputTokens != 42 || entry.OutputTokens != 18 || entry.TotalTokens != 60 { - t.Fatalf("unexpected token counts: %+v", entry) - } + t.Run("parses and overrides metadata", func(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-cache", + Model: "gpt-4o-body", + Usage: core.Usage{ + PromptTokens: 42, + CompletionTokens: 18, + TotalTokens: 60, + }, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + entry := ExtractFromCachedResponseBody(body, "req-cache", "gpt-4o", "openai", "/v1/chat/completions", CacheTypeExact) + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.CacheType != CacheTypeExact { + t.Fatalf("CacheType = %q, want %q", entry.CacheType, CacheTypeExact) + } + if entry.RequestID != "req-cache" { + t.Fatalf("RequestID = %q, want %q", entry.RequestID, "req-cache") + } + if entry.Provider != "openai" { + t.Fatalf("Provider = %q, want %q", entry.Provider, "openai") + } + if entry.Endpoint != "/v1/chat/completions" { + t.Fatalf("Endpoint = %q, want %q", entry.Endpoint, "/v1/chat/completions") + } + if entry.Model != "gpt-4o" { + t.Fatalf("Model = %q, want %q", entry.Model, "gpt-4o") + } + if entry.InputTokens != 42 || entry.OutputTokens != 18 || entry.TotalTokens != 60 { + t.Fatalf("unexpected token counts: %+v", entry) + } + }) + + t.Run("normalizes equivalent endpoint paths", func(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-cache", + Model: "gpt-4o-body", + Usage: core.Usage{ + PromptTokens: 7, + CompletionTokens: 3, + TotalTokens: 10, + }, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + entry := ExtractFromCachedResponseBody(body, "req-cache", "gpt-4o", "openai", "/v1/chat/completions/", CacheTypeExact) + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.Endpoint != "/v1/chat/completions" { + t.Fatalf("Endpoint = %q, want %q", entry.Endpoint, "/v1/chat/completions") + } + if entry.TotalTokens != 10 { + t.Fatalf("TotalTokens = %d, want 10", entry.TotalTokens) + } + }) + + t.Run("falls back to synthetic entry when body cannot be parsed", func(t *testing.T) { + entry := ExtractFromCachedResponseBody([]byte("{"), "req-cache-fallback", "gpt-4o", "openai", "/v1/chat/completions", CacheTypeExact) + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.RequestID != "req-cache-fallback" { + t.Fatalf("RequestID = %q, want %q", entry.RequestID, "req-cache-fallback") + } + if entry.Provider != "openai" { + t.Fatalf("Provider = %q, want %q", entry.Provider, "openai") + } + if entry.Endpoint != "/v1/chat/completions" { + t.Fatalf("Endpoint = %q, want %q", entry.Endpoint, "/v1/chat/completions") + } + if entry.Model != "gpt-4o" { + t.Fatalf("Model = %q, want %q", entry.Model, "gpt-4o") + } + if entry.InputTokens != 0 || entry.OutputTokens != 0 || entry.TotalTokens != 0 { + t.Fatalf("expected zero-token synthetic entry, got %+v", entry) + } + }) } func TestExtractFromChatResponse_WithBatchPricingEndpoint(t *testing.T) { From ced91e2aa9108f22cc58e39106c76a0ce95a573d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 10:12:48 +0200 Subject: [PATCH 4/4] fix(usage): tighten cache overview assertions --- PossibleRefactoring.md | 6 ++-- internal/admin/handler_test.go | 47 ++++++++++++++++++++------------ internal/usage/extractor.go | 10 +++---- internal/usage/extractor_test.go | 24 ++++++++++++++++ 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/PossibleRefactoring.md b/PossibleRefactoring.md index 22bcc565..3114b628 100644 --- a/PossibleRefactoring.md +++ b/PossibleRefactoring.md @@ -13,7 +13,7 @@ Why: How verified: - Symbol searched: `CacheTypeBoth` -- Command: `rg -n "CacheTypeBoth" internal` +- Command: `rg -n "CacheTypeBoth"` Suggested action: - Delete the constant and let tests confirm nothing depended on it. @@ -61,8 +61,8 @@ Why: How verified: - Symbols searched: `Middleware()` and `HandleRequest(` - Commands: - - `rg -n "\\.Middleware\\(\\)" internal | sort` - - `rg -n "HandleRequest\\(" internal | sort` + - `rg -n "\\.Middleware\\(\\)" | sort` + - `rg -n "HandleRequest\\(" | sort` Suggested action: - Before deleting the compatibility wrapper, keep equivalent cache-hit and cache-miss coverage around `HandleRequest()`. diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index a819183e..63909ac9 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -21,17 +21,18 @@ import ( // mockUsageReader implements usage.UsageReader for testing. type mockUsageReader struct { - summary *usage.UsageSummary - daily []usage.DailyUsage - modelUsage []usage.ModelUsage - usageLog *usage.UsageLogResult - cacheOverview *usage.CacheOverview - lastUsageLog usage.UsageLogParams - summaryErr error - dailyErr error - modelUsageErr error - usageLogErr error - cacheErr error + summary *usage.UsageSummary + daily []usage.DailyUsage + modelUsage []usage.ModelUsage + usageLog *usage.UsageLogResult + cacheOverview *usage.CacheOverview + lastUsageLog usage.UsageLogParams + lastCacheOverview usage.UsageQueryParams + summaryErr error + dailyErr error + modelUsageErr error + usageLogErr error + cacheErr error } type mockAuditReader struct { @@ -75,7 +76,8 @@ func (m *mockUsageReader) GetUsageLog(_ context.Context, params usage.UsageLogPa return m.usageLog, nil } -func (m *mockUsageReader) GetCacheOverview(_ context.Context, _ usage.UsageQueryParams) (*usage.CacheOverview, error) { +func (m *mockUsageReader) GetCacheOverview(_ context.Context, params usage.UsageQueryParams) (*usage.CacheOverview, error) { + m.lastCacheOverview = params if m.cacheErr != nil { return nil, m.cacheErr } @@ -1240,7 +1242,7 @@ func TestCacheOverview_ReturnsPayloadWhenEnabled(t *testing.T) { h := NewHandler(reader, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{ CacheEnabled: "on", })) - c, rec := newHandlerContext("/admin/api/v1/cache/overview?days=30") + c, rec := newHandlerContext("/admin/api/v1/cache/overview?days=30&user_path=/team") if err := h.CacheOverview(c); err != nil { t.Fatalf("unexpected error: %v", err) @@ -1259,6 +1261,12 @@ func TestCacheOverview_ReturnsPayloadWhenEnabled(t *testing.T) { if len(body.Daily) != 1 || body.Daily[0].ExactHits != 3 { t.Fatalf("unexpected daily payload: %+v", body.Daily) } + if reader.lastCacheOverview.CacheMode != usage.CacheModeCached { + t.Fatalf("CacheMode = %q, want %q", reader.lastCacheOverview.CacheMode, usage.CacheModeCached) + } + if reader.lastCacheOverview.UserPath != "/team" { + t.Fatalf("UserPath = %q, want %q", reader.lastCacheOverview.UserPath, "/team") + } } func TestCacheOverview_ReturnsErrorWhenReaderFails(t *testing.T) { @@ -1283,11 +1291,13 @@ func TestCacheOverview_ReturnsErrorWhenReaderFails(t *testing.T) { if !ok { t.Fatalf("expected error payload, got %v", body) } - if got, ok := errorBody["type"].(string); !ok || got == "" { - t.Fatalf("error.type = %#v, want non-empty string", errorBody["type"]) + if got, ok := errorBody["type"].(string); !ok || got != "internal_error" { + t.Fatalf("error.type = %#v, want %q", errorBody["type"], "internal_error") } - if got, ok := errorBody["message"].(string); !ok || got == "" { - t.Fatalf("error.message = %#v, want non-empty string", errorBody["message"]) + if got, ok := errorBody["message"].(string); !ok || got != "an unexpected error occurred" { + t.Fatalf("error.message = %#v, want %q", errorBody["message"], "an unexpected error occurred") + } else if strings.Contains(got, "boom") { + t.Fatalf("error.message leaked reader error: %q", got) } if _, ok := errorBody["param"]; !ok { t.Fatalf("error.param missing from payload: %v", errorBody) @@ -1295,6 +1305,9 @@ func TestCacheOverview_ReturnsErrorWhenReaderFails(t *testing.T) { if _, ok := errorBody["code"]; !ok { t.Fatalf("error.code missing from payload: %v", errorBody) } + if reader.lastCacheOverview.CacheMode != usage.CacheModeCached { + t.Fatalf("CacheMode = %q, want %q", reader.lastCacheOverview.CacheMode, usage.CacheModeCached) + } } // --- handleError tests --- diff --git a/internal/usage/extractor.go b/internal/usage/extractor.go index 401f84af..610a3031 100644 --- a/internal/usage/extractor.go +++ b/internal/usage/extractor.go @@ -270,10 +270,10 @@ func ExtractFromCachedResponseBody( if entry == nil { entry = &UsageEntry{ ID: uuid.New().String(), - RequestID: requestID, + RequestID: strings.TrimSpace(requestID), Timestamp: time.Now().UTC(), - Model: model, - Provider: provider, + Model: strings.TrimSpace(model), + Provider: strings.TrimSpace(provider), Endpoint: endpoint, CacheType: cacheType, ProviderID: "", @@ -291,8 +291,8 @@ func ExtractFromCachedResponseBody( if normalized := strings.TrimSpace(provider); normalized != "" { entry.Provider = normalized } - if normalized := strings.TrimSpace(endpoint); normalized != "" { - entry.Endpoint = normalized + if endpoint != "" { + entry.Endpoint = endpoint } return entry } diff --git a/internal/usage/extractor_test.go b/internal/usage/extractor_test.go index a17343c8..91baa802 100644 --- a/internal/usage/extractor_test.go +++ b/internal/usage/extractor_test.go @@ -569,6 +569,30 @@ func TestExtractFromCachedResponseBody(t *testing.T) { t.Fatalf("expected zero-token synthetic entry, got %+v", entry) } }) + + t.Run("defaults unknown cache type to exact", func(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-cache", + Model: "gpt-4o-body", + Usage: core.Usage{ + PromptTokens: 2, + CompletionTokens: 1, + TotalTokens: 3, + }, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + entry := ExtractFromCachedResponseBody(body, "req-cache", "gpt-4o", "openai", "/v1/chat/completions", "unknown") + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.CacheType != CacheTypeExact { + t.Fatalf("CacheType = %q, want %q", entry.CacheType, CacheTypeExact) + } + }) } func TestExtractFromChatResponse_WithBatchPricingEndpoint(t *testing.T) {