mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.
Closes #4199. MUL-3346
295 lines
10 KiB
Go
295 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace / Project dashboard
|
|
//
|
|
// Three read endpoints power the workspace dashboard:
|
|
//
|
|
// GET /api/dashboard/usage/daily per-(date, model) token rows
|
|
// GET /api/dashboard/usage/by-agent per-(agent, model) token rows
|
|
// GET /api/dashboard/agent-runtime per-agent run-time + task counts
|
|
// GET /api/dashboard/runtime/daily per-date run-time + task counts
|
|
//
|
|
// All three accept ?days=N (defaults to 30, capped at 365) and an optional
|
|
// ?project_id=<uuid> to scope the rollup to a single project. With no
|
|
// project_id the data spans the whole workspace.
|
|
//
|
|
// Cost is computed client-side from a per-model pricing table — the model
|
|
// dimension is intentionally preserved on the wire (same convention as the
|
|
// per-runtime usage endpoints).
|
|
//
|
|
// Access control: workspace membership only — we don't filter by per-agent
|
|
// visibility on the dashboard because token spend / run time are workspace-
|
|
// level operational metrics. Agent-detail pages still gate on per-agent
|
|
// access (see GetWorkspaceAgentRunCounts).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parseProjectIDParam reads ?project_id=<uuid> off the URL. Returns a
|
|
// pgtype.UUID with Valid=false when the param is absent so sqlc's nullable
|
|
// argument resolves to SQL NULL and the WHERE clause degrades to "no
|
|
// project filter". On a malformed UUID it writes a 400 and returns
|
|
// ok=false; callers must return immediately.
|
|
func parseProjectIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, bool) {
|
|
raw := r.URL.Query().Get("project_id")
|
|
if raw == "" {
|
|
return pgtype.UUID{}, true
|
|
}
|
|
u, err := util.ParseUUID(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid project_id")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
// DashboardUsageDailyResponse is one (date, provider, model) bucket. Cost-side
|
|
// math happens on the client from a per-model pricing table; provider + model
|
|
// stay on the wire so the client can disambiguate bare model ids that collide
|
|
// across providers (e.g. Cursor's `auto`).
|
|
type DashboardUsageDailyResponse struct {
|
|
Date string `json:"date"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageDaily returns per-(date, model) token rows for the
|
|
// workspace, optionally scoped to a project. Backed by task_usage_hourly,
|
|
// sliced into calendar days under the viewer's tz.
|
|
func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), tz, since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageDaily(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
tz string,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageDailyResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageDaily(ctx, db.ListDashboardUsageDailyParams{
|
|
WorkspaceID: workspaceID,
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
Provider: row.Provider,
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardUsageByAgentResponse is one (agent, provider, model) row. provider
|
|
// rides along for the same cross-provider pricing disambiguation as the daily
|
|
// response; the client folds by agent_id and sums cost.
|
|
type DashboardUsageByAgentResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageByAgent returns per-(agent, model) token aggregates
|
|
// for the workspace, optionally scoped to a project. Backed by
|
|
// task_usage_hourly with the viewer's tz applied to the `?days=` cutoff.
|
|
func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// "By agent" has no date grouping in the SQL — tz only determines
|
|
// the cutoff boundary, not the bucket axis.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage by agent")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageByAgent(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageByAgentResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageByAgent(ctx, db.ListDashboardUsageByAgentParams{
|
|
WorkspaceID: workspaceID,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageByAgentResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageByAgentResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
Provider: row.Provider,
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardAgentRunTimeResponse is one agent's total terminal-task run time
|
|
// over the window. Includes failed tasks so the dashboard can surface how
|
|
// much execution time was spent on runs that didn't succeed.
|
|
type DashboardAgentRunTimeResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardAgentRunTime returns per-agent total task run time (seconds)
|
|
// and task counts for the workspace, optionally scoped to a project. Only
|
|
// terminal tasks (completed or failed) with both started_at and
|
|
// completed_at populated contribute, since queued/running tasks have no
|
|
// finite duration.
|
|
func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Cutoff in the viewer's tz so the "last N days" window matches the
|
|
// per-agent cost card (GetDashboardUsageByAgent).
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardAgentRunTime(r.Context(), db.ListDashboardAgentRunTimeParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardAgentRunTimeResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardAgentRunTimeResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// DashboardRunTimeDailyResponse is one (date) bucket of terminal-task run
|
|
// time and counts. Powers the workspace dashboard's daily Time and Tasks
|
|
// charts — same toggle as Tokens / Cost, different metric.
|
|
type DashboardRunTimeDailyResponse struct {
|
|
Date string `json:"date"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardRunTimeDaily returns per-date total task run time and task
|
|
// counts for the workspace, optionally scoped to a project. Only terminal
|
|
// tasks (completed or failed) with both started_at and completed_at
|
|
// populated contribute. Bucketed by completed_at so the day boundaries
|
|
// line up with the per-agent run-time card.
|
|
func (h *Handler) GetDashboardRunTimeDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Slice day buckets in the viewer's tz so the Time / Tasks charts cut
|
|
// their calendar day identically to the Cost / Tokens charts.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardRunTimeDaily(r.Context(), db.ListDashboardRunTimeDailyParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list daily runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardRunTimeDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardRunTimeDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|