blob: f7c26a86a0849e87c7762007fb10a5affddb0c33 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { UsageGroupBy, UsageQuery } from '@maka/core/usage-stats/types';
/**
* The Usage aggregation, expressed over the canonical ledger's columns.
*
* These fragments are the SQL half of rules whose vocabulary lives in
* `@maka/core`. Each one names the function it mirrors; change them together.
*/
/** Mirrors `clampCacheReadTokens`: cache reads cannot exceed the prompt they came from. */
export const CACHE_READ_TOKENS = `
CASE WHEN input_tokens IS NULL
THEN COALESCE(cache_read_input_tokens, 0)
ELSE MIN(COALESCE(cache_read_input_tokens, 0), input_tokens)
END`;
/**
* Unpriced records contribute nothing rather than zero. What they cost is
* reported through coverage instead, so a total never claims a call was free
* when the price was simply never resolved.
*/
export const PRICED_COST = `CASE WHEN cost_basis = 'priced' THEN COALESCE(cost_usd, 0) ELSE 0 END`;
/** Only a provider failure is an error. */
const ERROR_ROW = `status = 'failed'`;
export const TOKEN_SUMS = `
SUM(COALESCE(input_tokens, 0)) AS input,
SUM(COALESCE(output_tokens, 0)) AS output,
SUM(COALESCE(cache_miss_input_tokens, 0)) AS cacheMiss,
SUM(${CACHE_READ_TOKENS}) AS cacheRead,
SUM(COALESCE(cache_write_input_tokens, 0)) AS cacheWrite,
SUM(COALESCE(reasoning_tokens, 0)) AS reasoning,
SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)) AS total`;
export const COVERAGE_SUMS = `
COUNT(*) AS attempts,
SUM(cost_basis = 'priced') AS pricedAttempts,
SUM(cost_basis = 'unpriced') AS unpricedAttempts,
SUM(usage_basis = 'reported') AS usageReportedAttempts,
SUM(usage_basis = 'partial') AS usagePartialAttempts,
SUM(usage_basis = 'missing') AS usageMissingAttempts`;
export const REQUEST_SUMS = `
COUNT(*) AS totalRequests,
SUM(${PRICED_COST}) AS totalCostUsd,
SUM(latency_ms) AS totalDurationMs,
SUM((${CACHE_READ_TOKENS}) > 0) AS cacheHitRequests,
SUM(COALESCE(cache_write_input_tokens, 0) > 0) AS cacheCreateRequests,
SUM(${ERROR_ROW}) AS errorRequests`;
export interface SqlFilter {
readonly sql: string;
readonly parameters: readonly (string | number)[];
}
/**
* Rows a query addresses that can be counted.
*
* A tombstone — a row whose stored form was damaged before the ledger held
* columns — matches no filter and is excluded here; {@link unreadableFilter}
* counts it instead.
*/
export function countableFilter(
query: UsageQuery,
range: { readonly from: number; readonly to: number },
): SqlFilter {
const clauses = ['cost_basis IS NOT NULL', 'completed_at >= ?', 'completed_at <= ?'];
const parameters: (string | number)[] = [range.from, range.to];
const equals = (column: string, value: string | undefined) => {
if (value === undefined) return;
clauses.push(`${column} = ?`);
parameters.push(value);
};
equals('session_id', query.sessionId);
equals('provider_id', query.providerId);
equals('model_id', query.modelId);
equals('connection_slug', query.connectionSlug);
if (query.status !== undefined && query.status !== 'all') {
// `interrupted` joins `aborted`: both mean the call stopped short without
// the provider reporting a failure.
if (query.status === 'success') clauses.push(`status = 'completed'`);
else if (query.status === 'error') clauses.push(ERROR_ROW);
else clauses.push(`status NOT IN ('completed', 'failed')`);
}
return { sql: clauses.join(' AND '), parameters };
}
/**
* Rows a query addresses whose pricing was lost.
*
* Scoped by window and Session only — the columns a tombstone keeps. Narrowing
* it by provider or status would drop the row from the report on the strength
* of a field the row no longer has, which is how a total quietly stops
* mentioning spend it cannot account for.
*/
export function unreadableFilter(
query: UsageQuery,
range: { readonly from: number; readonly to: number },
): SqlFilter {
const clauses = ['cost_basis IS NULL', 'completed_at >= ?', 'completed_at <= ?'];
const parameters: (string | number)[] = [range.from, range.to];
if (query.sessionId !== undefined) {
clauses.push('session_id = ?');
parameters.push(query.sessionId);
}
return { sql: clauses.join(' AND '), parameters };
}
/**
* How SQL groups rows for a bucket query.
*
* SQLite decides only which rows belong together; the key string itself is
* still built by `usageBucketKey`, so both Usage sources keep deriving it from
* one place. `MIN(completed_at)` gives the time bucket a timestamp to name
* itself from.
*/
export function bucketGrouping(groupBy: UsageGroupBy): string {
switch (groupBy) {
case 'provider':
return 'provider_id';
case 'model':
return 'provider_id, model_id';
case 'day':
return `strftime('%Y-%m-%d', completed_at / 1000, 'unixepoch')`;
case 'hour':
return `strftime('%Y-%m-%dT%H', completed_at / 1000, 'unixepoch')`;
case 'tool':
// Tool invocations live in their own ledger; nothing here describes them.
return `''`;
}
}
/** SQL aggregates arrive as `null` for an empty set and as bigint-safe numbers. */
export function count(value: unknown): number {
return Number(value ?? 0);
}