Files
boocode/apps/coder/src/services/backends/opencode-usage.ts
indifferentketchup c060778258 feat(coder): Phase 1-UX backend — agent attribution + agent-sessions route + opencode usage
pending_changes.agent stamped at every queue site (native -> 'boocode', dispatched external -> task.agent, manual RightRail -> NULL) + flows through listPending. New GET /api/sessions/:id/agent-sessions -> [{agent,status,has_session,last_active_at}] per (chat,agent). opencode warm server consumes session.next.step.ended, accumulating input_tokens/output_tokens/cost onto agent_sessions (new idempotent columns) via a pure opencode-usage.ts mapper. Tests: agent-sessions.routes (3) + opencode-usage (6); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:14 +00:00

78 lines
2.7 KiB
TypeScript

/**
* v2.6 Phase 1-UX (U.6) — pure mapper for opencode's per-step usage event.
*
* opencode's warm server emits `session.next.step.ended` once per completed LLM
* step (so a multi-tool turn fires it several times). Its `properties` carry the
* step's token + cost accounting:
*
* {
* timestamp: number;
* sessionID: string;
* finish: string;
* cost: number; // USD for this step
* tokens: {
* input: number; output: number; reasoning: number;
* cache: { read: number; write: number };
* };
* snapshot?: string;
* }
*
* (Verified against @opencode-ai/sdk@1.15.12 — `EventSessionNextStepEnded` in
* `dist/v2/gen/types.gen.d.ts`, a member of the `Event` union the SSE loop
* switches on.)
*
* We normalize to the review's target slice `{input, output, cost}` (the
* provider-agnostic `AgentUsage` shape lands later). cache read/write tokens are
* folded into `input` so the persisted input count reflects the real context the
* model billed for; reasoning tokens are folded into `output` since that's what
* the provider counts them as for generation. This keeps the persisted totals a
* faithful sum of what opencode reported, without inventing extra columns yet.
*/
/** The `properties` shape of a `session.next.step.ended` event (subset we read). */
export interface StepEndedProps {
cost: number;
tokens: {
input: number;
output: number;
reasoning: number;
cache: { read: number; write: number };
};
}
/** Normalized per-step usage delta persisted onto the agent_sessions row. */
export interface StepUsage {
input: number;
output: number;
cost: number;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite integer (tokens). */
function n(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? Math.round(x) : 0;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite float (cost USD). */
function f(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? x : 0;
}
/**
* Map a `session.next.step.ended` payload → the normalized `{input, output, cost}`
* delta. Defensive against missing/partial token blocks (the wire is trusted but
* we never want a NaN to poison the accumulated DB total). `input` folds in cache
* read+write; `output` folds in reasoning.
*/
export function stepEndedToUsage(props: Partial<StepEndedProps> | undefined): StepUsage {
const t = props?.tokens;
const cacheRead = n(t?.cache?.read);
const cacheWrite = n(t?.cache?.write);
return {
input: n(t?.input) + cacheRead + cacheWrite,
output: n(t?.output) + n(t?.reasoning),
cost: f(props?.cost),
};
}