chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { hasBillableTokens, type NormalizedUsage } from '@/lib/usage/normalize';
|
||||
|
||||
const log = createLogger('UsageStorage');
|
||||
|
||||
/** Base directory for usage logs; lands in the openmaic-data volume in Docker. */
|
||||
function usageDir(baseDir?: string): string {
|
||||
return baseDir ?? path.join(process.cwd(), 'data', 'usage');
|
||||
}
|
||||
|
||||
/** Current month's jsonl file name, e.g. usage/2026-06.jsonl. */
|
||||
function monthlyFile(dir: string, now: Date): string {
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
return path.join(dir, `${y}-${m}.jsonl`);
|
||||
}
|
||||
|
||||
/** What kind of generation produced this usage. */
|
||||
export type UsageKind = 'llm' | 'image' | 'video' | 'tts' | 'asr';
|
||||
/** Unit of the non-token quantity. */
|
||||
export type UsageUnit = 'token' | 'image' | 'second' | 'character';
|
||||
|
||||
/** Input to record one generation's usage. */
|
||||
export interface UsageRecordInput {
|
||||
/** Modality. Defaults to 'llm'. */
|
||||
kind?: UsageKind;
|
||||
source: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
modelString: string;
|
||||
/** Token usage (LLM only). */
|
||||
usage?: NormalizedUsage;
|
||||
/** Non-token quantity: images count / seconds / characters. */
|
||||
quantity?: number;
|
||||
/** Unit for `quantity`. */
|
||||
unit?: UsageUnit;
|
||||
}
|
||||
|
||||
/** A persisted usage row — pure usage, no cost. */
|
||||
export interface UsageRecord {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
kind: UsageKind;
|
||||
source: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
modelString: string;
|
||||
// LLM token counts (0 for non-LLM rows).
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
reasoningTokens: number;
|
||||
// Non-token usage (e.g. image count, video seconds, TTS characters).
|
||||
quantity?: number;
|
||||
unit?: UsageUnit;
|
||||
}
|
||||
|
||||
interface RecordOptions {
|
||||
baseDir?: string;
|
||||
/** Injected clock for deterministic tests. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
function makeId(now: Date): string {
|
||||
counter = (counter + 1) % 1_000_000;
|
||||
return `${now.getTime()}-${counter.toString(36)}`;
|
||||
}
|
||||
|
||||
const ZERO_USAGE: NormalizedUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Records one generation's usage as a jsonl line. Fire-and-forget: never throws —
|
||||
* a logging failure must not break generation.
|
||||
*
|
||||
* - LLM rows: require billable tokens (skips empty usage, e.g. a streamed
|
||||
* OpenAI-compatible response that omitted usage).
|
||||
* - Non-LLM rows (image/video/tts/asr): require quantity > 0.
|
||||
*/
|
||||
export async function recordUsage(
|
||||
input: UsageRecordInput,
|
||||
opts: RecordOptions = {},
|
||||
): Promise<void> {
|
||||
// A test run must never append to the app's real usage log. Any test that
|
||||
// exercises callLLM / streamLLM reaches this through `recordUsageSafe` without
|
||||
// asking for it, and used to write rows into the live `data/usage/` file —
|
||||
// `minimax-auth-test`, `serialization-test` and friends were sitting in there
|
||||
// next to production traffic, corrupting any real usage analysis. Tests that
|
||||
// mean to exercise storage pass an explicit `baseDir` (or mock this module),
|
||||
// so both of those keep working.
|
||||
if (!opts.baseDir && (process.env.VITEST || process.env.NODE_ENV === 'test')) return;
|
||||
|
||||
try {
|
||||
const kind: UsageKind = input.kind ?? 'llm';
|
||||
const usage = input.usage ?? ZERO_USAGE;
|
||||
|
||||
if (kind === 'llm') {
|
||||
if (!hasBillableTokens(usage)) return;
|
||||
} else if (!input.quantity || input.quantity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = opts.now ?? new Date();
|
||||
const record: UsageRecord = {
|
||||
id: makeId(now),
|
||||
createdAt: now.getTime(),
|
||||
kind,
|
||||
source: input.source,
|
||||
providerId: input.providerId,
|
||||
modelId: input.modelId,
|
||||
modelString: input.modelString,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheCreationTokens: usage.cacheCreationTokens,
|
||||
reasoningTokens: usage.reasoningTokens,
|
||||
...(input.quantity != null ? { quantity: input.quantity } : {}),
|
||||
...(input.unit ? { unit: input.unit } : {}),
|
||||
};
|
||||
|
||||
const dir = usageDir(opts.baseDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.appendFile(monthlyFile(dir, now), JSON.stringify(record) + '\n', 'utf-8');
|
||||
} catch (err) {
|
||||
log.warn('Failed to record usage (ignored):', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-LLM modality usage event (image / video / tts / asr). */
|
||||
export interface GenerationUsageInput {
|
||||
kind: Exclude<UsageKind, 'llm'>;
|
||||
unit: UsageUnit;
|
||||
providerId: string;
|
||||
/** The client-requested model id; falls back to providerId when absent. */
|
||||
modelId?: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a non-LLM generation's usage. Thin wrapper over {@link recordUsage}
|
||||
* that derives `source` (= kind) and `modelString` (`provider:model`) from the
|
||||
* modality, so the generate routes don't each repeat that construction.
|
||||
* Fire-and-forget like `recordUsage`.
|
||||
*/
|
||||
export function recordGenerationUsage(input: GenerationUsageInput): Promise<void> {
|
||||
const modelId = input.modelId || input.providerId;
|
||||
return recordUsage({
|
||||
kind: input.kind,
|
||||
unit: input.unit,
|
||||
source: input.kind,
|
||||
providerId: input.providerId,
|
||||
modelId,
|
||||
modelString: `${input.providerId}:${modelId}`,
|
||||
quantity: input.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
interface ReadOptions {
|
||||
baseDir?: string;
|
||||
/** Limit to specific YYYY-MM month files; defaults to all files in the dir. */
|
||||
months?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all usage records (across monthly files). Returns [] when the dir is
|
||||
* absent. Malformed lines are skipped. Legacy rows without `kind` are treated as
|
||||
* 'llm'; any legacy cost fields are simply ignored.
|
||||
*/
|
||||
export async function readUsageRecords(opts: ReadOptions = {}): Promise<UsageRecord[]> {
|
||||
const dir = usageDir(opts.baseDir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await fs.readdir(dir)).filter((f) => f.endsWith('.jsonl'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (opts.months?.length) {
|
||||
files = files.filter((f) => opts.months!.some((m) => f.startsWith(m)));
|
||||
}
|
||||
|
||||
const records: UsageRecord[] = [];
|
||||
for (const file of files.sort()) {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(path.join(dir, file), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed) as UsageRecord;
|
||||
if (!row.kind) row.kind = 'llm'; // backward-compat
|
||||
records.push(row);
|
||||
} catch {
|
||||
// skip malformed line
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
Reference in new issue
Block a user