diff --git a/server/db/queries/prices.sql b/server/db/queries/prices.sql index 0b15a69..d87acde 100644 --- a/server/db/queries/prices.sql +++ b/server/db/queries/prices.sql @@ -35,3 +35,27 @@ LEFT JOIN potluck_requests pr ON pr.id = pkbr.matched_request_id WHERE pkbr.is_duplicate = 0 GROUP BY pkbr.model ORDER BY request_count DESC; + +-- name: ListModelStatsFromRequests :many +-- Per-model aggregate directly from potluck_requests. +-- Covers all providers (including those without billing rows). +-- since scopes the TPS window (48h); token counts are all-time. +SELECT + model, + COUNT(*) AS request_count, + SUM(COALESCE(prompt_tokens, 0)) AS total_input_tokens, + SUM(COALESCE(completion_tokens, 0)) AS total_output_tokens, + AVG( + CASE + WHEN finished_at IS NOT NULL + AND finished_at > started_at + AND started_at >= ? + THEN CAST(COALESCE(completion_tokens, 0) AS REAL) + / (finished_at - started_at) + ELSE NULL + END + ) AS avg_tps +FROM potluck_requests +WHERE status = 'done' +GROUP BY model +ORDER BY request_count DESC; diff --git a/server/internal/api/web/models.go b/server/internal/api/web/models.go index 246b6d4..675d7c7 100644 --- a/server/internal/api/web/models.go +++ b/server/internal/api/web/models.go @@ -23,10 +23,39 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { refreshedAt = toInt64(ts) } - // Stats cover the last 48h. - statsRows, _ := s.Q.ListModelStats(r.Context(), time.Now().Add(-48*time.Hour).Unix()) - statsMap := make(map[string]any, len(statsRows)) - for _, st := range statsRows { + // Stats cover the last 48h. Merge billing-row stats (pioneer) with + // potluck_requests stats (all providers). Request-based stats take + // precedence since they use the full prefixed model ID that matches + // the catalog. + since := time.Now().Add(-48 * time.Hour).Unix() + statsMap := make(map[string]any) + + // First: billing-row stats (legacy pioneer, unprefixed model names). + billingRows, _ := s.Q.ListModelStats(r.Context(), since) + for _, st := range billingRows { + var avgTps *float64 + if st.AvgTps.Valid { + avgTps = &st.AvgTps.Float64 + } + var totalIn, totalOut float64 + if st.TotalInputTokens.Valid { + totalIn = st.TotalInputTokens.Float64 + } + if st.TotalOutputTokens.Valid { + totalOut = st.TotalOutputTokens.Float64 + } + statsMap[st.Model] = map[string]any{ + "request_count": st.RequestCount, + "total_input_tokens": totalIn, + "total_output_tokens": totalOut, + "avg_tps": avgTps, + } + } + + // Second: request-based stats (all providers, prefixed model names). + // These override billing-row stats when keys match. + reqRows, _ := s.Q.ListModelStatsFromRequests(r.Context(), since) + for _, st := range reqRows { var avgTps *float64 if st.AvgTps.Valid { avgTps = &st.AvgTps.Float64 diff --git a/server/internal/store/prices.sql.go b/server/internal/store/prices.sql.go index e9406ca..cbf913a 100644 --- a/server/internal/store/prices.sql.go +++ b/server/internal/store/prices.sql.go @@ -120,6 +120,68 @@ func (q *Queries) ListModelStats(ctx context.Context, startedAt int64) ([]ListMo return items, nil } +const listModelStatsFromRequests = `-- name: ListModelStatsFromRequests :many +SELECT + model, + COUNT(*) AS request_count, + SUM(COALESCE(prompt_tokens, 0)) AS total_input_tokens, + SUM(COALESCE(completion_tokens, 0)) AS total_output_tokens, + AVG( + CASE + WHEN finished_at IS NOT NULL + AND finished_at > started_at + AND started_at >= ? + THEN CAST(COALESCE(completion_tokens, 0) AS REAL) + / (finished_at - started_at) + ELSE NULL + END + ) AS avg_tps +FROM potluck_requests +WHERE status = 'done' +GROUP BY model +ORDER BY request_count DESC +` + +type ListModelStatsFromRequestsRow struct { + Model string `json:"model"` + RequestCount int64 `json:"request_count"` + TotalInputTokens sql.NullFloat64 `json:"total_input_tokens"` + TotalOutputTokens sql.NullFloat64 `json:"total_output_tokens"` + AvgTps sql.NullFloat64 `json:"avg_tps"` +} + +// Per-model aggregate directly from potluck_requests. +// Covers all providers (including those without billing rows). +// since scopes the TPS window (48h); token counts are all-time. +func (q *Queries) ListModelStatsFromRequests(ctx context.Context, startedAt int64) ([]ListModelStatsFromRequestsRow, error) { + rows, err := q.db.QueryContext(ctx, listModelStatsFromRequests, startedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListModelStatsFromRequestsRow{} + for rows.Next() { + var i ListModelStatsFromRequestsRow + if err := rows.Scan( + &i.Model, + &i.RequestCount, + &i.TotalInputTokens, + &i.TotalOutputTokens, + &i.AvgTps, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertModelPrice = `-- name: UpsertModelPrice :exec INSERT INTO model_prices (model, input_micros_per_1k, output_micros_per_1k, updated_at) VALUES (?, ?, ?, ?) diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go index 9b2d0c0..71f0f23 100644 --- a/server/internal/store/querier.go +++ b/server/internal/store/querier.go @@ -110,6 +110,10 @@ type Querier interface { // Per-model aggregate from billing rows + potluck_requests. // since scopes the TPS window (48h); cost/token counts are all-time. ListModelStats(ctx context.Context, startedAt int64) ([]ListModelStatsRow, error) + // Per-model aggregate directly from potluck_requests. + // Covers all providers (including those without billing rows). + // since scopes the TPS window (48h); token counts are all-time. + ListModelStatsFromRequests(ctx context.Context, startedAt int64) ([]ListModelStatsFromRequestsRow, error) // All users with their pool key stats (if any). // Users without keys appear with zero contributions. // private_reservation_micros = sum(max_micros - shared_micros) for active keys.