mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
refactor(billing): unify the LLM billing domain
Replace the overlapping usage and cost model with canonical billing primitives centered on ModelRef, ModelHandle, TokenCounts, and BilledModelUsage. This also renames the public API and web surface from usage to billing, removes compatibility aliases, and normalizes provider usage adapters onto the shared billing vocabulary.
This commit is contained in:
parent
0a2fd4b0dc
commit
6ca2833e77
109 changed files with 2403 additions and 1389 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2020,6 +2020,7 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
"fabro-macros",
|
"fabro-macros",
|
||||||
|
"fabro-model",
|
||||||
"fabro-util",
|
"fabro-util",
|
||||||
"hex",
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import * as RunStages from "./routes/run-stages";
|
||||||
import * as RunSettings from "./routes/run-settings";
|
import * as RunSettings from "./routes/run-settings";
|
||||||
import * as RunGraph from "./routes/run-graph";
|
import * as RunGraph from "./routes/run-graph";
|
||||||
import * as RunFiles from "./routes/run-files";
|
import * as RunFiles from "./routes/run-files";
|
||||||
import * as RunUsage from "./routes/run-usage";
|
import * as RunBilling from "./routes/run-billing";
|
||||||
import * as Insights from "./routes/insights";
|
import * as Insights from "./routes/insights";
|
||||||
import * as InsightsEditor from "./routes/insights-editor";
|
import * as InsightsEditor from "./routes/insights-editor";
|
||||||
import * as InsightsNew from "./routes/insights-new";
|
import * as InsightsNew from "./routes/insights-new";
|
||||||
|
|
@ -106,7 +106,7 @@ export const routes: RouteObject[] = [
|
||||||
route("settings", RunSettings),
|
route("settings", RunSettings),
|
||||||
route("graph", RunGraph),
|
route("graph", RunGraph),
|
||||||
route("files", RunFiles),
|
route("files", RunFiles),
|
||||||
route("usage", RunUsage),
|
route("billing", RunBilling),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
route("insights", Insights, {
|
route("insights", Insights, {
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,47 @@
|
||||||
import { apiJson } from "../api";
|
import { apiJson } from "../api";
|
||||||
import { formatDurationSecs } from "../lib/format";
|
import { formatDurationSecs } from "../lib/format";
|
||||||
import type { RunUsage } from "@qltysh/fabro-api-client";
|
import type { RunBilling } from "@qltysh/fabro-api-client";
|
||||||
|
|
||||||
export async function loader({ request, params }: any) {
|
|
||||||
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
|
|
||||||
const stages = usage.stages.map((s) => ({
|
|
||||||
stage: s.stage.name,
|
|
||||||
model: s.model.id,
|
|
||||||
inputTokens: s.usage.input_tokens,
|
|
||||||
outputTokens: s.usage.output_tokens,
|
|
||||||
runtime: formatDurationSecs(s.runtime_secs),
|
|
||||||
cost: s.usage.cost,
|
|
||||||
}));
|
|
||||||
const totalRuntime = formatDurationSecs(usage.totals.runtime_secs);
|
|
||||||
const totalCost = usage.totals.cost;
|
|
||||||
const totalInput = usage.totals.input_tokens;
|
|
||||||
const totalOutput = usage.totals.output_tokens;
|
|
||||||
const modelBreakdown = usage.by_model
|
|
||||||
.map((m) => ({
|
|
||||||
model: m.model.id,
|
|
||||||
stages: m.stages,
|
|
||||||
inputTokens: m.usage.input_tokens,
|
|
||||||
outputTokens: m.usage.output_tokens,
|
|
||||||
cost: m.usage.cost,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.cost - a.cost);
|
|
||||||
return { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(n: number) {
|
function formatTokens(n: number) {
|
||||||
return `${(n / 1000).toFixed(1)}k`;
|
return `${(n / 1000).toFixed(1)}k`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RunUsage({ loaderData }: any) {
|
function formatUsdMicros(usdMicros?: number) {
|
||||||
const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData;
|
return usdMicros == null ? "-" : `$${(usdMicros / 1_000_000).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request, params }: any) {
|
||||||
|
const billing = await apiJson<RunBilling>(`/runs/${params.id}/billing`, { request });
|
||||||
|
const stages = billing.stages.map((stage) => ({
|
||||||
|
stage: stage.stage.name,
|
||||||
|
model: stage.model.id,
|
||||||
|
inputTokens: stage.billing.input_tokens,
|
||||||
|
outputTokens: stage.billing.output_tokens + (stage.billing.reasoning_tokens ?? 0),
|
||||||
|
runtime: formatDurationSecs(stage.runtime_secs),
|
||||||
|
totalUsdMicros: stage.billing.total_usd_micros,
|
||||||
|
}));
|
||||||
|
const totalRuntime = formatDurationSecs(billing.totals.runtime_secs);
|
||||||
|
const totalInput = billing.totals.input_tokens;
|
||||||
|
const totalOutput = billing.totals.output_tokens + (billing.totals.reasoning_tokens ?? 0);
|
||||||
|
const totalUsdMicros = billing.totals.total_usd_micros;
|
||||||
|
const modelBreakdown = billing.by_model
|
||||||
|
.map((entry) => ({
|
||||||
|
model: entry.model.id,
|
||||||
|
stages: entry.stages,
|
||||||
|
inputTokens: entry.billing.input_tokens,
|
||||||
|
outputTokens: entry.billing.output_tokens + (entry.billing.reasoning_tokens ?? 0),
|
||||||
|
totalUsdMicros: entry.billing.total_usd_micros,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
|
||||||
|
return { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunBilling({ loaderData }: any) {
|
||||||
|
const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } =
|
||||||
|
loaderData;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border border-line overflow-hidden">
|
<div className="overflow-hidden rounded-md border border-line">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||||
|
|
@ -44,7 +49,7 @@ export default function RunUsage({ loaderData }: any) {
|
||||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
|
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -53,10 +58,13 @@ export default function RunUsage({ loaderData }: any) {
|
||||||
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
|
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-fg-3">{row.model}</td>
|
<td className="px-4 py-3 font-mono text-xs text-fg-3">{row.model}</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
|
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
|
||||||
|
{formatTokens(row.outputTokens)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">{row.runtime}</td>
|
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">{row.runtime}</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
|
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||||
|
{formatUsdMicros(row.totalUsdMicros)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -65,47 +73,64 @@ export default function RunUsage({ loaderData }: any) {
|
||||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||||
<td />
|
<td />
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
|
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
|
||||||
|
{formatTokens(totalOutput)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||||
|
{totalRuntime}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||||
|
{formatUsdMicros(totalUsdMicros)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">{totalRuntime}</td>
|
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">By Model</h3>
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||||
<div className="rounded-md border border-line overflow-hidden">
|
By Model
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-md border border-line">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{modelBreakdown.map((row) => (
|
{modelBreakdown.map((row) => (
|
||||||
<tr key={row.model} className="border-b border-line last:border-b-0">
|
<tr key={row.model} className="border-b border-line last:border-b-0">
|
||||||
<td className="px-4 py-3 font-mono text-xs text-fg-2">{row.model}</td>
|
<td className="px-4 py-3 font-mono text-xs text-fg-2">{row.model}</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">{row.stages}</td>
|
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
|
{row.stages}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||||
|
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
|
||||||
|
{formatTokens(row.outputTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||||
|
{formatUsdMicros(row.totalUsdMicros)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-line-strong bg-panel/40">
|
<tr className="border-t border-line-strong bg-panel/40">
|
||||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">{stages.length}</td>
|
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
|
{stages.length}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||||
|
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
|
||||||
|
{formatTokens(totalOutput)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||||
|
{formatUsdMicros(totalUsdMicros)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -11,7 +11,7 @@ const tabs = [
|
||||||
{ name: "Overview", path: "", count: null },
|
{ name: "Overview", path: "", count: null },
|
||||||
{ name: "Stages", path: "/stages/detect-drift", count: null },
|
{ name: "Stages", path: "/stages/detect-drift", count: null },
|
||||||
{ name: "Files Changed", path: "/files", count: null },
|
{ name: "Files Changed", path: "/files", count: null },
|
||||||
{ name: "Usage", path: "/usage", count: null },
|
{ name: "Billing", path: "/billing", count: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const handle = { hideHeader: true };
|
export const handle = { hideHeader: true };
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { useTheme } from "../lib/theme";
|
||||||
import { getGraphTheme } from "../lib/graph-theme";
|
import { getGraphTheme } from "../lib/graph-theme";
|
||||||
import { apiJson } from "../api";
|
import { apiJson } from "../api";
|
||||||
import { formatDurationSecs } from "../lib/format";
|
import { formatDurationSecs } from "../lib/format";
|
||||||
import type { PaginatedRunStageList, PaginatedRunList, WorkflowDetail } from "@qltysh/fabro-api-client";
|
import type { PaginatedRunStageList, PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||||
|
|
||||||
|
interface WorkflowGraphResponse {
|
||||||
|
graph: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const handle = { wide: true };
|
export const handle = { wide: true };
|
||||||
|
|
||||||
|
|
@ -35,7 +39,7 @@ export async function loader({ request, params }: any) {
|
||||||
let graphDot: string | null = null;
|
let graphDot: string | null = null;
|
||||||
if (run) {
|
if (run) {
|
||||||
try {
|
try {
|
||||||
const workflow = await apiJson<WorkflowDetail>(`/workflows/${run.workflow}`, { request });
|
const workflow = await apiJson<WorkflowGraphResponse>(`/workflows/${run.workflow}`, { request });
|
||||||
graphDot = workflow.graph;
|
graphDot = workflow.graph;
|
||||||
} catch {
|
} catch {
|
||||||
// workflow not found — leave graphDot null
|
// workflow not found — leave graphDot null
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||||
import { apiJson } from "../api";
|
import { apiJson } from "../api";
|
||||||
import type { WorkflowDetail as ApiWorkflowDetail, RunSettings } from "@qltysh/fabro-api-client";
|
import type { RunSettings } from "@qltysh/fabro-api-client";
|
||||||
|
|
||||||
|
interface ApiWorkflowDetail {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
filename: string;
|
||||||
|
settings: RunSettings;
|
||||||
|
graph: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowEntry {
|
export interface WorkflowEntry {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,27 @@ import {
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { apiJson } from "../api";
|
import { apiJson } from "../api";
|
||||||
import { timeAgo, timeUntil } from "../lib/time";
|
import { timeAgo, timeUntil } from "../lib/time";
|
||||||
import type { PaginatedWorkflowList } from "@qltysh/fabro-api-client";
|
|
||||||
|
interface WorkflowRunSummary {
|
||||||
|
ran_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowScheduleSummary {
|
||||||
|
expression: string;
|
||||||
|
next_run?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowListItem {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
filename: string;
|
||||||
|
last_run?: WorkflowRunSummary | null;
|
||||||
|
schedule?: WorkflowScheduleSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedWorkflowList {
|
||||||
|
data: WorkflowListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export function meta({}: any) {
|
export function meta({}: any) {
|
||||||
return [{ title: "Workflows — Fabro" }];
|
return [{ title: "Workflows — Fabro" }];
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
apps/fabro-web/dist/index.html
vendored
2
apps/fabro-web/dist/index.html
vendored
|
|
@ -61,7 +61,7 @@
|
||||||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||||
<script type="module" src="/assets/entry-xtvaf517.js"></script>
|
<script type="module" src="/assets/entry-r31bcs2m.js"></script>
|
||||||
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
||||||
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
||||||
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ tags:
|
||||||
description: Internal run details (stages, turns, context, configuration)
|
description: Internal run details (stages, turns, context, configuration)
|
||||||
- name: Workflows
|
- name: Workflows
|
||||||
description: Workflow definitions and execution
|
description: Workflow definitions and execution
|
||||||
- name: Usage
|
- name: Billing
|
||||||
description: Token and cost usage
|
description: Token counts and billed totals
|
||||||
- name: Insights
|
- name: Insights
|
||||||
description: SQL query editor and history
|
description: SQL query editor and history
|
||||||
- name: Models
|
- name: Models
|
||||||
|
|
@ -823,21 +823,21 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/api/v1/runs/{id}/usage:
|
/api/v1/runs/{id}/billing:
|
||||||
get:
|
get:
|
||||||
operationId: retrieveRunUsage
|
operationId: retrieveRunBilling
|
||||||
tags: [Run Outputs]
|
tags: [Run Outputs]
|
||||||
summary: Retrieve Run Usage
|
summary: Retrieve Run Billing
|
||||||
description: Returns token and cost usage broken down by stage and model for a specific run.
|
description: Returns token counts and billed totals broken down by stage and model for a specific run.
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/RunId"
|
- $ref: "#/components/parameters/RunId"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Usage data
|
description: Billing data
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/RunUsage"
|
$ref: "#/components/schemas/RunBilling"
|
||||||
"404":
|
"404":
|
||||||
description: Run not found
|
description: Run not found
|
||||||
content:
|
content:
|
||||||
|
|
@ -1186,21 +1186,21 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/PaginatedHistoryEntryList"
|
$ref: "#/components/schemas/PaginatedHistoryEntryList"
|
||||||
|
|
||||||
# ── Usage ────────────────────────────────────────────────────────────
|
# ── Billing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/api/v1/usage:
|
/api/v1/billing:
|
||||||
get:
|
get:
|
||||||
operationId: getAggregateUsage
|
operationId: getAggregateBilling
|
||||||
tags: [Usage]
|
tags: [Billing]
|
||||||
summary: Aggregate Usage
|
summary: Aggregate Billing
|
||||||
description: Returns aggregate token/cost usage across all completed runs since server start.
|
description: Returns aggregate token counts and billed totals across all completed runs since server start.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Aggregate usage data
|
description: Aggregate billing data
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AggregateUsage"
|
$ref: "#/components/schemas/AggregateBilling"
|
||||||
|
|
||||||
# ── System ───────────────────────────────────────────────────────────
|
# ── System ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -2955,9 +2955,9 @@ components:
|
||||||
format: int64
|
format: int64
|
||||||
minimum: 0
|
minimum: 0
|
||||||
nullable: true
|
nullable: true
|
||||||
total_cost:
|
total_usd_micros:
|
||||||
type: number
|
type: integer
|
||||||
format: double
|
format: int64
|
||||||
nullable: true
|
nullable: true
|
||||||
|
|
||||||
# ── Run Board Schemas ────────────────────────────────────────────────
|
# ── Run Board Schemas ────────────────────────────────────────────────
|
||||||
|
|
@ -3050,13 +3050,13 @@ components:
|
||||||
description: Repository name.
|
description: Repository name.
|
||||||
example: api-server
|
example: api-server
|
||||||
|
|
||||||
TokenUsage:
|
BilledTokenCounts:
|
||||||
description: Token and cost usage totals.
|
description: Token counts with optional billed USD micros totals.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- input_tokens
|
- input_tokens
|
||||||
- output_tokens
|
- output_tokens
|
||||||
- cost
|
- total_tokens
|
||||||
properties:
|
properties:
|
||||||
input_tokens:
|
input_tokens:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
@ -3066,10 +3066,28 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
description: Number of output tokens generated.
|
description: Number of output tokens generated.
|
||||||
example: 8750
|
example: 8750
|
||||||
cost:
|
total_tokens:
|
||||||
type: number
|
type: integer
|
||||||
description: Cost in USD.
|
description: Total billable tokens aggregated across categories.
|
||||||
example: 0.72
|
example: 37390
|
||||||
|
reasoning_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Number of reasoning tokens.
|
||||||
|
example: 1200
|
||||||
|
cache_read_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Number of cache read tokens.
|
||||||
|
example: 4800
|
||||||
|
cache_write_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Number of cache write tokens.
|
||||||
|
example: 1500
|
||||||
|
total_usd_micros:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
|
description: Billed USD amount in micros.
|
||||||
|
example: 720000
|
||||||
|
|
||||||
CodeLocation:
|
CodeLocation:
|
||||||
description: A file and line location in the codebase.
|
description: A file and line location in the codebase.
|
||||||
|
|
@ -3180,14 +3198,14 @@ components:
|
||||||
description: Question text.
|
description: Question text.
|
||||||
example: Accept or push for another round?
|
example: Accept or push for another round?
|
||||||
|
|
||||||
AggregateUsageTotals:
|
AggregateBillingTotals:
|
||||||
description: Aggregate usage totals across all runs.
|
description: Aggregate billing totals across all runs.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- runs
|
- runs
|
||||||
- input_tokens
|
- input_tokens
|
||||||
- output_tokens
|
- output_tokens
|
||||||
- cost
|
- total_tokens
|
||||||
- runtime_secs
|
- runtime_secs
|
||||||
properties:
|
properties:
|
||||||
runs:
|
runs:
|
||||||
|
|
@ -3202,17 +3220,35 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
description: Total output tokens.
|
description: Total output tokens.
|
||||||
example: 189720
|
example: 189720
|
||||||
cost:
|
total_tokens:
|
||||||
type: number
|
type: integer
|
||||||
description: Total cost in USD.
|
description: Total tokens aggregated across all billing categories.
|
||||||
example: 20.34
|
example: 833580
|
||||||
|
reasoning_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total reasoning tokens.
|
||||||
|
example: 12040
|
||||||
|
cache_read_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total cache read tokens.
|
||||||
|
example: 85400
|
||||||
|
cache_write_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total cache write tokens.
|
||||||
|
example: 9200
|
||||||
|
total_usd_micros:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
|
description: Total billed USD amount in micros.
|
||||||
|
example: 20340000
|
||||||
runtime_secs:
|
runtime_secs:
|
||||||
type: number
|
type: number
|
||||||
description: Total runtime in seconds.
|
description: Total runtime in seconds.
|
||||||
example: 3501.0
|
example: 3501.0
|
||||||
|
|
||||||
UsageStageRef:
|
BillingStageRef:
|
||||||
description: Reference to a usage stage.
|
description: Reference to a billing stage.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
|
|
@ -3530,36 +3566,36 @@ components:
|
||||||
meta:
|
meta:
|
||||||
$ref: "#/components/schemas/PaginationMeta"
|
$ref: "#/components/schemas/PaginationMeta"
|
||||||
|
|
||||||
# ── Usage Schemas ────────────────────────────────────────────────────
|
# ── Billing Schemas ──────────────────────────────────────────────────
|
||||||
|
|
||||||
UsageStage:
|
RunBillingStage:
|
||||||
description: Token and cost usage for a single stage within a run.
|
description: Token counts and billed totals for a single stage within a run.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- stage
|
- stage
|
||||||
- model
|
- model
|
||||||
- usage
|
- billing
|
||||||
- runtime_secs
|
- runtime_secs
|
||||||
properties:
|
properties:
|
||||||
stage:
|
stage:
|
||||||
$ref: "#/components/schemas/UsageStageRef"
|
$ref: "#/components/schemas/BillingStageRef"
|
||||||
model:
|
model:
|
||||||
$ref: "#/components/schemas/ModelReference"
|
$ref: "#/components/schemas/ModelReference"
|
||||||
usage:
|
billing:
|
||||||
$ref: "#/components/schemas/TokenUsage"
|
$ref: "#/components/schemas/BilledTokenCounts"
|
||||||
runtime_secs:
|
runtime_secs:
|
||||||
type: number
|
type: number
|
||||||
description: Wall-clock runtime in seconds.
|
description: Wall-clock runtime in seconds.
|
||||||
example: 154.0
|
example: 154.0
|
||||||
|
|
||||||
UsageTotals:
|
RunBillingTotals:
|
||||||
description: Aggregate usage totals across all stages of a run.
|
description: Aggregate billing totals across all stages of a run.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- runtime_secs
|
- runtime_secs
|
||||||
- input_tokens
|
- input_tokens
|
||||||
- output_tokens
|
- output_tokens
|
||||||
- cost
|
- total_tokens
|
||||||
properties:
|
properties:
|
||||||
runtime_secs:
|
runtime_secs:
|
||||||
type: number
|
type: number
|
||||||
|
|
@ -3573,18 +3609,36 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
description: Total output tokens generated.
|
description: Total output tokens generated.
|
||||||
example: 21080
|
example: 21080
|
||||||
cost:
|
total_tokens:
|
||||||
type: number
|
type: integer
|
||||||
description: Total cost in USD.
|
description: Total tokens aggregated across all billing categories.
|
||||||
example: 2.26
|
example: 92620
|
||||||
|
reasoning_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total reasoning tokens.
|
||||||
|
example: 3400
|
||||||
|
cache_read_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total cache read tokens.
|
||||||
|
example: 22000
|
||||||
|
cache_write_tokens:
|
||||||
|
type: integer
|
||||||
|
description: Total cache write tokens.
|
||||||
|
example: 4500
|
||||||
|
total_usd_micros:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
|
description: Total billed USD amount in micros.
|
||||||
|
example: 2260000
|
||||||
|
|
||||||
UsageByModel:
|
BillingByModel:
|
||||||
description: Usage statistics grouped by model.
|
description: Billing statistics grouped by model.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- model
|
- model
|
||||||
- stages
|
- stages
|
||||||
- usage
|
- billing
|
||||||
properties:
|
properties:
|
||||||
model:
|
model:
|
||||||
$ref: "#/components/schemas/ModelReference"
|
$ref: "#/components/schemas/ModelReference"
|
||||||
|
|
@ -3592,11 +3646,11 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
description: Number of stages that used this model.
|
description: Number of stages that used this model.
|
||||||
example: 2
|
example: 2
|
||||||
usage:
|
billing:
|
||||||
$ref: "#/components/schemas/TokenUsage"
|
$ref: "#/components/schemas/BilledTokenCounts"
|
||||||
|
|
||||||
RunUsage:
|
RunBilling:
|
||||||
description: Complete usage breakdown for a single run.
|
description: Complete billing breakdown for a single run.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- stages
|
- stages
|
||||||
|
|
@ -3605,31 +3659,31 @@ components:
|
||||||
properties:
|
properties:
|
||||||
stages:
|
stages:
|
||||||
type: array
|
type: array
|
||||||
description: Per-stage usage breakdown.
|
description: Per-stage billing breakdown.
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/UsageStage"
|
$ref: "#/components/schemas/RunBillingStage"
|
||||||
totals:
|
totals:
|
||||||
$ref: "#/components/schemas/UsageTotals"
|
$ref: "#/components/schemas/RunBillingTotals"
|
||||||
by_model:
|
by_model:
|
||||||
type: array
|
type: array
|
||||||
description: Usage grouped by model.
|
description: Billing grouped by model.
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/UsageByModel"
|
$ref: "#/components/schemas/BillingByModel"
|
||||||
|
|
||||||
AggregateUsage:
|
AggregateBilling:
|
||||||
description: Aggregate token and cost usage across all runs since server start.
|
description: Aggregate token counts and billed totals across all runs since server start.
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- totals
|
- totals
|
||||||
- by_model
|
- by_model
|
||||||
properties:
|
properties:
|
||||||
totals:
|
totals:
|
||||||
$ref: "#/components/schemas/AggregateUsageTotals"
|
$ref: "#/components/schemas/AggregateBillingTotals"
|
||||||
by_model:
|
by_model:
|
||||||
type: array
|
type: array
|
||||||
description: Usage grouped by model.
|
description: Billing grouped by model.
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/UsageByModel"
|
$ref: "#/components/schemas/BillingByModel"
|
||||||
|
|
||||||
PreviewUrlRequest:
|
PreviewUrlRequest:
|
||||||
description: Request body for generating a preview URL from a sandbox port.
|
description: Request body for generating a preview URL from a sandbox port.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
|
||||||
use fabro_llm::provider::StreamEventStream;
|
use fabro_llm::provider::StreamEventStream;
|
||||||
use fabro_llm::types::{Request, Response};
|
use fabro_llm::types::{Request, Response};
|
||||||
use fabro_mcp::config::McpServerSettings;
|
use fabro_mcp::config::McpServerSettings;
|
||||||
use fabro_model::{Catalog, ModelRef, Provider};
|
use fabro_model::{Catalog, ModelHandle, Provider};
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
use std::io::{IsTerminal, Write};
|
use std::io::{IsTerminal, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -166,8 +166,8 @@ fn build_tool_approval(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarizer_model_id(provider: Provider) -> ModelRef {
|
fn summarizer_model_id(provider: Provider) -> ModelHandle {
|
||||||
ModelRef::ByName {
|
ModelHandle::ByName {
|
||||||
provider,
|
provider,
|
||||||
model: match provider {
|
model: match provider {
|
||||||
Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini",
|
Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini",
|
||||||
|
|
@ -257,7 +257,7 @@ fn print_summary(session: &Session, styles: &Styles) {
|
||||||
{
|
{
|
||||||
turn_count += 1;
|
turn_count += 1;
|
||||||
tool_call_count += tool_calls.len();
|
tool_call_count += tool_calls.len();
|
||||||
total_tokens += usage.total_tokens;
|
total_tokens += usage.total_tokens();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let token_str = if total_tokens >= 1_000_000 {
|
let token_str = if total_tokens >= 1_000_000 {
|
||||||
|
|
@ -303,7 +303,7 @@ impl Middleware for DebugMiddleware {
|
||||||
response.finish_reason,
|
response.finish_reason,
|
||||||
response.usage.input_tokens,
|
response.usage.input_tokens,
|
||||||
response.usage.output_tokens,
|
response.usage.output_tokens,
|
||||||
response.usage.total_tokens,
|
response.usage.total_tokens(),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
Ok(response)
|
Ok(response)
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ mod tests {
|
||||||
use crate::test_support::TestProfile;
|
use crate::test_support::TestProfile;
|
||||||
use crate::tool_registry::ToolRegistry;
|
use crate::tool_registry::ToolRegistry;
|
||||||
use crate::types::Turn;
|
use crate::types::Turn;
|
||||||
use fabro_llm::types::{ToolCall, ToolResult, Usage};
|
use fabro_llm::types::{TokenCounts, ToolCall, ToolResult};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -274,7 +274,7 @@ mod tests {
|
||||||
serde_json::json!({"path": "foo.rs"}),
|
serde_json::json!({"path": "foo.rs"}),
|
||||||
)],
|
)],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ fn extract_recent_user_messages(discarded: Vec<Turn>, token_budget: usize) -> Ve
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fabro_llm::types::{ThinkingData, ToolCall, ToolResult, Usage};
|
use fabro_llm::types::{ThinkingData, TokenCounts, ToolCall, ToolResult};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -231,7 +231,7 @@ mod tests {
|
||||||
content: "Hi there".into(),
|
content: "Hi there".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -249,7 +249,7 @@ mod tests {
|
||||||
content: "Let me read that".into(),
|
content: "Let me read that".into(),
|
||||||
tool_calls: vec![tc],
|
tool_calls: vec![tc],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_2".into(),
|
response_id: "resp_2".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -275,7 +275,7 @@ mod tests {
|
||||||
content: "The answer is 42".into(),
|
content: "The answer is 42".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![thinking],
|
provider_parts: vec![thinking],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_3".into(),
|
response_id: "resp_3".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -300,7 +300,7 @@ mod tests {
|
||||||
content: "The answer".into(),
|
content: "The answer".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![thinking],
|
provider_parts: vec![thinking],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_4".into(),
|
response_id: "resp_4".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -331,7 +331,7 @@ mod tests {
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
tool_calls: vec![tc],
|
tool_calls: vec![tc],
|
||||||
provider_parts: vec![reasoning_item],
|
provider_parts: vec![reasoning_item],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -397,7 +397,7 @@ mod tests {
|
||||||
content: "Second".into(),
|
content: "Second".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -423,10 +423,9 @@ mod tests {
|
||||||
signature: None,
|
signature: None,
|
||||||
redacted: false,
|
redacted: false,
|
||||||
})],
|
})],
|
||||||
usage: Box::new(Usage {
|
usage: Box::new(TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
|
|
@ -467,7 +466,7 @@ mod tests {
|
||||||
content: "response".into(),
|
content: "response".into(),
|
||||||
tool_calls: vec![tc],
|
tool_calls: vec![tc],
|
||||||
provider_parts: vec![reasoning],
|
provider_parts: vec![reasoning],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -514,7 +513,7 @@ mod tests {
|
||||||
content: "answer".into(),
|
content: "answer".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![thinking],
|
provider_parts: vec![thinking],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp_1".into(),
|
response_id: "resp_1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -551,7 +550,7 @@ mod tests {
|
||||||
kind: ContentPart::OPENAI_REASONING.into(),
|
kind: ContentPart::OPENAI_REASONING.into(),
|
||||||
data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
|
data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
|
||||||
}],
|
}],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: format!("resp_{i}"),
|
response_id: format!("resp_{i}"),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -580,7 +579,7 @@ mod tests {
|
||||||
content: "reply".into(),
|
content: "reply".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "r1".into(),
|
response_id: "r1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
},
|
},
|
||||||
|
|
@ -624,7 +623,7 @@ mod tests {
|
||||||
content: "assistant msg".into(),
|
content: "assistant msg".into(),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "r1".into(),
|
response_id: "r1".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fabro_llm::types::{ToolCall, Usage};
|
use fabro_llm::types::{TokenCounts, ToolCall};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn {
|
fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn {
|
||||||
|
|
@ -102,7 +102,7 @@ mod tests {
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
tool_calls: vec![ToolCall::new("call_1", name, args)],
|
tool_calls: vec![ToolCall::new("call_1", name, args)],
|
||||||
provider_parts: vec![],
|
provider_parts: vec![],
|
||||||
usage: Box::new(Usage::default()),
|
usage: Box::new(TokenCounts::default()),
|
||||||
response_id: "resp".into(),
|
response_id: "resp".into(),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ use async_trait::async_trait;
|
||||||
use fabro_llm::client::Client;
|
use fabro_llm::client::Client;
|
||||||
use fabro_llm::error::SdkError;
|
use fabro_llm::error::SdkError;
|
||||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||||
use fabro_llm::types::{ContentPart, FinishReason, Message, Request, Response, StreamEvent, Usage};
|
use fabro_llm::types::{
|
||||||
|
ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts,
|
||||||
|
};
|
||||||
use fabro_model::Provider;
|
use fabro_model::Provider;
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -172,10 +174,9 @@ pub fn text_response(text: &str) -> Response {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -248,10 +249,9 @@ pub fn tool_call_response(
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -401,10 +401,9 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) ->
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::sandbox::GrepOptions;
|
||||||
use crate::tool_registry::{RegisteredTool, ToolRegistry};
|
use crate::tool_registry::{RegisteredTool, ToolRegistry};
|
||||||
use fabro_llm::client::Client;
|
use fabro_llm::client::Client;
|
||||||
use fabro_llm::types::{Message, Request, ToolDefinition};
|
use fabro_llm::types::{Message, Request, ToolDefinition};
|
||||||
use fabro_model::ModelRef;
|
use fabro_model::ModelHandle;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -14,7 +14,7 @@ const MAX_WEB_FETCH_BYTES: usize = 100 * 1024;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebFetchSummarizer {
|
pub struct WebFetchSummarizer {
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
pub model_id: ModelRef,
|
pub model_id: ModelHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the input looks like it contains HTML markup.
|
/// Returns true if the input looks like it contains HTML markup.
|
||||||
|
|
@ -1257,7 +1257,7 @@ mod tests {
|
||||||
let client = make_client(provider).await;
|
let client = make_client(provider).await;
|
||||||
let summarizer = WebFetchSummarizer {
|
let summarizer = WebFetchSummarizer {
|
||||||
client,
|
client,
|
||||||
model_id: ModelRef::ByName {
|
model_id: ModelHandle::ByName {
|
||||||
provider: fabro_model::Provider::Anthropic,
|
provider: fabro_model::Provider::Anthropic,
|
||||||
model: "mock-model".to_string(),
|
model: "mock-model".to_string(),
|
||||||
},
|
},
|
||||||
|
|
@ -1353,7 +1353,7 @@ mod tests {
|
||||||
|
|
||||||
let summarizer = WebFetchSummarizer {
|
let summarizer = WebFetchSummarizer {
|
||||||
client,
|
client,
|
||||||
model_id: ModelRef::ByName {
|
model_id: ModelHandle::ByName {
|
||||||
provider: fabro_model::Provider::Anthropic,
|
provider: fabro_model::Provider::Anthropic,
|
||||||
model: "target-model".to_string(),
|
model: "target-model".to_string(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
use fabro_llm::error::SdkError;
|
use fabro_llm::error::SdkError;
|
||||||
use fabro_llm::types::{ContentPart, ThinkingData, ToolCall, ToolResult, Usage};
|
use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub enum Turn {
|
||||||
/// `Anthropic` thinking blocks with signatures) preserved for round-tripping.
|
/// `Anthropic` thinking blocks with signatures) preserved for round-tripping.
|
||||||
/// Reasoning/thinking text is stored here as `ContentPart::Thinking`.
|
/// Reasoning/thinking text is stored here as `ContentPart::Thinking`.
|
||||||
provider_parts: Vec<ContentPart>,
|
provider_parts: Vec<ContentPart>,
|
||||||
usage: Box<Usage>,
|
usage: Box<TokenCounts>,
|
||||||
response_id: String,
|
response_id: String,
|
||||||
timestamp: SystemTime,
|
timestamp: SystemTime,
|
||||||
},
|
},
|
||||||
|
|
@ -112,7 +112,7 @@ pub enum AgentEvent {
|
||||||
AssistantMessage {
|
AssistantMessage {
|
||||||
text: String,
|
text: String,
|
||||||
model: String,
|
model: String,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
tool_call_count: usize,
|
tool_call_count: usize,
|
||||||
},
|
},
|
||||||
TextDelta {
|
TextDelta {
|
||||||
|
|
@ -661,15 +661,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_event_assistant_message() {
|
fn agent_event_assistant_message() {
|
||||||
let usage = Usage {
|
let usage = TokenCounts {
|
||||||
input_tokens: 100,
|
input_tokens: 100,
|
||||||
output_tokens: 50,
|
output_tokens: 50,
|
||||||
total_tokens: 150,
|
cache_read_tokens: 80,
|
||||||
cache_read_tokens: Some(80),
|
cache_write_tokens: 10,
|
||||||
cache_write_tokens: Some(10),
|
reasoning_tokens: 20,
|
||||||
reasoning_tokens: Some(20),
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
let event = AgentEvent::AssistantMessage {
|
let event = AgentEvent::AssistantMessage {
|
||||||
text: "Hello".into(),
|
text: "Hello".into(),
|
||||||
|
|
@ -685,8 +682,8 @@ mod tests {
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*tool_call_count, 2);
|
assert_eq!(*tool_call_count, 2);
|
||||||
assert_eq!(usage.input_tokens, 100);
|
assert_eq!(usage.input_tokens, 100);
|
||||||
assert_eq!(usage.cache_read_tokens, Some(80));
|
assert_eq!(usage.cache_read_tokens, 80);
|
||||||
assert_eq!(usage.reasoning_tokens, Some(20));
|
assert_eq!(usage.reasoning_tokens, 20);
|
||||||
}
|
}
|
||||||
_ => panic!("expected AssistantMessage"),
|
_ => panic!("expected AssistantMessage"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use fabro_agent::{
|
||||||
use fabro_llm::client::Client;
|
use fabro_llm::client::Client;
|
||||||
use fabro_llm::provider::{Provider, ProviderAdapter};
|
use fabro_llm::provider::{Provider, ProviderAdapter};
|
||||||
use fabro_llm::providers::OpenAiAdapter;
|
use fabro_llm::providers::OpenAiAdapter;
|
||||||
use fabro_model::ModelRef;
|
use fabro_model::ModelHandle;
|
||||||
use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai};
|
use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai};
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
||||||
|
|
@ -21,22 +21,22 @@ struct OpenAiTwinOptions {
|
||||||
api_key: String,
|
api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarizer_model_id(provider: Provider) -> ModelRef {
|
fn summarizer_model_id(provider: Provider) -> ModelHandle {
|
||||||
match provider {
|
match provider {
|
||||||
Provider::OpenAi
|
Provider::OpenAi
|
||||||
| Provider::Kimi
|
| Provider::Kimi
|
||||||
| Provider::Zai
|
| Provider::Zai
|
||||||
| Provider::Minimax
|
| Provider::Minimax
|
||||||
| Provider::Inception
|
| Provider::Inception
|
||||||
| Provider::OpenAiCompatible => ModelRef::ByName {
|
| Provider::OpenAiCompatible => ModelHandle::ByName {
|
||||||
provider: Provider::OpenAi,
|
provider: Provider::OpenAi,
|
||||||
model: "gpt-5.4-mini".to_string(),
|
model: "gpt-5.4-mini".to_string(),
|
||||||
},
|
},
|
||||||
Provider::Gemini => ModelRef::ByName {
|
Provider::Gemini => ModelHandle::ByName {
|
||||||
provider: Provider::Gemini,
|
provider: Provider::Gemini,
|
||||||
model: "gemini-3-flash-preview".to_string(),
|
model: "gemini-3-flash-preview".to_string(),
|
||||||
},
|
},
|
||||||
Provider::Anthropic => ModelRef::ByName {
|
Provider::Anthropic => ModelHandle::ByName {
|
||||||
provider: Provider::Anthropic,
|
provider: Provider::Anthropic,
|
||||||
model: "claude-haiku-4-5".to_string(),
|
model: "claude-haiku-4-5".to_string(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use tracing::{debug, info};
|
||||||
use crate::args::{GlobalArgs, LogsArgs};
|
use crate::args::{GlobalArgs, LogsArgs};
|
||||||
use crate::server_client;
|
use crate::server_client;
|
||||||
use crate::server_runs::ServerSummaryLookup;
|
use crate::server_runs::ServerSummaryLookup;
|
||||||
|
use crate::shared::format_usd_micros;
|
||||||
|
|
||||||
const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500);
|
const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
|
@ -323,7 +324,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
|
||||||
"success" | "partial_success" => &styles.bold_green,
|
"success" | "partial_success" => &styles.bold_green,
|
||||||
_ => &styles.bold_red,
|
_ => &styles.bold_red,
|
||||||
};
|
};
|
||||||
let cost = format_cost(prop_field(&envelope, "total_cost"));
|
let cost = format_cost(
|
||||||
|
prop_field(&envelope, "total_usd_micros")
|
||||||
|
.or_else(|| prop_field(&envelope, "total_cost")),
|
||||||
|
);
|
||||||
|
|
||||||
let mut lines = vec![format!(
|
let mut lines = vec![format!(
|
||||||
"{} {} {} {}",
|
"{} {} {} {}",
|
||||||
|
|
@ -333,8 +337,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
|
||||||
styles.dim.apply_to(&cost),
|
styles.dim.apply_to(&cost),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
if let Some(usage) = prop_field(&envelope, "usage") {
|
if let Some(billing) =
|
||||||
let total = usage
|
prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage"))
|
||||||
|
{
|
||||||
|
let total = billing
|
||||||
.get("total_tokens")
|
.get("total_tokens")
|
||||||
.and_then(serde_json::Value::as_i64)
|
.and_then(serde_json::Value::as_i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
@ -349,11 +355,11 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
|
||||||
))
|
))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(cache_read) = usage
|
if let Some(cache_read) = billing
|
||||||
.get("cache_read_tokens")
|
.get("cache_read_tokens")
|
||||||
.and_then(serde_json::Value::as_i64)
|
.and_then(serde_json::Value::as_i64)
|
||||||
{
|
{
|
||||||
let cache_write = usage
|
let cache_write = billing
|
||||||
.get("cache_write_tokens")
|
.get("cache_write_tokens")
|
||||||
.and_then(serde_json::Value::as_i64)
|
.and_then(serde_json::Value::as_i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
@ -367,7 +373,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
|
||||||
))
|
))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(reasoning) = usage
|
if let Some(reasoning) = billing
|
||||||
.get("reasoning_tokens")
|
.get("reasoning_tokens")
|
||||||
.and_then(serde_json::Value::as_i64)
|
.and_then(serde_json::Value::as_i64)
|
||||||
{
|
{
|
||||||
|
|
@ -429,13 +435,18 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
|
||||||
"stage.completed" => {
|
"stage.completed" => {
|
||||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||||
let duration = format_duration_ms(prop_field(&envelope, "duration_ms"));
|
let duration = format_duration_ms(prop_field(&envelope, "duration_ms"));
|
||||||
let usage = prop_field(&envelope, "usage");
|
let billing =
|
||||||
let cost = format_cost(usage.and_then(|value| value.get("cost")));
|
prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage"));
|
||||||
let input_tokens = usage
|
let cost = format_cost(
|
||||||
|
billing
|
||||||
|
.and_then(|value| value.get("total_usd_micros"))
|
||||||
|
.or_else(|| billing.and_then(|value| value.get("cost"))),
|
||||||
|
);
|
||||||
|
let input_tokens = billing
|
||||||
.and_then(|value| value.get("input_tokens"))
|
.and_then(|value| value.get("input_tokens"))
|
||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let output_tokens = usage
|
let output_tokens = billing
|
||||||
.and_then(|value| value.get("output_tokens"))
|
.and_then(|value| value.get("output_tokens"))
|
||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
@ -698,11 +709,21 @@ fn format_duration_ms(value: Option<&serde_json::Value>) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_cost(value: Option<&serde_json::Value>) -> String {
|
fn format_cost(value: Option<&serde_json::Value>) -> String {
|
||||||
let cost = value.and_then(serde_json::Value::as_f64).unwrap_or(0.0);
|
match value {
|
||||||
if cost > 0.0 {
|
Some(value) => {
|
||||||
format!("${cost:.2}")
|
if let Some(usd_micros) = value.as_i64() {
|
||||||
} else {
|
if usd_micros > 0 {
|
||||||
String::new()
|
return format_usd_micros(usd_micros);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cost = value.as_f64().unwrap_or(0.0);
|
||||||
|
if cost > 0.0 {
|
||||||
|
format!("${cost:.2}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -934,7 +955,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn pretty_workflow_run_completed() {
|
fn pretty_workflow_run_completed() {
|
||||||
let styles = no_color_styles();
|
let styles = no_color_styles();
|
||||||
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
|
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_usd_micros":570000,"billing":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
|
||||||
let result = format_event_pretty(line, &styles).unwrap();
|
let result = format_event_pretty(line, &styles).unwrap();
|
||||||
assert!(result.contains("SUCCESS"), "got: {result}");
|
assert!(result.contains("SUCCESS"), "got: {result}");
|
||||||
assert!(result.contains("25s"), "got: {result}");
|
assert!(result.contains("25s"), "got: {result}");
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSecti
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
use fabro_util::text::strip_goal_decoration;
|
use fabro_util::text::strip_goal_decoration;
|
||||||
use fabro_workflow::artifact_snapshot::collect_artifact_paths;
|
use fabro_workflow::artifact_snapshot::collect_artifact_paths;
|
||||||
use fabro_workflow::outcome::{StageStatus, format_cost};
|
use fabro_workflow::outcome::StageStatus;
|
||||||
use fabro_workflow::records::Conclusion;
|
use fabro_workflow::records::Conclusion;
|
||||||
use indicatif::HumanDuration;
|
use indicatif::HumanDuration;
|
||||||
|
|
||||||
use crate::server_client;
|
use crate::server_client;
|
||||||
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};
|
use crate::shared::{
|
||||||
|
format_tokens_human, format_usd_micros, print_diagnostics, relative_path, tilde_path,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) fn print_preflight_workflow_summary(
|
pub(crate) fn print_preflight_workflow_summary(
|
||||||
workflow: &types::PreflightWorkflowSummary,
|
workflow: &types::PreflightWorkflowSummary,
|
||||||
|
|
@ -177,22 +179,54 @@ pub(crate) fn print_run_conclusion(
|
||||||
HumanDuration(Duration::from_millis(conclusion.duration_ms))
|
HumanDuration(Duration::from_millis(conclusion.duration_ms))
|
||||||
);
|
);
|
||||||
|
|
||||||
let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens;
|
if let Some(billing) = conclusion.billing.as_ref() {
|
||||||
if total_tokens > 0 {
|
let total_tokens = i64::try_from(billing.total_tokens).unwrap_or(i64::MAX);
|
||||||
if conclusion.has_pricing {
|
if total_tokens > 0 {
|
||||||
if let Some(cost) = conclusion.total_cost {
|
if let Some(total_usd_micros) = billing.total_usd_micros {
|
||||||
if cost > 0.0 {
|
if total_usd_micros > 0 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
styles.dim.apply_to(format!(
|
styles.dim.apply_to(format!(
|
||||||
"Cost: {} ({} toks)",
|
"Cost: {} ({} toks)",
|
||||||
format_cost(cost),
|
format_usd_micros(total_usd_micros),
|
||||||
format_tokens_human(total_tokens)
|
format_tokens_human(total_tokens)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles
|
||||||
|
.dim
|
||||||
|
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
if billing.cache_read_tokens > 0 || billing.cache_write_tokens > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"Cache: {} read, {} write",
|
||||||
|
format_tokens_human(
|
||||||
|
i64::try_from(billing.cache_read_tokens).unwrap_or(i64::MAX)
|
||||||
|
),
|
||||||
|
format_tokens_human(
|
||||||
|
i64::try_from(billing.cache_write_tokens).unwrap_or(i64::MAX)
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if billing.reasoning_tokens > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"Reasoning: {} tokens",
|
||||||
|
format_tokens_human(
|
||||||
|
i64::try_from(billing.reasoning_tokens).unwrap_or(i64::MAX)
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if billing.total_usd_micros.is_none() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
styles
|
styles
|
||||||
|
|
@ -200,25 +234,6 @@ pub(crate) fn print_run_conclusion(
|
||||||
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
|
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if conclusion.total_cache_read_tokens > 0 {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
styles.dim.apply_to(format!(
|
|
||||||
"Cache: {} read, {} write",
|
|
||||||
format_tokens_human(conclusion.total_cache_read_tokens),
|
|
||||||
format_tokens_human(conclusion.total_cache_write_tokens),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if conclusion.total_reasoning_tokens > 0 {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
styles.dim.apply_to(format!(
|
|
||||||
"Reasoning: {} tokens",
|
|
||||||
format_tokens_human(conclusion.total_reasoning_tokens),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(run_dir) = run_dir {
|
if let Some(run_dir) = run_dir {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,24 @@
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use fabro_types::{EventBody, RunEvent, StageUsage};
|
use fabro_types::{BilledModelUsage, EventBody, RunEvent};
|
||||||
use fabro_workflow::event::RunNoticeLevel;
|
use fabro_workflow::event::RunNoticeLevel;
|
||||||
use fabro_workflow::outcome::compute_stage_cost;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct ProgressUsage {
|
pub(super) struct ProgressUsage {
|
||||||
pub(super) model: Option<String>,
|
|
||||||
pub(super) input_tokens: u64,
|
pub(super) input_tokens: u64,
|
||||||
pub(super) output_tokens: u64,
|
pub(super) output_tokens: u64,
|
||||||
pub(super) speed: Option<String>,
|
|
||||||
pub(super) cost: Option<f64>,
|
pub(super) cost: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProgressUsage {
|
impl ProgressUsage {
|
||||||
pub(super) fn from_stage_usage(usage: &StageUsage) -> Option<Self> {
|
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option<Self> {
|
||||||
|
let tokens = usage.tokens();
|
||||||
Some(Self {
|
Some(Self {
|
||||||
model: Some(usage.model.clone()),
|
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
|
||||||
input_tokens: u64::try_from(usage.input_tokens).ok()?,
|
output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?,
|
||||||
output_tokens: u64::try_from(usage.output_tokens).ok()?,
|
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
|
||||||
speed: usage.speed.clone(),
|
|
||||||
cost: usage.cost,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,22 +27,7 @@ impl ProgressUsage {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn display_cost(&self) -> Option<f64> {
|
pub(super) fn display_cost(&self) -> Option<f64> {
|
||||||
self.cost.or_else(|| {
|
self.cost
|
||||||
let model = self.model.clone()?;
|
|
||||||
let input_tokens = i64::try_from(self.input_tokens).ok()?;
|
|
||||||
let output_tokens = i64::try_from(self.output_tokens).ok()?;
|
|
||||||
let usage = StageUsage {
|
|
||||||
model,
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
reasoning_tokens: None,
|
|
||||||
speed: self.speed.clone(),
|
|
||||||
cost: None,
|
|
||||||
};
|
|
||||||
compute_stage_cost(&usage)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,7 +312,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
||||||
duration_ms: props.duration_ms,
|
duration_ms: props.duration_ms,
|
||||||
status: props.status.to_string(),
|
status: props.status.to_string(),
|
||||||
usage: props
|
usage: props
|
||||||
.usage
|
.billing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(ProgressUsage::from_stage_usage),
|
.and_then(ProgressUsage::from_stage_usage),
|
||||||
}),
|
}),
|
||||||
|
|
@ -533,7 +514,7 @@ mod tests {
|
||||||
status: "success".into(),
|
status: "success".into(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -418,10 +418,11 @@ mod tests {
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use fabro_agent::{AgentEvent, SandboxEvent};
|
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||||
use fabro_llm::types::Usage;
|
use fabro_llm::types::TokenCounts;
|
||||||
|
use fabro_model::Provider;
|
||||||
use fabro_types::fixtures;
|
use fabro_types::fixtures;
|
||||||
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
|
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
|
||||||
use fabro_workflow::outcome::StageUsage;
|
use fabro_workflow::outcome::billed_model_usage_from_llm;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::commands::run::run_progress::stage_display::ToolCallStatus;
|
use crate::commands::run::run_progress::stage_display::ToolCallStatus;
|
||||||
|
|
@ -498,7 +499,7 @@ mod tests {
|
||||||
AgentEvent::AssistantMessage {
|
AgentEvent::AssistantMessage {
|
||||||
text: "done".into(),
|
text: "done".into(),
|
||||||
model: model.into(),
|
model: model.into(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
tool_call_count: 0,
|
tool_call_count: 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -513,16 +514,16 @@ mod tests {
|
||||||
status: "success".into(),
|
status: "success".into(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: Some(StageUsage {
|
billing: Some(billed_model_usage_from_llm(
|
||||||
model: "gpt-5-mini".into(),
|
"gpt-5-mini",
|
||||||
input_tokens: 1200,
|
Provider::OpenAi,
|
||||||
output_tokens: 300,
|
None,
|
||||||
cache_read_tokens: None,
|
&TokenCounts {
|
||||||
cache_write_tokens: None,
|
input_tokens: 1200,
|
||||||
reasoning_tokens: None,
|
output_tokens: 300,
|
||||||
speed: None,
|
..TokenCounts::default()
|
||||||
cost: Some(0.12),
|
},
|
||||||
}),
|
)),
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
@ -811,9 +812,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
emit(&mut ui, stage_completed("plan", "Plan"));
|
emit(&mut ui, stage_completed("plan", "Plan"));
|
||||||
|
|
||||||
insta::assert_snapshot!(rendered(&buffer), @r"
|
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.00 5s");
|
||||||
✓ Plan $0.12 5s
|
|
||||||
");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1049,7 +1048,7 @@ mod tests {
|
||||||
Running devcontainer postCreate (1 commands)...
|
Running devcontainer postCreate (1 commands)...
|
||||||
✓ [1/1] npm run setup 1s
|
✓ [1/1] npm run setup 1s
|
||||||
Devcontainer: postCreate (1s)
|
Devcontainer: postCreate (1s)
|
||||||
✓ Code $0.12 5s (1 turns, 0 tools, 1.5k toks)
|
✓ Code $0.00 5s (1 turns, 0 tools, 1.5k toks)
|
||||||
"#);
|
"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,10 +316,10 @@ mod tests {
|
||||||
artifact_count: 0,
|
artifact_count: 0,
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
reason: None,
|
reason: None,
|
||||||
total_cost: None,
|
total_usd_micros: None,
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
final_patch: None,
|
final_patch: None,
|
||||||
usage: None,
|
billing: None,
|
||||||
})),
|
})),
|
||||||
Some(WorkerTitlePhase::Succeeded)
|
Some(WorkerTitlePhase::Succeeded)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::args::{GlobalArgs, WaitArgs};
|
use crate::args::{GlobalArgs, WaitArgs};
|
||||||
use crate::server_runs::ServerSummaryLookup;
|
use crate::server_runs::ServerSummaryLookup;
|
||||||
use crate::shared::format_duration_ms;
|
use crate::shared::{format_duration_ms, format_usd_micros};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
|
const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
@ -92,8 +92,12 @@ fn build_json_output(
|
||||||
});
|
});
|
||||||
if let Some(c) = conclusion {
|
if let Some(c) = conclusion {
|
||||||
value["duration_ms"] = c.duration_ms.into();
|
value["duration_ms"] = c.duration_ms.into();
|
||||||
if let Some(cost) = c.total_cost {
|
if let Some(total_usd_micros) = c
|
||||||
value["total_cost"] = cost.into();
|
.billing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|billing| billing.total_usd_micros)
|
||||||
|
{
|
||||||
|
value["total_usd_micros"] = total_usd_micros.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value
|
value
|
||||||
|
|
@ -118,8 +122,10 @@ fn print_human_output(
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
let duration = format_duration_ms(c.duration_ms);
|
let duration = format_duration_ms(c.duration_ms);
|
||||||
let cost = c
|
let cost = c
|
||||||
.total_cost
|
.billing
|
||||||
.map(|v| format!(" ${v:.2}"))
|
.as_ref()
|
||||||
|
.and_then(|billing| billing.total_usd_micros)
|
||||||
|
.map(|value| format!(" {}", format_usd_micros(value)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(" {duration}{cost}")
|
format!(" {duration}{cost}")
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +142,7 @@ fn print_human_output(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use fabro_types::BilledTokenCounts;
|
||||||
use fabro_types::fixtures;
|
use fabro_types::fixtures;
|
||||||
use fabro_workflow::outcome::StageStatus;
|
use fabro_workflow::outcome::StageStatus;
|
||||||
use fabro_workflow::records::Conclusion;
|
use fabro_workflow::records::Conclusion;
|
||||||
|
|
@ -155,20 +162,22 @@ mod tests {
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
stages: vec![],
|
stages: vec![],
|
||||||
total_cost: Some(0.42),
|
billing: Some(BilledTokenCounts {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
total_usd_micros: Some(420_000),
|
||||||
|
}),
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
};
|
};
|
||||||
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
|
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
|
||||||
assert_eq!(json["run_id"], run_id.to_string());
|
assert_eq!(json["run_id"], run_id.to_string());
|
||||||
assert_eq!(json["status"], "succeeded");
|
assert_eq!(json["status"], "succeeded");
|
||||||
assert_eq!(json["duration_ms"], 12345);
|
assert_eq!(json["duration_ms"], 12345);
|
||||||
assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON);
|
assert_eq!(json["total_usd_micros"], 420_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -178,7 +187,7 @@ mod tests {
|
||||||
assert_eq!(json["run_id"], run_id.to_string());
|
assert_eq!(json["run_id"], run_id.to_string());
|
||||||
assert_eq!(json["status"], "failed");
|
assert_eq!(json["status"], "failed");
|
||||||
assert!(json.get("duration_ms").is_none());
|
assert!(json.get("duration_ms").is_none());
|
||||||
assert!(json.get("total_cost").is_none());
|
assert!(json.get("total_usd_micros").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -197,17 +206,11 @@ mod tests {
|
||||||
failure_reason: Some("error".into()),
|
failure_reason: Some("error".into()),
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
stages: vec![],
|
stages: vec![],
|
||||||
total_cost: None,
|
billing: None,
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
};
|
};
|
||||||
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
|
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
|
||||||
assert!(json.get("total_cost").is_none());
|
assert!(json.get("total_usd_micros").is_none());
|
||||||
assert_eq!(json["duration_ms"], 500);
|
assert_eq!(json["duration_ms"], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,14 +225,16 @@ mod tests {
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
stages: vec![],
|
stages: vec![],
|
||||||
total_cost: Some(0.15),
|
billing: Some(BilledTokenCounts {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
total_usd_micros: Some(150_000),
|
||||||
|
}),
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
};
|
};
|
||||||
// Just verify no panic; actual stderr output is hard to capture
|
// Just verify no panic; actual stderr output is hard to capture
|
||||||
print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles);
|
print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles);
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ pub(crate) async fn list_command(
|
||||||
"start_time": run.start_time(),
|
"start_time": run.start_time(),
|
||||||
"labels": run.labels(),
|
"labels": run.labels(),
|
||||||
"duration_ms": run.duration_ms(),
|
"duration_ms": run.duration_ms(),
|
||||||
"total_cost": run.total_cost(),
|
"total_usd_micros": run.total_usd_micros(),
|
||||||
"host_repo_path": run.host_repo_path(),
|
"host_repo_path": run.host_repo_path(),
|
||||||
"goal": run.goal(),
|
"goal": run.goal(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -149,9 +149,9 @@ mod tests {
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use fabro_store::{Database, EventEnvelope, EventPayload};
|
use fabro_store::{Database, EventEnvelope, EventPayload};
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
AggregateStats, AttrValue, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId,
|
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
|
||||||
RunRecord, RunStatus, RunStatusRecord, SandboxRecord, Settings, StageStatus, StartRecord,
|
NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord,
|
||||||
StatusReason, fixtures,
|
Settings, StageStatus, StartRecord, StatusReason, fixtures,
|
||||||
};
|
};
|
||||||
use fabro_workflow::event::{Event, append_event};
|
use fabro_workflow::event::{Event, append_event};
|
||||||
use object_store::memory::InMemory;
|
use object_store::memory::InMemory;
|
||||||
|
|
@ -241,14 +241,16 @@ mod tests {
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
final_git_commit_sha: Some("feedbeef".to_string()),
|
final_git_commit_sha: Some("feedbeef".to_string()),
|
||||||
stages: Vec::new(),
|
stages: Vec::new(),
|
||||||
total_cost: Some(1.25),
|
billing: Some(BilledTokenCounts {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
total_tokens: 150,
|
||||||
|
reasoning_tokens: 50,
|
||||||
|
cache_read_tokens: 30,
|
||||||
|
cache_write_tokens: 40,
|
||||||
|
total_usd_micros: Some(1_250_000),
|
||||||
|
}),
|
||||||
total_retries: 2,
|
total_retries: 2,
|
||||||
total_input_tokens: 10,
|
|
||||||
total_output_tokens: 20,
|
|
||||||
total_cache_read_tokens: 30,
|
|
||||||
total_cache_write_tokens: 40,
|
|
||||||
total_reasoning_tokens: 50,
|
|
||||||
has_pricing: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,7 +264,7 @@ mod tests {
|
||||||
stages: Vec::new(),
|
stages: Vec::new(),
|
||||||
stats: AggregateStats {
|
stats: AggregateStats {
|
||||||
total_duration_ms: 3210,
|
total_duration_ms: 3210,
|
||||||
total_cost: Some(1.25),
|
total_billing_usd_micros: Some(1_250_000),
|
||||||
total_retries: 2,
|
total_retries: 2,
|
||||||
files_touched: vec!["src/lib.rs".to_string()],
|
files_touched: vec!["src/lib.rs".to_string()],
|
||||||
stages_completed: 3,
|
stages_completed: 3,
|
||||||
|
|
@ -423,7 +425,7 @@ mod tests {
|
||||||
response: "Implemented".to_string(),
|
response: "Implemented".to_string(),
|
||||||
model: "gpt-5".to_string(),
|
model: "gpt-5".to_string(),
|
||||||
provider: "openai".to_string(),
|
provider: "openai".to_string(),
|
||||||
usage: None,
|
billing: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -439,7 +441,7 @@ mod tests {
|
||||||
status: "partial_success".to_string(),
|
status: "partial_success".to_string(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: Some("captured output".to_string()),
|
notes: Some("captured output".to_string()),
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
@ -516,10 +518,13 @@ mod tests {
|
||||||
artifact_count: 0,
|
artifact_count: 0,
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
reason: None,
|
reason: None,
|
||||||
total_cost: conclusion.total_cost,
|
total_usd_micros: conclusion
|
||||||
|
.billing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|billing| billing.total_usd_micros),
|
||||||
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
|
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
|
||||||
final_patch: None,
|
final_patch: None,
|
||||||
usage: None,
|
billing: conclusion.billing.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ impl ServerRunSummaryInfo {
|
||||||
self.summary.duration_ms
|
self.summary.duration_ms
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn total_cost(&self) -> Option<f64> {
|
pub(crate) fn total_usd_micros(&self) -> Option<i64> {
|
||||||
self.summary.total_cost
|
self.summary.total_usd_micros
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn host_repo_path(&self) -> Option<&str> {
|
pub(crate) fn host_repo_path(&self) -> Option<&str> {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ pub(crate) fn format_tokens_human(tokens: i64) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_usd_micros(usd_micros: i64) -> String {
|
||||||
|
format!("${:.2}", usd_micros as f64 / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn tilde_path(path: &Path) -> String {
|
pub(crate) fn tilde_path(path: &Path) -> String {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
if let Ok(suffix) = path.strip_prefix(&home) {
|
if let Ok(suffix) = path.strip_prefix(&home) {
|
||||||
|
|
@ -132,7 +136,7 @@ pub(crate) fn format_size(bytes: u64) -> String {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::format_tokens_human;
|
use super::{format_tokens_human, format_usd_micros};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_tokens_human_zero() {
|
fn format_tokens_human_zero() {
|
||||||
|
|
@ -163,4 +167,9 @@ mod tests {
|
||||||
fn format_tokens_human_mid_millions() {
|
fn format_tokens_human_mid_millions() {
|
||||||
assert_eq!(format_tokens_human(3_456_789), "3.5m");
|
assert_eq!(format_tokens_human(3_456_789), "3.5m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_usd_micros_two_decimals() {
|
||||||
|
assert_eq!(format_usd_micros(570_000), "$0.57");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ fn attach_uses_configured_server_target_without_server_flag() {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"status_reason": null,
|
"status_reason": null,
|
||||||
"duration_ms": 12,
|
"duration_ms": 12,
|
||||||
"total_cost": null
|
"total_usd_micros": null
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -575,6 +575,19 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"host_repo_path": "[TEMP_DIR]",
|
"host_repo_path": "[TEMP_DIR]",
|
||||||
|
"provenance": {
|
||||||
|
"client": {
|
||||||
|
"name": "fabro-cli",
|
||||||
|
"user_agent": "fabro-cli/0.176.2",
|
||||||
|
"version": "0.176.2"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"version": "0.176.2"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"auth_method": "disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
"run_dir": "[RUN_DIR]",
|
"run_dir": "[RUN_DIR]",
|
||||||
"settings": {
|
"settings": {
|
||||||
"goal": "Wait for approval",
|
"goal": "Wait for approval",
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ fn bare() {
|
||||||
exit_code: 0
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
||||||
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
|
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
|
||||||
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
||||||
|
|
@ -72,7 +72,7 @@ fn list() {
|
||||||
exit_code: 0
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
||||||
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
|
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
|
||||||
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
||||||
|
|
@ -105,11 +105,11 @@ fn list_provider() {
|
||||||
success: true
|
success: true
|
||||||
exit_code: 0
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
||||||
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
|
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
|
||||||
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
|
||||||
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|
@ -123,8 +123,8 @@ fn list_query() {
|
||||||
success: true
|
success: true
|
||||||
exit_code: 0
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
||||||
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
|
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|
@ -155,8 +155,8 @@ fn list_query_case_insensitive() {
|
||||||
success: true
|
success: true
|
||||||
exit_code: 0
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
MODEL PROVIDER ALIASES CONTEXT COST SPEED
|
||||||
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
|
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"status_reason": null,
|
"status_reason": null,
|
||||||
"duration_ms": 123,
|
"duration_ms": 123,
|
||||||
"total_cost": null
|
"total_usd_micros": null
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"status_reason": null,
|
"status_reason": null,
|
||||||
"duration_ms": 123,
|
"duration_ms": 123,
|
||||||
"total_cost": null
|
"total_usd_micros": null
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,8 @@ fn remote_run_state_response() -> serde_json::Value {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"duration_ms": 12,
|
"duration_ms": 12,
|
||||||
"stages": [],
|
"stages": [],
|
||||||
"total_cost": null,
|
"billing": null,
|
||||||
"total_retries": 0,
|
"total_retries": 0,
|
||||||
"total_input_tokens": 0,
|
|
||||||
"total_output_tokens": 0,
|
|
||||||
"total_cache_read_tokens": 0,
|
|
||||||
"total_cache_write_tokens": 0,
|
|
||||||
"total_reasoning_tokens": 0,
|
|
||||||
"has_pricing": false
|
|
||||||
},
|
},
|
||||||
"retro": null,
|
"retro": null,
|
||||||
"retro_prompt": null,
|
"retro_prompt": null,
|
||||||
|
|
@ -746,6 +740,19 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"host_repo_path": "[TEMP_DIR]",
|
"host_repo_path": "[TEMP_DIR]",
|
||||||
|
"provenance": {
|
||||||
|
"client": {
|
||||||
|
"name": "fabro-cli",
|
||||||
|
"user_agent": "fabro-cli/0.176.2",
|
||||||
|
"version": "0.176.2"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"version": "0.176.2"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"auth_method": "disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
"run_dir": "[RUN_DIR]",
|
"run_dir": "[RUN_DIR]",
|
||||||
"settings": {
|
"settings": {
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,11 @@ async fn run_real_cli_test(provider: Provider, model: &str) {
|
||||||
"{provider}/{model}: expected response to contain '4', got: {text}"
|
"{provider}/{model}: expected response to contain '4', got: {text}"
|
||||||
);
|
);
|
||||||
let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage"));
|
let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage"));
|
||||||
|
let tokens = usage.tokens();
|
||||||
assert!(
|
assert!(
|
||||||
usage.input_tokens > 0,
|
tokens.input_tokens > 0,
|
||||||
"{provider}/{model}: input_tokens should be > 0, got {}",
|
"{provider}/{model}: input_tokens should be > 0, got {}",
|
||||||
usage.input_tokens
|
tokens.input_tokens
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"),
|
CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"),
|
||||||
|
|
|
||||||
|
|
@ -302,10 +302,9 @@ mod tests {
|
||||||
provider: self.provider_name.clone(),
|
provider: self.provider_name.clone(),
|
||||||
message: Message::assistant(&self.response_text),
|
message: Message::assistant(&self.response_text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -321,14 +320,14 @@ mod tests {
|
||||||
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage::default(),
|
TokenCounts::default(),
|
||||||
Response {
|
Response {
|
||||||
id: "resp_mock".into(),
|
id: "resp_mock".into(),
|
||||||
model: "mock-model".into(),
|
model: "mock-model".into(),
|
||||||
provider,
|
provider,
|
||||||
message: Message::assistant(&text),
|
message: Message::assistant(&text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::tools::{RepairToolCallFn, Tool, execute_all_tools_with_repair};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response,
|
FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response,
|
||||||
ResponseFormat, ResponseFormatType, RetryPolicy, StepResult, StreamEvent, TimeoutOptions,
|
ResponseFormat, ResponseFormatType, RetryPolicy, StepResult, StreamEvent, TimeoutOptions,
|
||||||
ToolCall, ToolChoice, ToolDefinition, Usage,
|
TokenCounts, ToolCall, ToolChoice, ToolDefinition,
|
||||||
};
|
};
|
||||||
use fabro_util::backoff::BackoffPolicy;
|
use fabro_util::backoff::BackoffPolicy;
|
||||||
use futures::{Stream, StreamExt, future, stream};
|
use futures::{Stream, StreamExt, future, stream};
|
||||||
|
|
@ -79,7 +79,7 @@ fn build_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_generate_result(steps: Vec<StepResult>, total_usage: Usage) -> GenerateResult {
|
fn build_generate_result(steps: Vec<StepResult>, total_usage: TokenCounts) -> GenerateResult {
|
||||||
let last = steps.last().expect("steps should not be empty");
|
let last = steps.last().expect("steps should not be empty");
|
||||||
let response = last.response.clone();
|
let response = last.response.clone();
|
||||||
let tool_results = last.tool_results.clone();
|
let tool_results = last.tool_results.clone();
|
||||||
|
|
@ -132,7 +132,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
|
||||||
|
|
||||||
let generate_future = async {
|
let generate_future = async {
|
||||||
let mut steps: Vec<StepResult> = Vec::new();
|
let mut steps: Vec<StepResult> = Vec::new();
|
||||||
let mut total_usage = Usage::default();
|
let mut total_usage = TokenCounts::default();
|
||||||
|
|
||||||
let mut round = 0u32;
|
let mut round = 0u32;
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -479,7 +479,7 @@ pub struct StreamAccumulator {
|
||||||
reasoning_parts: Vec<String>,
|
reasoning_parts: Vec<String>,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
finish_reason: Option<FinishReason>,
|
finish_reason: Option<FinishReason>,
|
||||||
usage: Option<Usage>,
|
usage: Option<TokenCounts>,
|
||||||
response: Option<Response>,
|
response: Option<Response>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1144,10 +1144,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&self.response_text),
|
message: Message::assistant(&self.response_text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1162,10 +1161,9 @@ mod tests {
|
||||||
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage {
|
TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Response {
|
Response {
|
||||||
|
|
@ -1174,10 +1172,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&text),
|
message: Message::assistant(&text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1292,10 +1289,9 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1310,10 +1306,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("The weather in SF is 72F"),
|
message: Message::assistant("The weather in SF is 72F"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 20,
|
input_tokens: 20,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1381,10 +1376,9 @@ mod tests {
|
||||||
provider: "p".into(),
|
provider: "p".into(),
|
||||||
message: Message::assistant("Hello world"),
|
message: Message::assistant("Hello world"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 2,
|
output_tokens: 2,
|
||||||
total_tokens: 7,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1597,7 +1591,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&self.full_text),
|
message: Message::assistant(&self.full_text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1613,10 +1607,9 @@ mod tests {
|
||||||
|
|
||||||
events.push(Ok(StreamEvent::finish(
|
events.push(Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage {
|
TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Response {
|
Response {
|
||||||
|
|
@ -1625,10 +1618,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&self.full_text),
|
message: Message::assistant(&self.full_text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -1816,7 +1808,7 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1993,7 +1985,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("fallback"),
|
message: Message::assistant("fallback"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2018,10 +2010,9 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -2046,10 +2037,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 20,
|
input_tokens: 20,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -2165,10 +2155,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("tool step"),
|
message: Message::assistant("tool step"),
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -2335,7 +2324,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("fallback"),
|
message: Message::assistant("fallback"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2362,10 +2351,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -2450,7 +2438,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("fallback"),
|
message: Message::assistant("fallback"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2466,7 +2454,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2475,7 +2463,7 @@ mod tests {
|
||||||
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
|
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage::default(),
|
TokenCounts::default(),
|
||||||
response,
|
response,
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
@ -2548,7 +2536,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant("fallback"),
|
message: Message::assistant("fallback"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2573,7 +2561,7 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2582,7 +2570,7 @@ mod tests {
|
||||||
Ok(StreamEvent::ToolCallEnd { tool_call }),
|
Ok(StreamEvent::ToolCallEnd { tool_call }),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::ToolCalls,
|
FinishReason::ToolCalls,
|
||||||
Usage::default(),
|
TokenCounts::default(),
|
||||||
response,
|
response,
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
@ -2597,7 +2585,7 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -2606,7 +2594,7 @@ mod tests {
|
||||||
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
|
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage::default(),
|
TokenCounts::default(),
|
||||||
response,
|
response,
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@ pub mod tools;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
// Re-export module-level default client helpers (Section 2.5).
|
// Re-export module-level default client helpers (Section 2.5).
|
||||||
pub use fabro_model::{ModelRef, Provider};
|
pub use fabro_model::{ModelHandle, Provider};
|
||||||
pub use generate::set_default_client;
|
pub use generate::set_default_client;
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::types::ToolResult;
|
use crate::types::ToolResult;
|
||||||
use crate::types::{FinishReason, Message, Response, StepResult, Usage};
|
use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts};
|
||||||
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
|
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
|
||||||
|
|
||||||
fn test_model_with(features: ModelFeatures) -> Model {
|
fn test_model_with(features: ModelFeatures) -> Model {
|
||||||
|
|
@ -257,7 +257,7 @@ mod tests {
|
||||||
provider: "anthropic".to_string(),
|
provider: "anthropic".to_string(),
|
||||||
message: Message::assistant(text),
|
message: Message::assistant(text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -296,7 +296,7 @@ mod tests {
|
||||||
let result = GenerateResult {
|
let result = GenerateResult {
|
||||||
response: response_with_text("84 is even"),
|
response: response_with_text("84 is even"),
|
||||||
tool_results,
|
tool_results,
|
||||||
total_usage: Usage::default(),
|
total_usage: TokenCounts::default(),
|
||||||
steps: vec![first_step, second_step],
|
steps: vec![first_step, second_step],
|
||||||
output: None,
|
output: None,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::types::{Request, Response, StreamEvent, ToolChoice};
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
pub use fabro_model::{ModelRef, Provider};
|
pub use fabro_model::{ModelHandle, Provider};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ProviderAdapter trait
|
// ProviderAdapter trait
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ use crate::providers::common::{
|
||||||
};
|
};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||||
ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition,
|
ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, ToolChoice,
|
||||||
Usage,
|
ToolDefinition,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Provider adapter for the Anthropic Messages API.
|
/// Provider adapter for the Anthropic Messages API.
|
||||||
|
|
@ -170,8 +170,6 @@ struct ApiUsage {
|
||||||
cache_read_input_tokens: Option<i64>,
|
cache_read_input_tokens: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
cache_creation_input_tokens: Option<i64>,
|
cache_creation_input_tokens: Option<i64>,
|
||||||
#[serde(default)]
|
|
||||||
speed: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimate reasoning tokens from thinking content blocks.
|
/// Estimate reasoning tokens from thinking content blocks.
|
||||||
|
|
@ -660,7 +658,7 @@ struct StreamAccumulator {
|
||||||
id: String,
|
id: String,
|
||||||
model: String,
|
model: String,
|
||||||
content_parts: Vec<ContentPart>,
|
content_parts: Vec<ContentPart>,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
finish_reason: FinishReason,
|
finish_reason: FinishReason,
|
||||||
/// The kind of the current content block, set by `content_block_start`.
|
/// The kind of the current content block, set by `content_block_start`.
|
||||||
current_block: Option<ContentBlockKind>,
|
current_block: Option<ContentBlockKind>,
|
||||||
|
|
@ -680,7 +678,7 @@ impl StreamAccumulator {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
content_parts: Vec::new(),
|
content_parts: Vec::new(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
current_block: None,
|
current_block: None,
|
||||||
current_text: String::new(),
|
current_text: String::new(),
|
||||||
|
|
@ -728,14 +726,12 @@ impl StreamAccumulator {
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.usage.cache_read_tokens = usage
|
self.usage.cache_read_tokens = usage
|
||||||
.get("cache_read_input_tokens")
|
.get("cache_read_input_tokens")
|
||||||
.and_then(serde_json::Value::as_i64);
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
self.usage.cache_write_tokens = usage
|
self.usage.cache_write_tokens = usage
|
||||||
.get("cache_creation_input_tokens")
|
.get("cache_creation_input_tokens")
|
||||||
.and_then(serde_json::Value::as_i64);
|
.and_then(serde_json::Value::as_i64)
|
||||||
self.usage.speed = usage
|
.unwrap_or(0);
|
||||||
.get("speed")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(String::from);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
vec![StreamEvent::StreamStart]
|
vec![StreamEvent::StreamStart]
|
||||||
|
|
@ -922,12 +918,13 @@ impl StreamAccumulator {
|
||||||
.get("output_tokens")
|
.get("output_tokens")
|
||||||
.and_then(serde_json::Value::as_i64)
|
.and_then(serde_json::Value::as_i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.usage.total_tokens = self.usage.input_tokens + self.usage.output_tokens;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_message_stop(&mut self) -> Vec<StreamEvent> {
|
fn handle_message_stop(&mut self) -> Vec<StreamEvent> {
|
||||||
self.usage.reasoning_tokens = estimate_reasoning_tokens(&self.content_parts);
|
let reasoning_tokens = estimate_reasoning_tokens(&self.content_parts).unwrap_or(0);
|
||||||
|
self.usage.reasoning_tokens = reasoning_tokens;
|
||||||
|
self.usage.output_tokens = self.usage.output_tokens.saturating_sub(reasoning_tokens);
|
||||||
let response = self.take_response();
|
let response = self.take_response();
|
||||||
vec![StreamEvent::Finish {
|
vec![StreamEvent::Finish {
|
||||||
finish_reason: response.finish_reason.clone(),
|
finish_reason: response.finish_reason.clone(),
|
||||||
|
|
@ -1260,8 +1257,7 @@ impl ProviderAdapter for Adapter {
|
||||||
} else {
|
} else {
|
||||||
map_finish_reason(api_resp.stop_reason.as_deref())
|
map_finish_reason(api_resp.stop_reason.as_deref())
|
||||||
};
|
};
|
||||||
let total = api_resp.usage.input_tokens + api_resp.usage.output_tokens;
|
let reasoning_tokens = estimate_reasoning_tokens(&content_parts).unwrap_or(0);
|
||||||
let reasoning_tokens = estimate_reasoning_tokens(&content_parts);
|
|
||||||
|
|
||||||
Ok(Response {
|
Ok(Response {
|
||||||
id: api_resp.id,
|
id: api_resp.id,
|
||||||
|
|
@ -1274,15 +1270,16 @@ impl ProviderAdapter for Adapter {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason,
|
finish_reason,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: api_resp.usage.input_tokens,
|
input_tokens: api_resp.usage.input_tokens,
|
||||||
output_tokens: api_resp.usage.output_tokens,
|
output_tokens: api_resp
|
||||||
total_tokens: total,
|
.usage
|
||||||
|
.output_tokens
|
||||||
|
.saturating_sub(reasoning_tokens),
|
||||||
reasoning_tokens,
|
reasoning_tokens,
|
||||||
cache_read_tokens: api_resp.usage.cache_read_input_tokens,
|
cache_read_tokens: api_resp.usage.cache_read_input_tokens.unwrap_or(0),
|
||||||
cache_write_tokens: api_resp.usage.cache_creation_input_tokens,
|
cache_write_tokens: api_resp.usage.cache_creation_input_tokens.unwrap_or(0),
|
||||||
speed: api_resp.usage.speed,
|
..TokenCounts::default()
|
||||||
..Usage::default()
|
|
||||||
},
|
},
|
||||||
raw: serde_json::from_str(&body).ok(),
|
raw: serde_json::from_str(&body).ok(),
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
|
|
@ -1956,14 +1953,14 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
});
|
});
|
||||||
let event = StreamEvent::Finish {
|
let event = StreamEvent::Finish {
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
let result = convert_stream_event_for_json_schema(event);
|
let result = convert_stream_event_for_json_schema(event);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::error::{SdkError, error_from_status_code};
|
use crate::error::{SdkError, error_from_status_code};
|
||||||
use crate::provider::{ProviderAdapter, StreamEventStream};
|
use crate::provider::{ProviderAdapter, StreamEventStream};
|
||||||
use crate::providers::common::LineReader;
|
use crate::providers::common::LineReader;
|
||||||
use crate::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
|
use crate::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
|
|
@ -135,18 +135,15 @@ impl ProviderAdapter for Adapter {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let finish_reason = map_stop_reason(&server_resp.stop_reason);
|
let finish_reason = map_stop_reason(&server_resp.stop_reason);
|
||||||
let total = server_resp.usage.input_tokens + server_resp.usage.output_tokens;
|
|
||||||
|
|
||||||
Ok(Response {
|
Ok(Response {
|
||||||
id: server_resp.id,
|
id: server_resp.id,
|
||||||
model: server_resp.model,
|
model: server_resp.model,
|
||||||
provider: self.provider_name.clone(),
|
provider: self.provider_name.clone(),
|
||||||
message: server_resp.message,
|
message: server_resp.message,
|
||||||
finish_reason,
|
finish_reason,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: server_resp.usage.input_tokens,
|
input_tokens: server_resp.usage.input_tokens,
|
||||||
output_tokens: server_resp.usage.output_tokens,
|
output_tokens: server_resp.usage.output_tokens,
|
||||||
total_tokens: total,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -338,7 +335,7 @@ data: {\"type\":\"text_delta\",\"delta\":\" world\",\"text_id\":null}\n\
|
||||||
assert_eq!(response.finish_reason, FinishReason::Stop);
|
assert_eq!(response.finish_reason, FinishReason::Stop);
|
||||||
assert_eq!(response.usage.input_tokens, 10);
|
assert_eq!(response.usage.input_tokens, 10);
|
||||||
assert_eq!(response.usage.output_tokens, 5);
|
assert_eq!(response.usage.output_tokens, 5);
|
||||||
assert_eq!(response.usage.total_tokens, 15);
|
assert_eq!(response.usage.total_tokens(), 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ use crate::providers::common::{
|
||||||
};
|
};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||||
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
|
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall,
|
||||||
ToolDefinition, Usage,
|
ToolChoice, ToolDefinition,
|
||||||
};
|
};
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
|
|
||||||
|
|
@ -138,7 +138,6 @@ struct CandidateContent {
|
||||||
struct UsageMetadata {
|
struct UsageMetadata {
|
||||||
prompt_token_count: Option<i64>,
|
prompt_token_count: Option<i64>,
|
||||||
candidates_token_count: Option<i64>,
|
candidates_token_count: Option<i64>,
|
||||||
total_token_count: Option<i64>,
|
|
||||||
thoughts_token_count: Option<i64>,
|
thoughts_token_count: Option<i64>,
|
||||||
cached_content_token_count: Option<i64>,
|
cached_content_token_count: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
@ -487,19 +486,21 @@ fn apply_default_safety_settings(body: &mut serde_json::Value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert `UsageMetadata` from the Gemini API into a unified `Usage`.
|
/// Convert `UsageMetadata` from the Gemini API into a unified `TokenCounts`.
|
||||||
fn parse_usage(metadata: Option<&UsageMetadata>) -> Usage {
|
fn parse_usage(metadata: Option<&UsageMetadata>) -> TokenCounts {
|
||||||
metadata.map_or_else(Usage::default, |u| {
|
metadata.map_or_else(TokenCounts::default, |u| {
|
||||||
let input = u.prompt_token_count.unwrap_or(0);
|
let input = u.prompt_token_count.unwrap_or(0);
|
||||||
let output = u.candidates_token_count.unwrap_or(0);
|
let reasoning_tokens = u.thoughts_token_count.unwrap_or(0);
|
||||||
let total = u.total_token_count.unwrap_or(input + output);
|
let output = u
|
||||||
Usage {
|
.candidates_token_count
|
||||||
|
.unwrap_or(0)
|
||||||
|
.saturating_sub(reasoning_tokens);
|
||||||
|
TokenCounts {
|
||||||
input_tokens: input,
|
input_tokens: input,
|
||||||
output_tokens: output,
|
output_tokens: output,
|
||||||
total_tokens: total,
|
reasoning_tokens,
|
||||||
reasoning_tokens: u.thoughts_token_count,
|
cache_read_tokens: u.cached_content_token_count.unwrap_or(0),
|
||||||
cache_read_tokens: u.cached_content_token_count,
|
..TokenCounts::default()
|
||||||
..Usage::default()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -696,7 +697,7 @@ struct SseStreamState {
|
||||||
/// The `text_id` used for `TextStart`/`TextDelta`/`TextEnd`.
|
/// The `text_id` used for `TextStart`/`TextDelta`/`TextEnd`.
|
||||||
text_id: String,
|
text_id: String,
|
||||||
/// Latest usage metadata (updated per chunk; final chunk has totals).
|
/// Latest usage metadata (updated per chunk; final chunk has totals).
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
/// The finish reason string from the candidate, if received.
|
/// The finish reason string from the candidate, if received.
|
||||||
finish_reason_str: Option<String>,
|
finish_reason_str: Option<String>,
|
||||||
/// Whether we have emitted the `Finish` event.
|
/// Whether we have emitted the `Finish` event.
|
||||||
|
|
@ -723,7 +724,7 @@ impl SseStreamState {
|
||||||
accumulated_text: String::new(),
|
accumulated_text: String::new(),
|
||||||
accumulated_tool_calls: Vec::new(),
|
accumulated_tool_calls: Vec::new(),
|
||||||
text_id: uuid::Uuid::new_v4().to_string(),
|
text_id: uuid::Uuid::new_v4().to_string(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
finish_reason_str: None,
|
finish_reason_str: None,
|
||||||
finished: false,
|
finished: false,
|
||||||
rate_limit,
|
rate_limit,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ use crate::providers::common::{
|
||||||
};
|
};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||||
ResponseFormat, ResponseFormatType, Role, StreamEvent, ToolCall, ToolChoice, ToolDefinition,
|
ResponseFormat, ResponseFormatType, Role, StreamEvent, TokenCounts, ToolCall, ToolChoice,
|
||||||
Usage,
|
ToolDefinition,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
|
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
|
||||||
|
|
@ -162,7 +162,6 @@ struct ApiResponse {
|
||||||
struct ApiUsage {
|
struct ApiUsage {
|
||||||
input_tokens: i64,
|
input_tokens: i64,
|
||||||
output_tokens: i64,
|
output_tokens: i64,
|
||||||
total_tokens: Option<i64>,
|
|
||||||
output_tokens_details: Option<OutputTokenDetails>,
|
output_tokens_details: Option<OutputTokenDetails>,
|
||||||
input_tokens_details: Option<InputTokenDetails>,
|
input_tokens_details: Option<InputTokenDetails>,
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +542,7 @@ struct SseStreamState {
|
||||||
reasoning_items: Vec<serde_json::Value>,
|
reasoning_items: Vec<serde_json::Value>,
|
||||||
/// Raw message output items to preserve for round-tripping.
|
/// Raw message output items to preserve for round-tripping.
|
||||||
message_items: Vec<serde_json::Value>,
|
message_items: Vec<serde_json::Value>,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
finish_reason: FinishReason,
|
finish_reason: FinishReason,
|
||||||
emitted_start: bool,
|
emitted_start: bool,
|
||||||
emitted_text_start: bool,
|
emitted_text_start: bool,
|
||||||
|
|
@ -837,19 +836,21 @@ fn handle_response_completed(
|
||||||
|
|
||||||
if let Some(usage_data) = response_data.get("usage") {
|
if let Some(usage_data) = response_data.get("usage") {
|
||||||
if let Ok(u) = serde_json::from_value::<ApiUsage>(usage_data.clone()) {
|
if let Ok(u) = serde_json::from_value::<ApiUsage>(usage_data.clone()) {
|
||||||
state.usage = Usage {
|
let reasoning_tokens = u
|
||||||
|
.output_tokens_details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.reasoning_tokens)
|
||||||
|
.unwrap_or(0);
|
||||||
|
state.usage = TokenCounts {
|
||||||
input_tokens: u.input_tokens,
|
input_tokens: u.input_tokens,
|
||||||
output_tokens: u.output_tokens,
|
output_tokens: u.output_tokens.saturating_sub(reasoning_tokens),
|
||||||
total_tokens: u.total_tokens.unwrap_or(u.input_tokens + u.output_tokens),
|
reasoning_tokens,
|
||||||
reasoning_tokens: u
|
|
||||||
.output_tokens_details
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|d| d.reasoning_tokens),
|
|
||||||
cache_read_tokens: u
|
cache_read_tokens: u
|
||||||
.input_tokens_details
|
.input_tokens_details
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|d| d.cached_tokens),
|
.and_then(|d| d.cached_tokens)
|
||||||
..Usage::default()
|
.unwrap_or(0),
|
||||||
|
..TokenCounts::default()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -961,19 +962,23 @@ impl ProviderAdapter for Adapter {
|
||||||
let usage = api_resp
|
let usage = api_resp
|
||||||
.usage
|
.usage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Usage::default, |u| Usage {
|
.map_or_else(TokenCounts::default, |u| {
|
||||||
input_tokens: u.input_tokens,
|
let reasoning_tokens = u
|
||||||
output_tokens: u.output_tokens,
|
|
||||||
total_tokens: u.total_tokens.unwrap_or(u.input_tokens + u.output_tokens),
|
|
||||||
reasoning_tokens: u
|
|
||||||
.output_tokens_details
|
.output_tokens_details
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|d| d.reasoning_tokens),
|
.and_then(|d| d.reasoning_tokens)
|
||||||
cache_read_tokens: u
|
.unwrap_or(0);
|
||||||
.input_tokens_details
|
TokenCounts {
|
||||||
.as_ref()
|
input_tokens: u.input_tokens,
|
||||||
.and_then(|d| d.cached_tokens),
|
output_tokens: u.output_tokens.saturating_sub(reasoning_tokens),
|
||||||
..Usage::default()
|
reasoning_tokens,
|
||||||
|
cache_read_tokens: u
|
||||||
|
.input_tokens_details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.cached_tokens)
|
||||||
|
.unwrap_or(0),
|
||||||
|
..TokenCounts::default()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Response {
|
Ok(Response {
|
||||||
|
|
@ -1039,7 +1044,7 @@ impl ProviderAdapter for Adapter {
|
||||||
tool_calls: Vec::new(),
|
tool_calls: Vec::new(),
|
||||||
reasoning_items: Vec::new(),
|
reasoning_items: Vec::new(),
|
||||||
message_items: Vec::new(),
|
message_items: Vec::new(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
emitted_start: false,
|
emitted_start: false,
|
||||||
emitted_text_start: false,
|
emitted_text_start: false,
|
||||||
|
|
@ -1593,7 +1598,7 @@ mod tests {
|
||||||
tool_calls: Vec::new(),
|
tool_calls: Vec::new(),
|
||||||
reasoning_items: Vec::new(),
|
reasoning_items: Vec::new(),
|
||||||
message_items: Vec::new(),
|
message_items: Vec::new(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
emitted_start: true,
|
emitted_start: true,
|
||||||
emitted_text_start: false,
|
emitted_text_start: false,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use crate::providers::common::{
|
||||||
};
|
};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||||
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
|
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall,
|
||||||
ToolDefinition, Usage,
|
ToolChoice, ToolDefinition,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `OpenAI`-compatible Chat Completions adapter (Section 7.10).
|
/// `OpenAI`-compatible Chat Completions adapter (Section 7.10).
|
||||||
|
|
@ -157,7 +157,6 @@ struct ApiFunction {
|
||||||
struct ApiUsage {
|
struct ApiUsage {
|
||||||
prompt_tokens: i64,
|
prompt_tokens: i64,
|
||||||
completion_tokens: i64,
|
completion_tokens: i64,
|
||||||
total_tokens: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Streaming response types ---
|
// --- Streaming response types ---
|
||||||
|
|
@ -505,11 +504,10 @@ impl ProviderAdapter for Adapter {
|
||||||
let usage = api_resp
|
let usage = api_resp
|
||||||
.usage
|
.usage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Usage::default, |u| Usage {
|
.map_or_else(TokenCounts::default, |u| TokenCounts {
|
||||||
input_tokens: u.prompt_tokens,
|
input_tokens: u.prompt_tokens,
|
||||||
output_tokens: u.completion_tokens,
|
output_tokens: u.completion_tokens,
|
||||||
total_tokens: u.total_tokens,
|
..TokenCounts::default()
|
||||||
..Usage::default()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Response {
|
Ok(Response {
|
||||||
|
|
@ -676,7 +674,7 @@ struct StreamState {
|
||||||
accumulated_text: String,
|
accumulated_text: String,
|
||||||
accumulated_reasoning: String,
|
accumulated_reasoning: String,
|
||||||
tool_calls: Vec<AccumulatedToolCall>,
|
tool_calls: Vec<AccumulatedToolCall>,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
finish_reason: FinishReason,
|
finish_reason: FinishReason,
|
||||||
text_started: bool,
|
text_started: bool,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
|
@ -702,7 +700,7 @@ impl StreamState {
|
||||||
accumulated_text: String::new(),
|
accumulated_text: String::new(),
|
||||||
accumulated_reasoning: String::new(),
|
accumulated_reasoning: String::new(),
|
||||||
tool_calls: Vec::new(),
|
tool_calls: Vec::new(),
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
text_started: false,
|
text_started: false,
|
||||||
done: false,
|
done: false,
|
||||||
|
|
@ -740,11 +738,10 @@ impl StreamState {
|
||||||
|
|
||||||
// Capture usage if present (often in a dedicated chunk).
|
// Capture usage if present (often in a dedicated chunk).
|
||||||
if let Some(usage) = &chunk.usage {
|
if let Some(usage) = &chunk.usage {
|
||||||
self.usage = Usage {
|
self.usage = TokenCounts {
|
||||||
input_tokens: usage.prompt_tokens,
|
input_tokens: usage.prompt_tokens,
|
||||||
output_tokens: usage.completion_tokens,
|
output_tokens: usage.completion_tokens,
|
||||||
total_tokens: usage.total_tokens,
|
..TokenCounts::default()
|
||||||
..Usage::default()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,7 +953,6 @@ mod tests {
|
||||||
let usage = chunk.usage.unwrap();
|
let usage = chunk.usage.unwrap();
|
||||||
assert_eq!(usage.prompt_tokens, 10);
|
assert_eq!(usage.prompt_tokens, 10);
|
||||||
assert_eq!(usage.completion_tokens, 20);
|
assert_eq!(usage.completion_tokens, 20);
|
||||||
assert_eq!(usage.total_tokens, 30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1045,11 +1041,10 @@ mod tests {
|
||||||
state.response_model = "gpt-4".into();
|
state.response_model = "gpt-4".into();
|
||||||
state.accumulated_text = "Hello world".into();
|
state.accumulated_text = "Hello world".into();
|
||||||
state.text_started = true;
|
state.text_started = true;
|
||||||
state.usage = Usage {
|
state.usage = TokenCounts {
|
||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
total_tokens: 15,
|
..TokenCounts::default()
|
||||||
..Usage::default()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let events = state.finish_events();
|
let events = state.finish_events();
|
||||||
|
|
|
||||||
|
|
@ -365,50 +365,9 @@ impl<'de> Deserialize<'de> for FinishReason {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3.9 Usage ---
|
// --- 3.9 TokenCounts ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
pub use fabro_model::TokenCounts;
|
||||||
pub struct Usage {
|
|
||||||
pub input_tokens: i64,
|
|
||||||
pub output_tokens: i64,
|
|
||||||
pub total_tokens: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reasoning_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_read_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_write_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub speed: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub raw: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Add for Usage {
|
|
||||||
type Output = Self;
|
|
||||||
|
|
||||||
fn add(self, rhs: Self) -> Self {
|
|
||||||
const fn add_optional(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
|
||||||
match (a, b) {
|
|
||||||
(None, None) => None,
|
|
||||||
(Some(a), None) => Some(a),
|
|
||||||
(None, Some(b)) => Some(b),
|
|
||||||
(Some(a), Some(b)) => Some(a + b),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
input_tokens: self.input_tokens + rhs.input_tokens,
|
|
||||||
output_tokens: self.output_tokens + rhs.output_tokens,
|
|
||||||
total_tokens: self.total_tokens + rhs.total_tokens,
|
|
||||||
reasoning_tokens: add_optional(self.reasoning_tokens, rhs.reasoning_tokens),
|
|
||||||
cache_read_tokens: add_optional(self.cache_read_tokens, rhs.cache_read_tokens),
|
|
||||||
cache_write_tokens: add_optional(self.cache_write_tokens, rhs.cache_write_tokens),
|
|
||||||
speed: self.speed,
|
|
||||||
raw: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3.10 ResponseFormat ---
|
// --- 3.10 ResponseFormat ---
|
||||||
|
|
||||||
|
|
@ -557,7 +516,7 @@ pub struct Response {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub message: Message,
|
pub message: Message,
|
||||||
pub finish_reason: FinishReason,
|
pub finish_reason: FinishReason,
|
||||||
pub usage: Usage,
|
pub usage: TokenCounts,
|
||||||
pub raw: Option<serde_json::Value>,
|
pub raw: Option<serde_json::Value>,
|
||||||
pub warnings: Vec<Warning>,
|
pub warnings: Vec<Warning>,
|
||||||
pub rate_limit: Option<RateLimitInfo>,
|
pub rate_limit: Option<RateLimitInfo>,
|
||||||
|
|
@ -633,14 +592,14 @@ pub enum StreamEvent {
|
||||||
},
|
},
|
||||||
StepFinish {
|
StepFinish {
|
||||||
finish_reason: FinishReason,
|
finish_reason: FinishReason,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
response: Box<Response>,
|
response: Box<Response>,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
tool_results: Vec<ToolResult>,
|
tool_results: Vec<ToolResult>,
|
||||||
},
|
},
|
||||||
Finish {
|
Finish {
|
||||||
finish_reason: FinishReason,
|
finish_reason: FinishReason,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
response: Box<Response>,
|
response: Box<Response>,
|
||||||
},
|
},
|
||||||
Error {
|
Error {
|
||||||
|
|
@ -660,7 +619,7 @@ impl StreamEvent {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn step_finish(
|
pub fn step_finish(
|
||||||
reason: FinishReason,
|
reason: FinishReason,
|
||||||
usage: Usage,
|
usage: TokenCounts,
|
||||||
response: Response,
|
response: Response,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
tool_results: Vec<ToolResult>,
|
tool_results: Vec<ToolResult>,
|
||||||
|
|
@ -675,7 +634,7 @@ impl StreamEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn finish(reason: FinishReason, usage: Usage, response: Response) -> Self {
|
pub fn finish(reason: FinishReason, usage: TokenCounts, response: Response) -> Self {
|
||||||
Self::Finish {
|
Self::Finish {
|
||||||
finish_reason: reason,
|
finish_reason: reason,
|
||||||
usage,
|
usage,
|
||||||
|
|
@ -787,7 +746,7 @@ pub enum ObjectStreamEvent {
|
||||||
pub struct GenerateResult {
|
pub struct GenerateResult {
|
||||||
pub response: Response,
|
pub response: Response,
|
||||||
pub tool_results: Vec<ToolResult>,
|
pub tool_results: Vec<ToolResult>,
|
||||||
pub total_usage: Usage,
|
pub total_usage: TokenCounts,
|
||||||
pub steps: Vec<StepResult>,
|
pub steps: Vec<StepResult>,
|
||||||
pub output: Option<serde_json::Value>,
|
pub output: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
@ -916,42 +875,35 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_serialization_skips_none_optional_fields() {
|
fn usage_serialization_skips_none_optional_fields() {
|
||||||
let usage = Usage {
|
let usage = TokenCounts {
|
||||||
input_tokens: 100,
|
input_tokens: 100,
|
||||||
output_tokens: 50,
|
output_tokens: 50,
|
||||||
total_tokens: 150,
|
..TokenCounts::default()
|
||||||
reasoning_tokens: None,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
|
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
|
||||||
{
|
{
|
||||||
"input_tokens": 100,
|
"input_tokens": 100,
|
||||||
"output_tokens": 50,
|
"output_tokens": 50,
|
||||||
"total_tokens": 150
|
"reasoning_tokens": 0,
|
||||||
|
"cache_read_tokens": 0,
|
||||||
|
"cache_write_tokens": 0
|
||||||
}
|
}
|
||||||
"#);
|
"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_serialization_includes_present_optional_fields() {
|
fn usage_serialization_includes_present_optional_fields() {
|
||||||
let usage = Usage {
|
let usage = TokenCounts {
|
||||||
input_tokens: 100,
|
input_tokens: 100,
|
||||||
output_tokens: 50,
|
output_tokens: 30,
|
||||||
total_tokens: 150,
|
reasoning_tokens: 20,
|
||||||
reasoning_tokens: Some(20),
|
cache_read_tokens: 80,
|
||||||
cache_read_tokens: Some(80),
|
cache_write_tokens: 10,
|
||||||
cache_write_tokens: Some(10),
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
|
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
|
||||||
{
|
{
|
||||||
"input_tokens": 100,
|
"input_tokens": 100,
|
||||||
"output_tokens": 50,
|
"output_tokens": 30,
|
||||||
"total_tokens": 150,
|
|
||||||
"reasoning_tokens": 20,
|
"reasoning_tokens": 20,
|
||||||
"cache_read_tokens": 80,
|
"cache_read_tokens": 80,
|
||||||
"cache_write_tokens": 10
|
"cache_write_tokens": 10
|
||||||
|
|
@ -961,70 +913,57 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_deserialization_without_optional_fields() {
|
fn usage_deserialization_without_optional_fields() {
|
||||||
let json = r#"{"input_tokens":100,"output_tokens":50,"total_tokens":150}"#;
|
let json = r#"{"input_tokens":100,"output_tokens":50}"#;
|
||||||
let usage: Usage = serde_json::from_str(json).unwrap();
|
let usage: TokenCounts = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(usage.input_tokens, 100);
|
assert_eq!(usage.input_tokens, 100);
|
||||||
assert_eq!(usage.reasoning_tokens, None);
|
assert_eq!(usage.reasoning_tokens, 0);
|
||||||
assert_eq!(usage.cache_read_tokens, None);
|
assert_eq!(usage.cache_read_tokens, 0);
|
||||||
|
assert_eq!(usage.total_tokens(), 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_addition_both_filled() {
|
fn usage_addition_both_filled() {
|
||||||
let a = Usage {
|
let a = TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 15,
|
||||||
total_tokens: 30,
|
reasoning_tokens: 5,
|
||||||
reasoning_tokens: Some(5),
|
cache_read_tokens: 3,
|
||||||
cache_read_tokens: Some(3),
|
cache_write_tokens: 1,
|
||||||
cache_write_tokens: Some(1),
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
let b = Usage {
|
let b = TokenCounts {
|
||||||
input_tokens: 15,
|
input_tokens: 15,
|
||||||
output_tokens: 25,
|
output_tokens: 15,
|
||||||
total_tokens: 40,
|
reasoning_tokens: 10,
|
||||||
reasoning_tokens: Some(10),
|
cache_read_tokens: 7,
|
||||||
cache_read_tokens: Some(7),
|
cache_write_tokens: 2,
|
||||||
cache_write_tokens: Some(2),
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
let sum = a + b;
|
let sum = a + b;
|
||||||
assert_eq!(sum.input_tokens, 25);
|
assert_eq!(sum.input_tokens, 25);
|
||||||
assert_eq!(sum.output_tokens, 45);
|
assert_eq!(sum.output_tokens, 30);
|
||||||
assert_eq!(sum.total_tokens, 70);
|
assert_eq!(sum.total_tokens(), 83);
|
||||||
assert_eq!(sum.reasoning_tokens, Some(15));
|
assert_eq!(sum.reasoning_tokens, 15);
|
||||||
assert_eq!(sum.cache_read_tokens, Some(10));
|
assert_eq!(sum.cache_read_tokens, 10);
|
||||||
assert_eq!(sum.cache_write_tokens, Some(3));
|
assert_eq!(sum.cache_write_tokens, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_addition_one_none() {
|
fn usage_addition_one_none() {
|
||||||
let a = Usage {
|
let a = TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 15,
|
||||||
total_tokens: 30,
|
reasoning_tokens: 5,
|
||||||
reasoning_tokens: Some(5),
|
..TokenCounts::default()
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
let b = Usage {
|
let b = TokenCounts {
|
||||||
input_tokens: 15,
|
input_tokens: 15,
|
||||||
output_tokens: 25,
|
output_tokens: 25,
|
||||||
total_tokens: 40,
|
cache_read_tokens: 7,
|
||||||
reasoning_tokens: None,
|
..TokenCounts::default()
|
||||||
cache_read_tokens: Some(7),
|
|
||||||
cache_write_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
raw: None,
|
|
||||||
};
|
};
|
||||||
let sum = a + b;
|
let sum = a + b;
|
||||||
assert_eq!(sum.reasoning_tokens, Some(5));
|
assert_eq!(sum.reasoning_tokens, 5);
|
||||||
assert_eq!(sum.cache_read_tokens, Some(7));
|
assert_eq!(sum.cache_read_tokens, 7);
|
||||||
assert_eq!(sum.cache_write_tokens, None);
|
assert_eq!(sum.cache_write_tokens, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1049,7 +988,7 @@ mod tests {
|
||||||
provider: "test".into(),
|
provider: "test".into(),
|
||||||
message: Message::assistant("Hello world"),
|
message: Message::assistant("Hello world"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1077,7 +1016,7 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1108,7 +1047,7 @@ mod tests {
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1125,7 +1064,7 @@ mod tests {
|
||||||
provider: "test".into(),
|
provider: "test".into(),
|
||||||
message: Message::assistant("Hello"),
|
message: Message::assistant("Hello"),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
@ -1291,10 +1230,9 @@ mod tests {
|
||||||
provider: "test".into(),
|
provider: "test".into(),
|
||||||
message: Message::assistant("tool response"),
|
message: Message::assistant("tool response"),
|
||||||
finish_reason: FinishReason::ToolCalls,
|
finish_reason: FinishReason::ToolCalls,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
total_tokens: 15,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ async fn run_multi_turn_cache_test(
|
||||||
"response text should not be empty on turn {turn}"
|
"response text should not be empty on turn {turn}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let cache_read = response.usage.cache_read_tokens.unwrap_or(0) as f64;
|
let cache_read = response.usage.cache_read_tokens as f64;
|
||||||
let input = response.usage.input_tokens as f64;
|
let input = response.usage.input_tokens as f64;
|
||||||
let ratio = cache_read / input;
|
let ratio = cache_read / input;
|
||||||
best_cache_ratio = best_cache_ratio.max(ratio);
|
best_cache_ratio = best_cache_ratio.max(ratio);
|
||||||
|
|
|
||||||
692
lib/crates/fabro-model/src/billing.rs
Normal file
692
lib/crates/fabro-model/src/billing.rs
Normal file
|
|
@ -0,0 +1,692 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Model, Provider};
|
||||||
|
|
||||||
|
const USD_MICROS_PER_USD: i128 = 1_000_000;
|
||||||
|
const TOKENS_PER_MTOK: i128 = 1_000_000;
|
||||||
|
const ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR: i64 = 6;
|
||||||
|
const ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR: i64 = 1;
|
||||||
|
const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5;
|
||||||
|
const ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR: i64 = 4;
|
||||||
|
const ANTHROPIC_CACHE_WRITE_1H_NUMERATOR: i64 = 2;
|
||||||
|
const ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR: i64 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
|
||||||
|
pub struct UsdMicros(pub i64);
|
||||||
|
|
||||||
|
impl std::ops::Add for UsdMicros {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
Self(self.0 + rhs.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::AddAssign for UsdMicros {
|
||||||
|
fn add_assign(&mut self, rhs: Self) {
|
||||||
|
self.0 += rhs.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::iter::Sum for UsdMicros {
|
||||||
|
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
|
||||||
|
iter.fold(Self::default(), |acc, value| acc + value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PricePerMTok {
|
||||||
|
pub usd_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PricePerMTok {
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_usd(usd: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
usd_micros: (usd * USD_MICROS_PER_USD as f64).round() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn multiply_ratio(self, numerator: i64, denominator: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
usd_micros: self.usd_micros.saturating_mul(numerator) / denominator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bill(self, tokens: i64) -> UsdMicros {
|
||||||
|
let total = i128::from(tokens) * i128::from(self.usd_micros);
|
||||||
|
UsdMicros((total / TOKENS_PER_MTOK) as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Speed {
|
||||||
|
Standard,
|
||||||
|
Fast,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Speed {
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Standard => "standard",
|
||||||
|
Self::Fast => "fast",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Speed {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"standard" => Ok(Self::Standard),
|
||||||
|
"fast" => Ok(Self::Fast),
|
||||||
|
other => Err(format!("unknown speed: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Speed {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ModelRef {
|
||||||
|
pub provider: Provider,
|
||||||
|
pub model_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub speed: Option<Speed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TokenCounts {
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reasoning_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_tokens: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenCounts {
|
||||||
|
#[must_use]
|
||||||
|
pub fn billable_output_tokens(&self) -> i64 {
|
||||||
|
self.output_tokens + self.reasoning_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn total_tokens(&self) -> i64 {
|
||||||
|
self.input_tokens
|
||||||
|
+ self.billable_output_tokens()
|
||||||
|
+ self.cache_read_tokens
|
||||||
|
+ self.cache_write_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Add for TokenCounts {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
Self {
|
||||||
|
input_tokens: self.input_tokens + rhs.input_tokens,
|
||||||
|
output_tokens: self.output_tokens + rhs.output_tokens,
|
||||||
|
reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
|
||||||
|
cache_read_tokens: self.cache_read_tokens + rhs.cache_read_tokens,
|
||||||
|
cache_write_tokens: self.cache_write_tokens + rhs.cache_write_tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::AddAssign for TokenCounts {
|
||||||
|
fn add_assign(&mut self, rhs: Self) {
|
||||||
|
self.input_tokens += rhs.input_tokens;
|
||||||
|
self.output_tokens += rhs.output_tokens;
|
||||||
|
self.reasoning_tokens += rhs.reasoning_tokens;
|
||||||
|
self.cache_read_tokens += rhs.cache_read_tokens;
|
||||||
|
self.cache_write_tokens += rhs.cache_write_tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ModelUsage {
|
||||||
|
pub model: ModelRef,
|
||||||
|
pub tokens: TokenCounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAiModelPricing {
|
||||||
|
pub input: PricePerMTok,
|
||||||
|
pub cached_input: Option<PricePerMTok>,
|
||||||
|
pub output: PricePerMTok,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicModelPricing {
|
||||||
|
pub input: PricePerMTok,
|
||||||
|
pub cache_read: Option<PricePerMTok>,
|
||||||
|
pub cache_write_5m: Option<PricePerMTok>,
|
||||||
|
pub cache_write_1h: Option<PricePerMTok>,
|
||||||
|
pub output: PricePerMTok,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GeminiStorageSegment {
|
||||||
|
pub cached_tokens: i64,
|
||||||
|
pub ttl_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GeminiStoragePricing {
|
||||||
|
pub usd_micros_per_mtok_second: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GeminiModelPricing {
|
||||||
|
pub input: PricePerMTok,
|
||||||
|
pub output: PricePerMTok,
|
||||||
|
pub cached_input: Option<PricePerMTok>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub storage: Option<GeminiStoragePricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "provider", rename_all = "snake_case")]
|
||||||
|
pub enum ModelPricingPolicy {
|
||||||
|
OpenAi(OpenAiModelPricing),
|
||||||
|
OpenAiCompatible(OpenAiModelPricing),
|
||||||
|
Anthropic(AnthropicModelPricing),
|
||||||
|
Gemini(GeminiModelPricing),
|
||||||
|
Kimi(OpenAiModelPricing),
|
||||||
|
Zai(OpenAiModelPricing),
|
||||||
|
Minimax(OpenAiModelPricing),
|
||||||
|
Inception(OpenAiModelPricing),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ModelPricing {
|
||||||
|
pub model: ModelRef,
|
||||||
|
pub policy: ModelPricingPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAiBillingFacts {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicBillingFacts {
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_5m_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_1h_tokens: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct GeminiBillingFacts {
|
||||||
|
#[serde(default)]
|
||||||
|
pub storage_segments: Vec<GeminiStorageSegment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "provider", rename_all = "snake_case")]
|
||||||
|
pub enum ModelBillingFacts {
|
||||||
|
OpenAi(OpenAiBillingFacts),
|
||||||
|
OpenAiCompatible(OpenAiBillingFacts),
|
||||||
|
Anthropic(AnthropicBillingFacts),
|
||||||
|
Gemini(GeminiBillingFacts),
|
||||||
|
Kimi(OpenAiBillingFacts),
|
||||||
|
Zai(OpenAiBillingFacts),
|
||||||
|
Minimax(OpenAiBillingFacts),
|
||||||
|
Inception(OpenAiBillingFacts),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelBillingFacts {
|
||||||
|
#[must_use]
|
||||||
|
pub fn for_provider(provider: Provider) -> Self {
|
||||||
|
match provider {
|
||||||
|
Provider::OpenAi => Self::OpenAi(OpenAiBillingFacts::default()),
|
||||||
|
Provider::OpenAiCompatible => Self::OpenAiCompatible(OpenAiBillingFacts::default()),
|
||||||
|
Provider::Anthropic => Self::Anthropic(AnthropicBillingFacts::default()),
|
||||||
|
Provider::Gemini => Self::Gemini(GeminiBillingFacts::default()),
|
||||||
|
Provider::Kimi => Self::Kimi(OpenAiBillingFacts::default()),
|
||||||
|
Provider::Zai => Self::Zai(OpenAiBillingFacts::default()),
|
||||||
|
Provider::Minimax => Self::Minimax(OpenAiBillingFacts::default()),
|
||||||
|
Provider::Inception => Self::Inception(OpenAiBillingFacts::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ModelBillingInput {
|
||||||
|
pub usage: ModelUsage,
|
||||||
|
pub facts: ModelBillingFacts,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BilledModelUsage {
|
||||||
|
pub input: ModelBillingInput,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total_usd_micros: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BilledModelUsage {
|
||||||
|
#[must_use]
|
||||||
|
pub fn model(&self) -> &ModelRef {
|
||||||
|
&self.input.usage.model
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_id(&self) -> &str {
|
||||||
|
&self.input.usage.model.model_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn tokens(&self) -> &TokenCounts {
|
||||||
|
&self.input.usage.tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BilledTokenCounts {
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reasoning_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_tokens: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_tokens: i64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total_usd_micros: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BilledTokenCounts {
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self {
|
||||||
|
let mut tokens = TokenCounts::default();
|
||||||
|
let mut total_usd_micros = 0_i64;
|
||||||
|
let mut has_total = false;
|
||||||
|
|
||||||
|
for entry in billed {
|
||||||
|
tokens += entry.input.usage.tokens.clone();
|
||||||
|
if let Some(value) = entry.total_usd_micros {
|
||||||
|
total_usd_micros += value;
|
||||||
|
has_total = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
input_tokens: tokens.input_tokens,
|
||||||
|
output_tokens: tokens.output_tokens,
|
||||||
|
total_tokens: tokens.total_tokens(),
|
||||||
|
reasoning_tokens: tokens.reasoning_tokens,
|
||||||
|
cache_read_tokens: tokens.cache_read_tokens,
|
||||||
|
cache_write_tokens: tokens.cache_write_tokens,
|
||||||
|
total_usd_micros: has_total.then_some(total_usd_micros),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
#[must_use]
|
||||||
|
pub fn billing_model_ref(&self, speed: Option<Speed>) -> ModelRef {
|
||||||
|
ModelRef {
|
||||||
|
provider: self.provider,
|
||||||
|
model_id: self.id.clone(),
|
||||||
|
speed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn pricing_for(&self, speed: Option<Speed>) -> Option<ModelPricing> {
|
||||||
|
let input = self.costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?;
|
||||||
|
let output = self
|
||||||
|
.costs
|
||||||
|
.output_cost_per_mtok
|
||||||
|
.map(PricePerMTok::from_usd)?;
|
||||||
|
let cached_input = self
|
||||||
|
.costs
|
||||||
|
.cache_input_cost_per_mtok
|
||||||
|
.map(PricePerMTok::from_usd);
|
||||||
|
|
||||||
|
let (input, output, cached_input) = match (self.provider, speed) {
|
||||||
|
(Provider::Anthropic, Some(Speed::Fast)) if self.id == "claude-opus-4-6" => (
|
||||||
|
input.multiply_ratio(
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
|
||||||
|
),
|
||||||
|
output.multiply_ratio(
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
|
||||||
|
),
|
||||||
|
cached_input.map(|rate| {
|
||||||
|
rate.multiply_ratio(
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
|
||||||
|
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(_, None | Some(Speed::Standard)) => (input, output, cached_input),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let policy = match self.provider {
|
||||||
|
Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
Provider::OpenAiCompatible => {
|
||||||
|
ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Provider::Anthropic => ModelPricingPolicy::Anthropic(AnthropicModelPricing {
|
||||||
|
input,
|
||||||
|
cache_read: cached_input,
|
||||||
|
cache_write_5m: Some(input.multiply_ratio(
|
||||||
|
ANTHROPIC_CACHE_WRITE_5M_NUMERATOR,
|
||||||
|
ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR,
|
||||||
|
)),
|
||||||
|
cache_write_1h: Some(input.multiply_ratio(
|
||||||
|
ANTHROPIC_CACHE_WRITE_1H_NUMERATOR,
|
||||||
|
ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR,
|
||||||
|
)),
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cached_input,
|
||||||
|
storage: None,
|
||||||
|
}),
|
||||||
|
Provider::Kimi => ModelPricingPolicy::Kimi(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
Provider::Zai => ModelPricingPolicy::Zai(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
Provider::Minimax => ModelPricingPolicy::Minimax(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
Provider::Inception => ModelPricingPolicy::Inception(OpenAiModelPricing {
|
||||||
|
input,
|
||||||
|
cached_input,
|
||||||
|
output,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(ModelPricing {
|
||||||
|
model: self.billing_model_ref(speed),
|
||||||
|
policy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPricing {
|
||||||
|
#[must_use]
|
||||||
|
pub fn bill(&self, input: &ModelBillingInput) -> Option<UsdMicros> {
|
||||||
|
if input.usage.model != self.model {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bill = match (&self.policy, &input.facts) {
|
||||||
|
(ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_))
|
||||||
|
| (
|
||||||
|
ModelPricingPolicy::OpenAiCompatible(pricing),
|
||||||
|
ModelBillingFacts::OpenAiCompatible(_),
|
||||||
|
)
|
||||||
|
| (ModelPricingPolicy::Kimi(pricing), ModelBillingFacts::Kimi(_))
|
||||||
|
| (ModelPricingPolicy::Zai(pricing), ModelBillingFacts::Zai(_))
|
||||||
|
| (ModelPricingPolicy::Minimax(pricing), ModelBillingFacts::Minimax(_))
|
||||||
|
| (ModelPricingPolicy::Inception(pricing), ModelBillingFacts::Inception(_)) => {
|
||||||
|
Some(bill_openai_like(pricing, &input.usage.tokens))
|
||||||
|
}
|
||||||
|
(ModelPricingPolicy::Anthropic(pricing), ModelBillingFacts::Anthropic(facts)) => {
|
||||||
|
Some(bill_anthropic(pricing, &input.usage.tokens, facts))
|
||||||
|
}
|
||||||
|
(ModelPricingPolicy::Gemini(pricing), ModelBillingFacts::Gemini(facts)) => {
|
||||||
|
bill_gemini(pricing, &input.usage.tokens, facts)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(bill)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bill_usage(&self, input: ModelBillingInput) -> BilledModelUsage {
|
||||||
|
let total_usd_micros = self.bill(&input).map(|amount| amount.0);
|
||||||
|
BilledModelUsage {
|
||||||
|
input,
|
||||||
|
total_usd_micros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bill_openai_like(pricing: &OpenAiModelPricing, tokens: &TokenCounts) -> UsdMicros {
|
||||||
|
let mut total = pricing.input.bill(tokens.input_tokens);
|
||||||
|
total += pricing.output.bill(tokens.billable_output_tokens());
|
||||||
|
if let Some(cached_input) = pricing.cached_input {
|
||||||
|
total += cached_input.bill(tokens.cache_read_tokens);
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bill_anthropic(
|
||||||
|
pricing: &AnthropicModelPricing,
|
||||||
|
tokens: &TokenCounts,
|
||||||
|
facts: &AnthropicBillingFacts,
|
||||||
|
) -> UsdMicros {
|
||||||
|
let mut total = pricing.input.bill(tokens.input_tokens);
|
||||||
|
total += pricing.output.bill(tokens.billable_output_tokens());
|
||||||
|
if let Some(cache_read) = pricing.cache_read {
|
||||||
|
total += cache_read.bill(tokens.cache_read_tokens);
|
||||||
|
}
|
||||||
|
if let Some(cache_write_5m) = pricing.cache_write_5m {
|
||||||
|
total += cache_write_5m.bill(facts.cache_write_5m_tokens);
|
||||||
|
}
|
||||||
|
if let Some(cache_write_1h) = pricing.cache_write_1h {
|
||||||
|
total += cache_write_1h.bill(facts.cache_write_1h_tokens);
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bill_gemini(
|
||||||
|
pricing: &GeminiModelPricing,
|
||||||
|
tokens: &TokenCounts,
|
||||||
|
facts: &GeminiBillingFacts,
|
||||||
|
) -> Option<UsdMicros> {
|
||||||
|
if tokens.cache_read_tokens > 0 && pricing.cached_input.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !facts.storage_segments.is_empty() && pricing.storage.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total = pricing.input.bill(tokens.input_tokens);
|
||||||
|
total += pricing.output.bill(tokens.billable_output_tokens());
|
||||||
|
if let Some(cached_input) = pricing.cached_input {
|
||||||
|
total += cached_input.bill(tokens.cache_read_tokens);
|
||||||
|
}
|
||||||
|
if let Some(storage) = pricing.storage.as_ref() {
|
||||||
|
let storage_cost = facts
|
||||||
|
.storage_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
let token_seconds =
|
||||||
|
i128::from(segment.cached_tokens) * i128::from(segment.ttl_seconds);
|
||||||
|
UsdMicros(
|
||||||
|
(token_seconds * i128::from(storage.usd_micros_per_mtok_second)
|
||||||
|
/ TOKENS_PER_MTOK) as i64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sum::<UsdMicros>();
|
||||||
|
total += storage_cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Catalog;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_pricing_bills_cached_input_and_reasoning_output() {
|
||||||
|
let pricing = ModelPricing {
|
||||||
|
model: ModelRef {
|
||||||
|
provider: Provider::OpenAi,
|
||||||
|
model_id: "gpt-5.4".to_string(),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing {
|
||||||
|
input: PricePerMTok {
|
||||||
|
usd_micros: 1_250_000,
|
||||||
|
},
|
||||||
|
cached_input: Some(PricePerMTok {
|
||||||
|
usd_micros: 125_000,
|
||||||
|
}),
|
||||||
|
output: PricePerMTok {
|
||||||
|
usd_micros: 10_000_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let input = ModelBillingInput {
|
||||||
|
usage: ModelUsage {
|
||||||
|
model: pricing.model.clone(),
|
||||||
|
tokens: TokenCounts {
|
||||||
|
input_tokens: 500_000,
|
||||||
|
output_tokens: 125_000,
|
||||||
|
reasoning_tokens: 25_000,
|
||||||
|
cache_read_tokens: 250_000,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(pricing.bill(&input), Some(UsdMicros(2_156_250)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_fast_mode_derives_cache_write_rates_from_base_input() {
|
||||||
|
let model = Catalog::builtin().get("claude-opus-4-6").unwrap();
|
||||||
|
let pricing = model.pricing_for(Some(Speed::Fast)).unwrap();
|
||||||
|
|
||||||
|
let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else {
|
||||||
|
panic!("expected anthropic pricing");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(anthropic.input.usd_micros, 30_000_000);
|
||||||
|
assert_eq!(anthropic.output.usd_micros, 150_000_000);
|
||||||
|
assert_eq!(anthropic.cache_read.unwrap().usd_micros, 3_000_000);
|
||||||
|
assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 37_500_000);
|
||||||
|
assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 60_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_billing_supports_distinct_cache_write_buckets() {
|
||||||
|
let pricing = ModelPricing {
|
||||||
|
model: ModelRef {
|
||||||
|
provider: Provider::Anthropic,
|
||||||
|
model_id: "claude-opus-4-6".to_string(),
|
||||||
|
speed: Some(Speed::Fast),
|
||||||
|
},
|
||||||
|
policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing {
|
||||||
|
input: PricePerMTok {
|
||||||
|
usd_micros: 30_000_000,
|
||||||
|
},
|
||||||
|
cache_read: Some(PricePerMTok {
|
||||||
|
usd_micros: 3_000_000,
|
||||||
|
}),
|
||||||
|
cache_write_5m: Some(PricePerMTok {
|
||||||
|
usd_micros: 37_500_000,
|
||||||
|
}),
|
||||||
|
cache_write_1h: Some(PricePerMTok {
|
||||||
|
usd_micros: 60_000_000,
|
||||||
|
}),
|
||||||
|
output: PricePerMTok {
|
||||||
|
usd_micros: 150_000_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let input = ModelBillingInput {
|
||||||
|
usage: ModelUsage {
|
||||||
|
model: pricing.model.clone(),
|
||||||
|
tokens: TokenCounts {
|
||||||
|
input_tokens: 100_000,
|
||||||
|
output_tokens: 10_000,
|
||||||
|
reasoning_tokens: 5_000,
|
||||||
|
cache_read_tokens: 20_000,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: ModelBillingFacts::Anthropic(AnthropicBillingFacts {
|
||||||
|
cache_write_5m_tokens: 30_000,
|
||||||
|
cache_write_1h_tokens: 40_000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(pricing.bill(&input), Some(UsdMicros(8_835_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() {
|
||||||
|
let pricing = ModelPricing {
|
||||||
|
model: ModelRef {
|
||||||
|
provider: Provider::Gemini,
|
||||||
|
model_id: "gemini-3.1-pro-preview".to_string(),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
policy: ModelPricingPolicy::Gemini(GeminiModelPricing {
|
||||||
|
input: PricePerMTok {
|
||||||
|
usd_micros: 1_250_000,
|
||||||
|
},
|
||||||
|
output: PricePerMTok {
|
||||||
|
usd_micros: 10_000_000,
|
||||||
|
},
|
||||||
|
cached_input: None,
|
||||||
|
storage: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let input = ModelBillingInput {
|
||||||
|
usage: ModelUsage {
|
||||||
|
model: pricing.model.clone(),
|
||||||
|
tokens: TokenCounts {
|
||||||
|
input_tokens: 100_000,
|
||||||
|
output_tokens: 10_000,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: ModelBillingFacts::Gemini(GeminiBillingFacts {
|
||||||
|
storage_segments: vec![GeminiStorageSegment {
|
||||||
|
cached_tokens: 100_000,
|
||||||
|
ttl_seconds: 60,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(pricing.bill(&input), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
"knowledge_cutoff": "May 2025",
|
"knowledge_cutoff": "May 2025",
|
||||||
"features": { "tools": true, "vision": true, "reasoning": true, "effort": true },
|
"features": { "tools": true, "vision": true, "reasoning": true, "effort": true },
|
||||||
"costs": {
|
"costs": {
|
||||||
"input_cost_per_mtok": 15.0,
|
"input_cost_per_mtok": 5.0,
|
||||||
"output_cost_per_mtok": 75.0,
|
"output_cost_per_mtok": 25.0,
|
||||||
"cache_input_cost_per_mtok": 1.50
|
"cache_input_cost_per_mtok": 0.50
|
||||||
},
|
},
|
||||||
"estimated_output_tps": 25,
|
"estimated_output_tps": 25,
|
||||||
"aliases": ["opus", "claude-opus"]
|
"aliases": ["opus", "claude-opus"]
|
||||||
|
|
|
||||||
|
|
@ -458,13 +458,13 @@ mod tests {
|
||||||
},
|
},
|
||||||
costs: ModelCosts {
|
costs: ModelCosts {
|
||||||
input_cost_per_mtok: Some(
|
input_cost_per_mtok: Some(
|
||||||
15.0,
|
5.0,
|
||||||
),
|
),
|
||||||
output_cost_per_mtok: Some(
|
output_cost_per_mtok: Some(
|
||||||
75.0,
|
25.0,
|
||||||
),
|
),
|
||||||
cache_input_cost_per_mtok: Some(
|
cache_input_cost_per_mtok: Some(
|
||||||
1.5,
|
0.5,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
estimated_output_tps: Some(
|
estimated_output_tps: Some(
|
||||||
|
|
@ -874,8 +874,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn model_info_costs() {
|
fn model_info_costs() {
|
||||||
let claude = Catalog::builtin().get("claude-opus-4-6").unwrap();
|
let claude = Catalog::builtin().get("claude-opus-4-6").unwrap();
|
||||||
assert_eq!(claude.costs.input_cost_per_mtok, Some(15.0));
|
assert_eq!(claude.costs.input_cost_per_mtok, Some(5.0));
|
||||||
assert_eq!(claude.costs.output_cost_per_mtok, Some(75.0));
|
assert_eq!(claude.costs.output_cost_per_mtok, Some(25.0));
|
||||||
|
|
||||||
let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap();
|
let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap();
|
||||||
assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0));
|
assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0));
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
|
pub mod billing;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod model_ref;
|
pub mod model_ref;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
pub use billing::{
|
||||||
|
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
|
||||||
|
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
|
||||||
|
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||||
|
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||||
|
};
|
||||||
pub use catalog::{Catalog, FallbackTarget};
|
pub use catalog::{Catalog, FallbackTarget};
|
||||||
pub use model_ref::ModelRef;
|
pub use model_ref::ModelHandle;
|
||||||
pub use provider::Provider;
|
pub use provider::Provider;
|
||||||
pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits};
|
pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits};
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ use crate::types::Model;
|
||||||
/// A reference to a model — either a fully resolved `Model` or a
|
/// A reference to a model — either a fully resolved `Model` or a
|
||||||
/// provider + model-name pair that hasn't been looked up yet.
|
/// provider + model-name pair that hasn't been looked up yet.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ModelRef {
|
pub enum ModelHandle {
|
||||||
/// A model whose metadata has been resolved from the catalog.
|
/// A model whose metadata has been resolved from the catalog.
|
||||||
Resolved(Arc<Model>),
|
Resolved(Arc<Model>),
|
||||||
/// An unresolved provider:model pair (e.g. from CLI input or config).
|
/// An unresolved provider:model pair (e.g. from CLI input or config).
|
||||||
ByName { provider: Provider, model: String },
|
ByName { provider: Provider, model: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelRef {
|
impl ModelHandle {
|
||||||
/// The model identifier string (e.g. `"claude-opus-4-6"`).
|
/// The model identifier string (e.g. `"claude-opus-4-6"`).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn model_id(&self) -> &str {
|
pub fn model_id(&self) -> &str {
|
||||||
|
|
@ -34,13 +34,13 @@ impl ModelRef {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ModelRef {
|
impl fmt::Display for ModelHandle {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}:{}", self.provider(), self.model_id())
|
write!(f, "{}:{}", self.provider(), self.model_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for ModelRef {
|
impl fmt::Debug for ModelHandle {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id),
|
Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id),
|
||||||
|
|
@ -60,7 +60,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn by_name_display() {
|
fn by_name_display() {
|
||||||
let r = ModelRef::ByName {
|
let r = ModelHandle::ByName {
|
||||||
provider: Provider::Anthropic,
|
provider: Provider::Anthropic,
|
||||||
model: "claude-opus-4-6".to_string(),
|
model: "claude-opus-4-6".to_string(),
|
||||||
};
|
};
|
||||||
|
|
@ -69,7 +69,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn by_name_accessors() {
|
fn by_name_accessors() {
|
||||||
let r = ModelRef::ByName {
|
let r = ModelHandle::ByName {
|
||||||
provider: Provider::OpenAi,
|
provider: Provider::OpenAi,
|
||||||
model: "gpt-5.4".to_string(),
|
model: "gpt-5.4".to_string(),
|
||||||
};
|
};
|
||||||
|
|
@ -80,21 +80,21 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_display() {
|
fn resolved_display() {
|
||||||
let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone();
|
let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone();
|
||||||
let r = ModelRef::Resolved(Arc::new(info));
|
let r = ModelHandle::Resolved(Arc::new(info));
|
||||||
assert_eq!(r.to_string(), "anthropic:claude-opus-4-6");
|
assert_eq!(r.to_string(), "anthropic:claude-opus-4-6");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_accessors() {
|
fn resolved_accessors() {
|
||||||
let info = Catalog::builtin().get("gpt-5.4").unwrap().clone();
|
let info = Catalog::builtin().get("gpt-5.4").unwrap().clone();
|
||||||
let r = ModelRef::Resolved(Arc::new(info));
|
let r = ModelHandle::Resolved(Arc::new(info));
|
||||||
assert_eq!(r.model_id(), "gpt-5.4");
|
assert_eq!(r.model_id(), "gpt-5.4");
|
||||||
assert_eq!(r.provider(), Provider::OpenAi);
|
assert_eq!(r.provider(), Provider::OpenAi);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_format() {
|
fn debug_format() {
|
||||||
let r = ModelRef::ByName {
|
let r = ModelHandle::ByName {
|
||||||
provider: Provider::Gemini,
|
provider: Provider::Gemini,
|
||||||
model: "gemini-3.1-pro-preview".to_string(),
|
model: "gemini-3.1-pro-preview".to_string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,9 @@ mod tests {
|
||||||
assert!(info.supports_effort());
|
assert!(info.supports_effort());
|
||||||
assert_eq!(info.training(), Some("2025-08-01"));
|
assert_eq!(info.training(), Some("2025-08-01"));
|
||||||
assert_eq!(info.knowledge_cutoff(), Some("May 2025"));
|
assert_eq!(info.knowledge_cutoff(), Some("May 2025"));
|
||||||
assert_eq!(info.input_cost_per_mtok(), Some(15.0));
|
assert_eq!(info.input_cost_per_mtok(), Some(5.0));
|
||||||
assert_eq!(info.output_cost_per_mtok(), Some(75.0));
|
assert_eq!(info.output_cost_per_mtok(), Some(25.0));
|
||||||
assert_eq!(info.cache_input_cost_per_mtok(), Some(1.5));
|
assert_eq!(info.cache_input_cost_per_mtok(), Some(0.5));
|
||||||
assert_eq!(info.estimated_output_tps(), Some(25.0));
|
assert_eq!(info.estimated_output_tps(), Some(25.0));
|
||||||
assert!(!info.aliases().is_empty());
|
assert!(!info.aliases().is_empty());
|
||||||
assert!(!info.is_default());
|
assert!(!info.is_default());
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub struct CompletedStage {
|
||||||
pub succeeded: bool,
|
pub succeeded: bool,
|
||||||
pub failed: bool,
|
pub failed: bool,
|
||||||
pub retries: u32,
|
pub retries: u32,
|
||||||
pub cost: Option<f64>,
|
pub billing_usd_micros: Option<i64>,
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
pub failure_reason: Option<String>,
|
pub failure_reason: Option<String>,
|
||||||
pub files_touched: Vec<String>,
|
pub files_touched: Vec<String>,
|
||||||
|
|
@ -28,7 +28,7 @@ pub fn derive_retro(
|
||||||
) -> Retro {
|
) -> Retro {
|
||||||
let mut stages = Vec::new();
|
let mut stages = Vec::new();
|
||||||
let mut all_files: Vec<String> = Vec::new();
|
let mut all_files: Vec<String> = Vec::new();
|
||||||
let mut total_cost: Option<f64> = None;
|
let mut total_billing_usd_micros: Option<i64> = None;
|
||||||
let mut total_retries: u32 = 0;
|
let mut total_retries: u32 = 0;
|
||||||
let mut stages_completed: usize = 0;
|
let mut stages_completed: usize = 0;
|
||||||
let mut stages_failed: usize = 0;
|
let mut stages_failed: usize = 0;
|
||||||
|
|
@ -43,8 +43,8 @@ pub fn derive_retro(
|
||||||
stages_failed += 1;
|
stages_failed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(c) = cs.cost {
|
if let Some(cost) = cs.billing_usd_micros {
|
||||||
*total_cost.get_or_insert(0.0) += c;
|
*total_billing_usd_micros.get_or_insert(0) += cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dur = stage_durations.get(&cs.node_id).copied().unwrap_or(0);
|
let dur = stage_durations.get(&cs.node_id).copied().unwrap_or(0);
|
||||||
|
|
@ -53,7 +53,7 @@ pub fn derive_retro(
|
||||||
stage_label: cs.node_id.clone(),
|
stage_label: cs.node_id.clone(),
|
||||||
duration_ms: dur,
|
duration_ms: dur,
|
||||||
retries: cs.retries,
|
retries: cs.retries,
|
||||||
cost: cs.cost,
|
billing_usd_micros: cs.billing_usd_micros,
|
||||||
stage_id: cs.node_id,
|
stage_id: cs.node_id,
|
||||||
status: cs.status,
|
status: cs.status,
|
||||||
notes: cs.notes,
|
notes: cs.notes,
|
||||||
|
|
@ -76,7 +76,7 @@ pub fn derive_retro(
|
||||||
|
|
||||||
let stats = AggregateStats {
|
let stats = AggregateStats {
|
||||||
total_duration_ms: duration_ms,
|
total_duration_ms: duration_ms,
|
||||||
total_cost,
|
total_billing_usd_micros,
|
||||||
total_retries,
|
total_retries,
|
||||||
files_touched: all_files,
|
files_touched: all_files,
|
||||||
stages_completed,
|
stages_completed,
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,12 @@ pub(crate) async fn list_run_artifacts_stub(
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_run_usage(
|
pub(crate) async fn get_run_billing(
|
||||||
_auth: AuthenticatedService,
|
_auth: AuthenticatedService,
|
||||||
State(_state): State<Arc<AppState>>,
|
State(_state): State<Arc<AppState>>,
|
||||||
Path(_id): Path<String>,
|
Path(_id): Path<String>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
(StatusCode::OK, Json(runs::usage())).into_response()
|
(StatusCode::OK, Json(runs::billing())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_run_settings(
|
pub(crate) async fn get_run_settings(
|
||||||
|
|
@ -588,11 +588,11 @@ pub(crate) async fn prune_runs(
|
||||||
|
|
||||||
// ── Usage ──────────────────────────────────────────────────────────────
|
// ── Usage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) async fn get_aggregate_usage(
|
pub(crate) async fn get_aggregate_billing(
|
||||||
_auth: AuthenticatedService,
|
_auth: AuthenticatedService,
|
||||||
State(_state): State<Arc<AppState>>,
|
State(_state): State<Arc<AppState>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
(StatusCode::OK, Json(usage::aggregate())).into_response()
|
(StatusCode::OK, Json(billing::aggregate())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data modules ───────────────────────────────────────────────────────
|
// ── Data modules ───────────────────────────────────────────────────────
|
||||||
|
|
@ -1101,109 +1101,141 @@ mod runs {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn usage() -> RunUsage {
|
pub(super) fn billing() -> RunBilling {
|
||||||
RunUsage {
|
RunBilling {
|
||||||
stages: vec![
|
stages: vec![
|
||||||
UsageStage {
|
RunBillingStage {
|
||||||
stage: UsageStageRef {
|
stage: BillingStageRef {
|
||||||
id: "detect-drift".into(),
|
id: "detect-drift".into(),
|
||||||
name: "Detect Drift".into(),
|
name: "Detect Drift".into(),
|
||||||
},
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Opus 4.6".into(),
|
id: "Opus 4.6".into(),
|
||||||
},
|
},
|
||||||
usage: TokenUsage {
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
input_tokens: 12480,
|
input_tokens: 12480,
|
||||||
output_tokens: 3210,
|
output_tokens: 3210,
|
||||||
cost: 0.48,
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 15690,
|
||||||
|
total_usd_micros: Some(480_000),
|
||||||
},
|
},
|
||||||
runtime_secs: 72.0,
|
runtime_secs: 72.0,
|
||||||
},
|
},
|
||||||
UsageStage {
|
RunBillingStage {
|
||||||
stage: UsageStageRef {
|
stage: BillingStageRef {
|
||||||
id: "propose-changes".into(),
|
id: "propose-changes".into(),
|
||||||
name: "Propose Changes".into(),
|
name: "Propose Changes".into(),
|
||||||
},
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Gemini 3.1".into(),
|
id: "Gemini 3.1".into(),
|
||||||
},
|
},
|
||||||
usage: TokenUsage {
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
input_tokens: 28640,
|
input_tokens: 28640,
|
||||||
output_tokens: 8750,
|
output_tokens: 8750,
|
||||||
cost: 0.72,
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 37390,
|
||||||
|
total_usd_micros: Some(720_000),
|
||||||
},
|
},
|
||||||
runtime_secs: 154.0,
|
runtime_secs: 154.0,
|
||||||
},
|
},
|
||||||
UsageStage {
|
RunBillingStage {
|
||||||
stage: UsageStageRef {
|
stage: BillingStageRef {
|
||||||
id: "review-changes".into(),
|
id: "review-changes".into(),
|
||||||
name: "Review Changes".into(),
|
name: "Review Changes".into(),
|
||||||
},
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Codex 5.3".into(),
|
id: "Codex 5.3".into(),
|
||||||
},
|
},
|
||||||
usage: TokenUsage {
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
input_tokens: 9120,
|
input_tokens: 9120,
|
||||||
output_tokens: 2640,
|
output_tokens: 2640,
|
||||||
cost: 0.19,
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 11760,
|
||||||
|
total_usd_micros: Some(190_000),
|
||||||
},
|
},
|
||||||
runtime_secs: 45.0,
|
runtime_secs: 45.0,
|
||||||
},
|
},
|
||||||
UsageStage {
|
RunBillingStage {
|
||||||
stage: UsageStageRef {
|
stage: BillingStageRef {
|
||||||
id: "apply-changes".into(),
|
id: "apply-changes".into(),
|
||||||
name: "Apply Changes".into(),
|
name: "Apply Changes".into(),
|
||||||
},
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Opus 4.6".into(),
|
id: "Opus 4.6".into(),
|
||||||
},
|
},
|
||||||
usage: TokenUsage {
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
input_tokens: 21300,
|
input_tokens: 21300,
|
||||||
output_tokens: 6480,
|
output_tokens: 6480,
|
||||||
cost: 0.87,
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 27780,
|
||||||
|
total_usd_micros: Some(870_000),
|
||||||
},
|
},
|
||||||
runtime_secs: 118.0,
|
runtime_secs: 118.0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
totals: UsageTotals {
|
totals: RunBillingTotals {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
runtime_secs: 389.0,
|
runtime_secs: 389.0,
|
||||||
input_tokens: 71540,
|
input_tokens: 71540,
|
||||||
output_tokens: 21080,
|
output_tokens: 21080,
|
||||||
cost: 2.26,
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 92620,
|
||||||
|
total_usd_micros: Some(2_260_000),
|
||||||
},
|
},
|
||||||
by_model: vec![
|
by_model: vec![
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 33780,
|
||||||
|
output_tokens: 9690,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 43470,
|
||||||
|
total_usd_micros: Some(1_350_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Opus 4.6".into(),
|
id: "Opus 4.6".into(),
|
||||||
},
|
},
|
||||||
stages: 2,
|
stages: 2,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 33780,
|
|
||||||
output_tokens: 9690,
|
|
||||||
cost: 1.35,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 28640,
|
||||||
|
output_tokens: 8750,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 37390,
|
||||||
|
total_usd_micros: Some(720_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Gemini 3.1".into(),
|
id: "Gemini 3.1".into(),
|
||||||
},
|
},
|
||||||
stages: 1,
|
stages: 1,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 28640,
|
|
||||||
output_tokens: 8750,
|
|
||||||
cost: 0.72,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 9120,
|
||||||
|
output_tokens: 2640,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 11760,
|
||||||
|
total_usd_micros: Some(190_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Codex 5.3".into(),
|
id: "Codex 5.3".into(),
|
||||||
},
|
},
|
||||||
stages: 1,
|
stages: 1,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 9120,
|
|
||||||
output_tokens: 2640,
|
|
||||||
cost: 0.19,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
@ -1303,51 +1335,67 @@ mod runs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod usage {
|
mod billing {
|
||||||
use fabro_api::types::*;
|
use fabro_api::types::*;
|
||||||
|
|
||||||
pub(super) fn aggregate() -> AggregateUsage {
|
pub(super) fn aggregate() -> AggregateBilling {
|
||||||
AggregateUsage {
|
AggregateBilling {
|
||||||
totals: AggregateUsageTotals {
|
totals: AggregateBillingTotals {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
runs: 9,
|
runs: 9,
|
||||||
input_tokens: 643_860,
|
input_tokens: 643_860,
|
||||||
output_tokens: 189_720,
|
output_tokens: 189_720,
|
||||||
cost: 20.34,
|
reasoning_tokens: None,
|
||||||
runtime_secs: 3_501.0,
|
runtime_secs: 3_501.0,
|
||||||
|
total_tokens: 833_580,
|
||||||
|
total_usd_micros: Some(20_340_000),
|
||||||
},
|
},
|
||||||
by_model: vec![
|
by_model: vec![
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 304_020,
|
||||||
|
output_tokens: 87_210,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 391_230,
|
||||||
|
total_usd_micros: Some(12_150_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Opus 4.6".into(),
|
id: "Opus 4.6".into(),
|
||||||
},
|
},
|
||||||
stages: 18,
|
stages: 18,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 304_020,
|
|
||||||
output_tokens: 87_210,
|
|
||||||
cost: 12.15,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 257_760,
|
||||||
|
output_tokens: 78_750,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 336_510,
|
||||||
|
total_usd_micros: Some(6_480_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Gemini 3.1".into(),
|
id: "Gemini 3.1".into(),
|
||||||
},
|
},
|
||||||
stages: 9,
|
stages: 9,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 257_760,
|
|
||||||
output_tokens: 78_750,
|
|
||||||
cost: 6.48,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UsageByModel {
|
BillingByModel {
|
||||||
|
billing: BilledTokenCounts {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 82_080,
|
||||||
|
output_tokens: 23_760,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
total_tokens: 105_840,
|
||||||
|
total_usd_micros: Some(1_710_000),
|
||||||
|
},
|
||||||
model: ModelReference {
|
model: ModelReference {
|
||||||
id: "Codex 5.3".into(),
|
id: "Codex 5.3".into(),
|
||||||
},
|
},
|
||||||
stages: 9,
|
stages: 9,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: 82_080,
|
|
||||||
output_tokens: 23_760,
|
|
||||||
cost: 1.71,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,9 @@ use fabro_llm::generate::{GenerateParams, generate_object};
|
||||||
use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client};
|
use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client};
|
||||||
use fabro_llm::types::{
|
use fabro_llm::types::{
|
||||||
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
|
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
|
||||||
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
|
Response as LlmResponse, Role, StreamEvent, TokenCounts, ToolChoice, ToolDefinition,
|
||||||
};
|
};
|
||||||
|
use fabro_model::{BilledModelUsage, BilledTokenCounts};
|
||||||
use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId};
|
use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId};
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance,
|
RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance,
|
||||||
|
|
@ -80,21 +81,21 @@ use fabro_workflow::run_lookup::{
|
||||||
use fabro_workflow::run_status::RunStatus as WorkflowRunStatus;
|
use fabro_workflow::run_status::RunStatus as WorkflowRunStatus;
|
||||||
use fabro_workflow::run_status::StatusReason as WorkflowStatusReason;
|
use fabro_workflow::run_status::StatusReason as WorkflowStatusReason;
|
||||||
|
|
||||||
use fabro_api::types::AggregateUsageTotals;
|
|
||||||
pub use fabro_api::types::{
|
pub use fabro_api::types::{
|
||||||
AggregateUsage, ApiQuestion, ApiQuestionOption, AppendEventResponse, ArtifactEntry,
|
AggregateBilling, AggregateBillingTotals, ApiQuestion, ApiQuestionOption, AppendEventResponse,
|
||||||
ArtifactListResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,
|
ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel,
|
||||||
|
BillingStageRef, CompletionContentPart, CompletionMessage, CompletionMessageRole,
|
||||||
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
|
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
|
||||||
DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope,
|
DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope,
|
||||||
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
|
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
|
||||||
PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
|
PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
|
||||||
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat,
|
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat,
|
||||||
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse,
|
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling,
|
||||||
RunControlAction as ApiRunControlAction, RunError, RunEvent as ApiRunEvent, RunManifest,
|
RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError,
|
||||||
RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, ServerSettings,
|
RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry,
|
||||||
SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest,
|
SandboxFileListResponse, ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse,
|
||||||
StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts,
|
StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse,
|
||||||
TokenUsage, UsageByModel, WriteBlobResponse,
|
SystemRunCounts, WriteBlobResponse,
|
||||||
};
|
};
|
||||||
use fabro_graphviz::render::GraphFormat;
|
use fabro_graphviz::render::GraphFormat;
|
||||||
|
|
||||||
|
|
@ -232,21 +233,19 @@ const FILE_INTERVIEW_QUESTION_ID: &str = "q-file";
|
||||||
const WORKER_STDERR_LOG: &str = "worker.stderr.log";
|
const WORKER_STDERR_LOG: &str = "worker.stderr.log";
|
||||||
const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
|
const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
/// Per-model usage totals.
|
/// Per-model billing totals.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ModelUsageTotals {
|
struct ModelBillingTotals {
|
||||||
stages: i64,
|
stages: i64,
|
||||||
input_tokens: i64,
|
billing: BilledTokenCounts,
|
||||||
output_tokens: i64,
|
|
||||||
cost: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory aggregate usage counters, reset on server restart.
|
/// In-memory aggregate billing counters, reset on server restart.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct UsageAccumulator {
|
struct BillingAccumulator {
|
||||||
total_runs: i64,
|
total_runs: i64,
|
||||||
total_runtime_secs: f64,
|
total_runtime_secs: f64,
|
||||||
by_model: HashMap<String, ModelUsageTotals>,
|
by_model: HashMap<String, ModelBillingTotals>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync;
|
type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync;
|
||||||
|
|
@ -254,7 +253,7 @@ type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry +
|
||||||
/// Shared application state for the server.
|
/// Shared application state for the server.
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
runs: Mutex<HashMap<RunId, ManagedRun>>,
|
runs: Mutex<HashMap<RunId, ManagedRun>>,
|
||||||
aggregate_usage: Mutex<UsageAccumulator>,
|
aggregate_billing: Mutex<BillingAccumulator>,
|
||||||
store: Arc<Database>,
|
store: Arc<Database>,
|
||||||
artifact_store: ArtifactStore,
|
artifact_store: ArtifactStore,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
|
|
@ -270,6 +269,49 @@ pub struct AppState {
|
||||||
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn nonzero_i64(value: i64) -> Option<i64> {
|
||||||
|
(value != 0).then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_billed_token_counts_from_domain(billing: &BilledTokenCounts) -> ApiBilledTokenCounts {
|
||||||
|
ApiBilledTokenCounts {
|
||||||
|
cache_read_tokens: nonzero_i64(billing.cache_read_tokens),
|
||||||
|
cache_write_tokens: nonzero_i64(billing.cache_write_tokens),
|
||||||
|
input_tokens: billing.input_tokens,
|
||||||
|
output_tokens: billing.output_tokens,
|
||||||
|
reasoning_tokens: nonzero_i64(billing.reasoning_tokens),
|
||||||
|
total_tokens: billing.total_tokens,
|
||||||
|
total_usd_micros: billing.total_usd_micros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_billed_token_counts_from_usage(usage: &BilledModelUsage) -> ApiBilledTokenCounts {
|
||||||
|
let tokens = usage.tokens();
|
||||||
|
ApiBilledTokenCounts {
|
||||||
|
cache_read_tokens: nonzero_i64(tokens.cache_read_tokens),
|
||||||
|
cache_write_tokens: nonzero_i64(tokens.cache_write_tokens),
|
||||||
|
input_tokens: tokens.input_tokens,
|
||||||
|
output_tokens: tokens.output_tokens,
|
||||||
|
reasoning_tokens: nonzero_i64(tokens.reasoning_tokens),
|
||||||
|
total_tokens: tokens.total_tokens(),
|
||||||
|
total_usd_micros: usage.total_usd_micros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelUsage) {
|
||||||
|
let tokens = usage.tokens();
|
||||||
|
entry.stages += 1;
|
||||||
|
entry.billing.input_tokens += tokens.input_tokens;
|
||||||
|
entry.billing.output_tokens += tokens.output_tokens;
|
||||||
|
entry.billing.reasoning_tokens += tokens.reasoning_tokens;
|
||||||
|
entry.billing.cache_read_tokens += tokens.cache_read_tokens;
|
||||||
|
entry.billing.cache_write_tokens += tokens.cache_write_tokens;
|
||||||
|
entry.billing.total_tokens += tokens.total_tokens();
|
||||||
|
if let Some(value) = usage.total_usd_micros {
|
||||||
|
*entry.billing.total_usd_micros.get_or_insert(0) += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub(crate) fn dry_run(&self) -> bool {
|
pub(crate) fn dry_run(&self) -> bool {
|
||||||
self.settings.read().unwrap().dry_run_enabled()
|
self.settings.read().unwrap().dry_run_enabled()
|
||||||
|
|
@ -440,7 +482,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
|
||||||
"/runs/{id}/stages/{stageId}/artifacts/download",
|
"/runs/{id}/stages/{stageId}/artifacts/download",
|
||||||
get(not_implemented),
|
get(not_implemented),
|
||||||
)
|
)
|
||||||
.route("/runs/{id}/usage", get(demo::get_run_usage))
|
.route("/runs/{id}/billing", get(demo::get_run_billing))
|
||||||
.route("/runs/{id}/settings", get(demo::get_run_settings))
|
.route("/runs/{id}/settings", get(demo::get_run_settings))
|
||||||
.route("/runs/{id}/preview", post(demo::generate_preview_url_stub))
|
.route("/runs/{id}/preview", post(demo::generate_preview_url_stub))
|
||||||
.route("/runs/{id}/ssh", post(demo::create_ssh_access_stub))
|
.route("/runs/{id}/ssh", post(demo::create_ssh_access_stub))
|
||||||
|
|
@ -478,7 +520,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
|
||||||
.route("/system/info", get(demo::get_system_info))
|
.route("/system/info", get(demo::get_system_info))
|
||||||
.route("/system/df", get(demo::get_system_disk_usage))
|
.route("/system/df", get(demo::get_system_disk_usage))
|
||||||
.route("/system/prune/runs", post(demo::prune_runs))
|
.route("/system/prune/runs", post(demo::prune_runs))
|
||||||
.route("/usage", get(demo::get_aggregate_usage))
|
.route("/billing", get(demo::get_aggregate_billing))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn real_routes() -> Router<Arc<AppState>> {
|
fn real_routes() -> Router<Arc<AppState>> {
|
||||||
|
|
@ -516,7 +558,7 @@ fn real_routes() -> Router<Arc<AppState>> {
|
||||||
"/runs/{id}/stages/{stageId}/artifacts/download",
|
"/runs/{id}/stages/{stageId}/artifacts/download",
|
||||||
get(get_stage_artifact),
|
get(get_stage_artifact),
|
||||||
)
|
)
|
||||||
.route("/runs/{id}/usage", get(not_implemented))
|
.route("/runs/{id}/billing", get(get_run_billing))
|
||||||
.route("/runs/{id}/settings", get(not_implemented))
|
.route("/runs/{id}/settings", get(not_implemented))
|
||||||
.route("/runs/{id}/steer", post(not_implemented))
|
.route("/runs/{id}/steer", post(not_implemented))
|
||||||
.route("/runs/{id}/preview", post(generate_preview_url))
|
.route("/runs/{id}/preview", post(generate_preview_url))
|
||||||
|
|
@ -552,7 +594,7 @@ fn real_routes() -> Router<Arc<AppState>> {
|
||||||
.route("/system/info", get(get_system_info))
|
.route("/system/info", get(get_system_info))
|
||||||
.route("/system/df", get(get_system_df))
|
.route("/system/df", get(get_system_df))
|
||||||
.route("/system/prune/runs", post(prune_runs))
|
.route("/system/prune/runs", post(prune_runs))
|
||||||
.route("/usage", get(get_aggregate_usage))
|
.route("/billing", get(get_aggregate_billing))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn not_implemented() -> Response {
|
async fn not_implemented() -> Response {
|
||||||
|
|
@ -1266,40 +1308,161 @@ async fn cookie_and_demo_middleware(
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_aggregate_usage(
|
async fn get_aggregate_billing(
|
||||||
_auth: AuthenticatedService,
|
_auth: AuthenticatedService,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let agg = state
|
let agg = state
|
||||||
.aggregate_usage
|
.aggregate_billing
|
||||||
.lock()
|
.lock()
|
||||||
.expect("aggregate_usage lock poisoned");
|
.expect("aggregate_billing lock poisoned");
|
||||||
let by_model: Vec<UsageByModel> = agg
|
let by_model: Vec<BillingByModel> = agg
|
||||||
.by_model
|
.by_model
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(model, totals)| UsageByModel {
|
.map(|(model, totals)| BillingByModel {
|
||||||
|
billing: api_billed_token_counts_from_domain(&totals.billing),
|
||||||
model: ModelReference { id: model.clone() },
|
model: ModelReference { id: model.clone() },
|
||||||
stages: totals.stages,
|
stages: totals.stages,
|
||||||
usage: TokenUsage {
|
|
||||||
input_tokens: totals.input_tokens,
|
|
||||||
output_tokens: totals.output_tokens,
|
|
||||||
cost: totals.cost,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let response = AggregateUsage {
|
let total_billing =
|
||||||
totals: AggregateUsageTotals {
|
by_model
|
||||||
|
.iter()
|
||||||
|
.fold(BilledTokenCounts::default(), |mut acc, model| {
|
||||||
|
acc.input_tokens += model.billing.input_tokens;
|
||||||
|
acc.output_tokens += model.billing.output_tokens;
|
||||||
|
acc.reasoning_tokens += model.billing.reasoning_tokens.unwrap_or(0);
|
||||||
|
acc.cache_read_tokens += model.billing.cache_read_tokens.unwrap_or(0);
|
||||||
|
acc.cache_write_tokens += model.billing.cache_write_tokens.unwrap_or(0);
|
||||||
|
acc.total_tokens += model.billing.total_tokens;
|
||||||
|
if let Some(value) = model.billing.total_usd_micros {
|
||||||
|
*acc.total_usd_micros.get_or_insert(0) += value;
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
let response = AggregateBilling {
|
||||||
|
totals: AggregateBillingTotals {
|
||||||
|
cache_read_tokens: nonzero_i64(total_billing.cache_read_tokens),
|
||||||
|
cache_write_tokens: nonzero_i64(total_billing.cache_write_tokens),
|
||||||
|
input_tokens: total_billing.input_tokens,
|
||||||
|
output_tokens: total_billing.output_tokens,
|
||||||
|
reasoning_tokens: nonzero_i64(total_billing.reasoning_tokens),
|
||||||
runs: agg.total_runs,
|
runs: agg.total_runs,
|
||||||
input_tokens: by_model.iter().map(|m| m.usage.input_tokens).sum(),
|
|
||||||
output_tokens: by_model.iter().map(|m| m.usage.output_tokens).sum(),
|
|
||||||
cost: by_model.iter().map(|m| m.usage.cost).sum(),
|
|
||||||
runtime_secs: agg.total_runtime_secs,
|
runtime_secs: agg.total_runtime_secs,
|
||||||
|
total_tokens: total_billing.total_tokens,
|
||||||
|
total_usd_micros: total_billing.total_usd_micros,
|
||||||
},
|
},
|
||||||
by_model,
|
by_model,
|
||||||
};
|
};
|
||||||
(StatusCode::OK, Json(response)).into_response()
|
(StatusCode::OK, Json(response)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_run_billing(
|
||||||
|
_auth: AuthenticatedService,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<RunId>,
|
||||||
|
) -> Response {
|
||||||
|
let run_store = match state.store.open_run_reader(&id).await {
|
||||||
|
Ok(run_store) => run_store,
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::NOT_FOUND, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let checkpoint = match run_store.state().await {
|
||||||
|
Ok(state) => state.checkpoint,
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(checkpoint) = checkpoint else {
|
||||||
|
let empty = RunBilling {
|
||||||
|
by_model: Vec::new(),
|
||||||
|
stages: Vec::new(),
|
||||||
|
totals: RunBillingTotals {
|
||||||
|
cache_read_tokens: None,
|
||||||
|
cache_write_tokens: None,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
runtime_secs: 0.0,
|
||||||
|
total_tokens: 0,
|
||||||
|
total_usd_micros: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (StatusCode::OK, Json(empty)).into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let stage_durations = match run_store.list_events().await {
|
||||||
|
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut by_model_totals = HashMap::<String, ModelBillingTotals>::new();
|
||||||
|
let mut billed_usages = Vec::new();
|
||||||
|
let mut runtime_secs = 0.0_f64;
|
||||||
|
let mut stages = Vec::new();
|
||||||
|
|
||||||
|
for node_id in &checkpoint.completed_nodes {
|
||||||
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
||||||
|
runtime_secs += duration_ms as f64 / 1000.0;
|
||||||
|
|
||||||
|
let Some(usage) = checkpoint
|
||||||
|
.node_outcomes
|
||||||
|
.get(node_id)
|
||||||
|
.and_then(|outcome| outcome.usage.as_ref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
billed_usages.push(usage.clone());
|
||||||
|
let billing = api_billed_token_counts_from_usage(usage);
|
||||||
|
let model_id = usage.model_id().to_string();
|
||||||
|
accumulate_model_billing(by_model_totals.entry(model_id.clone()).or_default(), usage);
|
||||||
|
stages.push(RunBillingStage {
|
||||||
|
billing,
|
||||||
|
model: ModelReference { id: model_id },
|
||||||
|
runtime_secs: duration_ms as f64 / 1000.0,
|
||||||
|
stage: BillingStageRef {
|
||||||
|
id: node_id.clone(),
|
||||||
|
name: node_id.clone(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let totals = BilledTokenCounts::from_billed_usage(&billed_usages);
|
||||||
|
let by_model = by_model_totals
|
||||||
|
.into_iter()
|
||||||
|
.map(|(model, totals)| BillingByModel {
|
||||||
|
billing: api_billed_token_counts_from_domain(&totals.billing),
|
||||||
|
model: ModelReference { id: model },
|
||||||
|
stages: totals.stages,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let response = RunBilling {
|
||||||
|
by_model,
|
||||||
|
stages,
|
||||||
|
totals: RunBillingTotals {
|
||||||
|
cache_read_tokens: nonzero_i64(totals.cache_read_tokens),
|
||||||
|
cache_write_tokens: nonzero_i64(totals.cache_write_tokens),
|
||||||
|
input_tokens: totals.input_tokens,
|
||||||
|
output_tokens: totals.output_tokens,
|
||||||
|
reasoning_tokens: nonzero_i64(totals.reasoning_tokens),
|
||||||
|
runtime_secs,
|
||||||
|
total_tokens: totals.total_tokens,
|
||||||
|
total_usd_micros: totals.total_usd_micros,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(response)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// Create an `AppState` with default settings.
|
/// Create an `AppState` with default settings.
|
||||||
pub fn create_app_state() -> Arc<AppState> {
|
pub fn create_app_state() -> Arc<AppState> {
|
||||||
create_app_state_with_options(Settings::default(), 5)
|
create_app_state_with_options(Settings::default(), 5)
|
||||||
|
|
@ -1392,7 +1555,7 @@ pub(crate) fn build_app_state_with_path(
|
||||||
let (global_event_tx, _) = broadcast::channel(4096);
|
let (global_event_tx, _) = broadcast::channel(4096);
|
||||||
Ok(Arc::new(AppState {
|
Ok(Arc::new(AppState {
|
||||||
runs: Mutex::new(HashMap::new()),
|
runs: Mutex::new(HashMap::new()),
|
||||||
aggregate_usage: Mutex::new(UsageAccumulator::default()),
|
aggregate_billing: Mutex::new(BillingAccumulator::default()),
|
||||||
store,
|
store,
|
||||||
artifact_store,
|
artifact_store,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
|
|
@ -2695,18 +2858,18 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut agg = state
|
let mut agg = state
|
||||||
.aggregate_usage
|
.aggregate_billing
|
||||||
.lock()
|
.lock()
|
||||||
.expect("aggregate_usage lock poisoned");
|
.expect("aggregate_billing lock poisoned");
|
||||||
agg.total_runs += 1;
|
agg.total_runs += 1;
|
||||||
let mut run_runtime: f64 = 0.0;
|
let mut run_runtime: f64 = 0.0;
|
||||||
for (node_id, outcome) in &cp.node_outcomes {
|
for (node_id, outcome) in &cp.node_outcomes {
|
||||||
if let Some(usage) = &outcome.usage {
|
if let Some(usage) = &outcome.usage {
|
||||||
let entry = agg.by_model.entry(usage.model.clone()).or_default();
|
let entry = agg
|
||||||
entry.stages += 1;
|
.by_model
|
||||||
entry.input_tokens += usage.input_tokens;
|
.entry(usage.model_id().to_string())
|
||||||
entry.output_tokens += usage.output_tokens;
|
.or_default();
|
||||||
entry.cost += usage.cost.unwrap_or(0.0);
|
accumulate_model_billing(entry, usage);
|
||||||
}
|
}
|
||||||
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
||||||
run_runtime += duration_ms as f64 / 1000.0;
|
run_runtime += duration_ms as f64 / 1000.0;
|
||||||
|
|
@ -2918,18 +3081,18 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut agg = state
|
let mut agg = state
|
||||||
.aggregate_usage
|
.aggregate_billing
|
||||||
.lock()
|
.lock()
|
||||||
.expect("aggregate_usage lock poisoned");
|
.expect("aggregate_billing lock poisoned");
|
||||||
agg.total_runs += 1;
|
agg.total_runs += 1;
|
||||||
let mut run_runtime: f64 = 0.0;
|
let mut run_runtime: f64 = 0.0;
|
||||||
for (node_id, outcome) in &checkpoint.node_outcomes {
|
for (node_id, outcome) in &checkpoint.node_outcomes {
|
||||||
if let Some(usage) = &outcome.usage {
|
if let Some(usage) = &outcome.usage {
|
||||||
let entry = agg.by_model.entry(usage.model.clone()).or_default();
|
let entry = agg
|
||||||
entry.stages += 1;
|
.by_model
|
||||||
entry.input_tokens += usage.input_tokens;
|
.entry(usage.model_id().to_string())
|
||||||
entry.output_tokens += usage.output_tokens;
|
.or_default();
|
||||||
entry.cost += usage.cost.unwrap_or(0.0);
|
accumulate_model_billing(entry, usage);
|
||||||
}
|
}
|
||||||
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
||||||
run_runtime += duration_ms as f64 / 1000.0;
|
run_runtime += duration_ms as f64 / 1000.0;
|
||||||
|
|
@ -4309,14 +4472,14 @@ async fn create_completion(
|
||||||
if use_stream {
|
if use_stream {
|
||||||
let finish_event = StreamEvent::finish(
|
let finish_event = StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage::default(),
|
TokenCounts::default(),
|
||||||
LlmResponse {
|
LlmResponse {
|
||||||
id: msg_id.clone(),
|
id: msg_id.clone(),
|
||||||
model: model_id.clone(),
|
model: model_id.clone(),
|
||||||
provider: String::new(),
|
provider: String::new(),
|
||||||
message: LlmMessage::assistant(""),
|
message: LlmMessage::assistant(""),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage::default(),
|
usage: TokenCounts::default(),
|
||||||
raw: None,
|
raw: None,
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use crate::helpers::{
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn aggregate_usage_increments_after_run_completes() {
|
async fn aggregate_billing_increments_after_run_completes() {
|
||||||
let state = test_app_state_with_options(dry_run_settings(), 5);
|
let state = test_app_state_with_options(dry_run_settings(), 5);
|
||||||
let app = test_app_with_scheduler(state);
|
let app = test_app_with_scheduler(state);
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ async fn aggregate_usage_increments_after_run_completes() {
|
||||||
for _ in 0..POLL_ATTEMPTS {
|
for _ in 0..POLL_ATTEMPTS {
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri(api("/usage"))
|
.uri(api("/billing"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ use fabro_types::run_event::{
|
||||||
RunFailedProps, StageCompletedProps, StagePromptProps,
|
RunFailedProps, StageCompletedProps, StagePromptProps,
|
||||||
};
|
};
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
Checkpoint, Conclusion, EventBody, FailureSignature, NodeStatusRecord, Outcome,
|
BilledModelUsage, Checkpoint, Conclusion, EventBody, FailureSignature, NodeStatusRecord,
|
||||||
PullRequestRecord, Retro, RunControlAction, RunEvent, RunId, RunRecord, RunStatus,
|
Outcome, PullRequestRecord, Retro, RunControlAction, RunEvent, RunId, RunRecord, RunStatus,
|
||||||
RunStatusRecord, SandboxRecord, StageStatus, StageUsage, StartRecord, StatusReason, TokenUsage,
|
RunStatusRecord, SandboxRecord, StageStatus, StartRecord, StatusReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -50,20 +50,6 @@ pub struct NodeState {
|
||||||
pub stderr: Option<String>,
|
pub stderr: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
|
||||||
struct RunUsage {
|
|
||||||
input_tokens: i64,
|
|
||||||
output_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
reasoning_tokens: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
cache_read_tokens: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
cache_write_tokens: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
cost: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub(crate) struct EventProjectionCache {
|
pub(crate) struct EventProjectionCache {
|
||||||
pub last_seq: u32,
|
pub last_seq: u32,
|
||||||
|
|
@ -357,10 +343,11 @@ impl RunProjection {
|
||||||
.conclusion
|
.conclusion
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|conclusion| conclusion.duration_ms),
|
.map(|conclusion| conclusion.duration_ms),
|
||||||
total_cost: self
|
total_usd_micros: self
|
||||||
.conclusion
|
.conclusion
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|conclusion| conclusion.total_cost),
|
.and_then(|conclusion| conclusion.billing.as_ref())
|
||||||
|
.and_then(|billing| billing.total_usd_micros),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,7 +424,6 @@ fn conclusion_from_completed(
|
||||||
props: &RunCompletedProps,
|
props: &RunCompletedProps,
|
||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
) -> Result<Conclusion> {
|
) -> Result<Conclusion> {
|
||||||
let usage = props.usage.as_ref().map(run_usage_from_token_usage);
|
|
||||||
Ok(Conclusion {
|
Ok(Conclusion {
|
||||||
timestamp,
|
timestamp,
|
||||||
status: StageStatus::from_str(&props.status).map_err(|err| {
|
status: StageStatus::from_str(&props.status).map_err(|err| {
|
||||||
|
|
@ -447,23 +433,8 @@ fn conclusion_from_completed(
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
final_git_commit_sha: props.final_git_commit_sha.clone(),
|
final_git_commit_sha: props.final_git_commit_sha.clone(),
|
||||||
stages: Vec::new(),
|
stages: Vec::new(),
|
||||||
total_cost: props.total_cost,
|
billing: props.billing.clone(),
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: usage.as_ref().map_or(0, |usage| usage.input_tokens),
|
|
||||||
total_output_tokens: usage.as_ref().map_or(0, |usage| usage.output_tokens),
|
|
||||||
total_cache_read_tokens: usage
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|usage| usage.cache_read_tokens)
|
|
||||||
.unwrap_or(0),
|
|
||||||
total_cache_write_tokens: usage
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|usage| usage.cache_write_tokens)
|
|
||||||
.unwrap_or(0),
|
|
||||||
total_reasoning_tokens: usage
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|usage| usage.reasoning_tokens)
|
|
||||||
.unwrap_or(0),
|
|
||||||
has_pricing: usage.as_ref().is_some_and(|usage| usage.cost.is_some()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,14 +446,8 @@ fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime<Utc>) -> C
|
||||||
failure_reason: Some(props.error.clone()),
|
failure_reason: Some(props.error.clone()),
|
||||||
final_git_commit_sha: props.git_commit_sha.clone(),
|
final_git_commit_sha: props.git_commit_sha.clone(),
|
||||||
stages: Vec::new(),
|
stages: Vec::new(),
|
||||||
total_cost: None,
|
billing: None,
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,7 +462,7 @@ fn stage_visit(
|
||||||
.or_else(|| state.current_visit_for(node_id))
|
.or_else(|| state.current_visit_for(node_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<StageUsage>> {
|
fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<BilledModelUsage>> {
|
||||||
Outcome {
|
Outcome {
|
||||||
status: props.status.clone(),
|
status: props.status.clone(),
|
||||||
preferred_label: props.preferred_label.clone(),
|
preferred_label: props.preferred_label.clone(),
|
||||||
|
|
@ -511,14 +476,14 @@ fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<Stage
|
||||||
jump_to_node: props.jump_to_node.clone(),
|
jump_to_node: props.jump_to_node.clone(),
|
||||||
notes: props.notes.clone(),
|
notes: props.notes.clone(),
|
||||||
failure: props.failure.clone(),
|
failure: props.failure.clone(),
|
||||||
usage: props.usage.clone(),
|
usage: props.billing.clone(),
|
||||||
files_touched: props.files_touched.clone(),
|
files_touched: props.files_touched.clone(),
|
||||||
duration_ms: Some(props.duration_ms),
|
duration_ms: Some(props.duration_ms),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_status_from_outcome(
|
fn node_status_from_outcome(
|
||||||
outcome: &Outcome<Option<StageUsage>>,
|
outcome: &Outcome<Option<BilledModelUsage>>,
|
||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
) -> NodeStatusRecord {
|
) -> NodeStatusRecord {
|
||||||
NodeStatusRecord {
|
NodeStatusRecord {
|
||||||
|
|
@ -570,17 +535,6 @@ fn provider_used_from_agent_cli_started(props: &AgentCliStartedProps) -> Value {
|
||||||
Value::Object(provider_used)
|
Value::Object(provider_used)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_usage_from_token_usage(usage: &TokenUsage) -> RunUsage {
|
|
||||||
RunUsage {
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
reasoning_tokens: usage.reasoning_tokens,
|
|
||||||
cache_read_tokens: usage.cache_read_tokens,
|
|
||||||
cache_write_tokens: usage.cache_write_tokens,
|
|
||||||
cost: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ pub struct RunSummary {
|
||||||
pub status_reason: Option<StatusReason>,
|
pub status_reason: Option<StatusReason>,
|
||||||
pub pending_control: Option<RunControlAction>,
|
pub pending_control: Option<RunControlAction>,
|
||||||
pub duration_ms: Option<u64>,
|
pub duration_ms: Option<u64>,
|
||||||
pub total_cost: Option<f64>,
|
pub total_usd_micros: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ chrono = { workspace = true, features = ["serde"] }
|
||||||
clap = { workspace = true, optional = true }
|
clap = { workspace = true, optional = true }
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
fabro-macros = { path = "../fabro-macros" }
|
fabro-macros = { path = "../fabro-macros" }
|
||||||
|
fabro-model = { path = "../fabro-model" }
|
||||||
fabro-util = { path = "../fabro-util" }
|
fabro-util = { path = "../fabro-util" }
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
6
lib/crates/fabro-types/src/billing.rs
Normal file
6
lib/crates/fabro-types/src/billing.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub use fabro_model::{
|
||||||
|
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
|
||||||
|
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
|
||||||
|
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||||
|
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||||
|
};
|
||||||
|
|
@ -4,9 +4,9 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::billing::BilledModelUsage;
|
||||||
use crate::failure_signature::FailureSignature;
|
use crate::failure_signature::FailureSignature;
|
||||||
use crate::outcome::Outcome;
|
use crate::outcome::Outcome;
|
||||||
use crate::usage::StageUsage;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Checkpoint {
|
pub struct Checkpoint {
|
||||||
|
|
@ -16,7 +16,7 @@ pub struct Checkpoint {
|
||||||
pub node_retries: HashMap<String, u32>,
|
pub node_retries: HashMap<String, u32>,
|
||||||
pub context_values: HashMap<String, Value>,
|
pub context_values: HashMap<String, Value>,
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
pub node_outcomes: HashMap<String, Outcome<Option<StageUsage>>>,
|
pub node_outcomes: HashMap<String, Outcome<Option<BilledModelUsage>>>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub next_node_id: Option<String>,
|
pub next_node_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::BilledTokenCounts;
|
||||||
use crate::outcome::StageStatus;
|
use crate::outcome::StageStatus;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -9,7 +10,7 @@ pub struct StageSummary {
|
||||||
pub stage_label: String,
|
pub stage_label: String,
|
||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cost: Option<f64>,
|
pub billing_usd_micros: Option<i64>,
|
||||||
pub retries: u32,
|
pub retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,19 +26,7 @@ pub struct Conclusion {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub stages: Vec<StageSummary>,
|
pub stages: Vec<StageSummary>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub total_cost: Option<f64>,
|
pub billing: Option<BilledTokenCounts>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub total_retries: u32,
|
pub total_retries: u32,
|
||||||
#[serde(default)]
|
|
||||||
pub total_input_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub total_output_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub total_cache_read_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub total_cache_write_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub total_reasoning_tokens: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_pricing: bool,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
extern crate self as fabro_types;
|
extern crate self as fabro_types;
|
||||||
|
|
||||||
|
pub mod billing;
|
||||||
pub mod checkpoint;
|
pub mod checkpoint;
|
||||||
pub mod combine;
|
pub mod combine;
|
||||||
pub mod conclusion;
|
pub mod conclusion;
|
||||||
|
|
@ -18,10 +19,16 @@ pub mod settings;
|
||||||
pub mod stage_id;
|
pub mod stage_id;
|
||||||
pub mod start;
|
pub mod start;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod usage;
|
|
||||||
|
|
||||||
|
pub use billing::{
|
||||||
|
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
|
||||||
|
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
|
||||||
|
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||||
|
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||||
|
};
|
||||||
pub use checkpoint::Checkpoint;
|
pub use checkpoint::Checkpoint;
|
||||||
pub use conclusion::{Conclusion, StageSummary};
|
pub use conclusion::{Conclusion, StageSummary};
|
||||||
|
pub use fabro_macros::Combine;
|
||||||
pub use failure_signature::FailureSignature;
|
pub use failure_signature::FailureSignature;
|
||||||
pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type};
|
pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type};
|
||||||
pub use node_status::NodeStatusRecord;
|
pub use node_status::NodeStatusRecord;
|
||||||
|
|
@ -36,7 +43,7 @@ pub use run::{
|
||||||
RunSubjectProvenance,
|
RunSubjectProvenance,
|
||||||
};
|
};
|
||||||
pub use run_blob_id::RunBlobId;
|
pub use run_blob_id::RunBlobId;
|
||||||
pub use run_event::{EventBody, RunEvent, RunNoticeLevel, TokenUsage};
|
pub use run_event::{EventBody, RunEvent, RunNoticeLevel};
|
||||||
pub use run_id::RunId;
|
pub use run_id::RunId;
|
||||||
pub use run_id::fixtures;
|
pub use run_id::fixtures;
|
||||||
pub use sandbox_record::SandboxRecord;
|
pub use sandbox_record::SandboxRecord;
|
||||||
|
|
@ -47,6 +54,3 @@ pub use status::{
|
||||||
InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord,
|
InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord,
|
||||||
StatusReason,
|
StatusReason,
|
||||||
};
|
};
|
||||||
pub use usage::StageUsage;
|
|
||||||
|
|
||||||
pub use fabro_macros::Combine;
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ pub struct StageRetro {
|
||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
pub retries: u32,
|
pub retries: u32,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cost: Option<f64>,
|
pub billing_usd_micros: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -97,7 +97,7 @@ pub struct StageRetro {
|
||||||
pub struct AggregateStats {
|
pub struct AggregateStats {
|
||||||
pub total_duration_ms: u64,
|
pub total_duration_ms: u64,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub total_cost: Option<f64>,
|
pub total_billing_usd_micros: Option<i64>,
|
||||||
pub total_retries: u32,
|
pub total_retries: u32,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub files_touched: Vec<String>,
|
pub files_touched: Vec<String>,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::TokenUsage;
|
use super::BilledTokenCounts;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AgentSessionStartedProps {
|
pub struct AgentSessionStartedProps {
|
||||||
|
|
@ -32,7 +32,7 @@ pub struct AgentInputProps {
|
||||||
pub struct AgentMessageProps {
|
pub struct AgentMessageProps {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub usage: TokenUsage,
|
pub billing: BilledTokenCounts,
|
||||||
pub tool_call_count: usize,
|
pub tool_call_count: usize,
|
||||||
pub visit: u32,
|
pub visit: u32,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod run;
|
||||||
pub mod stage;
|
pub mod stage;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
pub use fabro_model::BilledTokenCounts;
|
||||||
use serde::de::Error as DeError;
|
use serde::de::Error as DeError;
|
||||||
use serde::ser::Error as SerError;
|
use serde::ser::Error as SerError;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
@ -26,23 +27,6 @@ pub enum RunNoticeLevel {
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TokenUsage {
|
|
||||||
pub input_tokens: i64,
|
|
||||||
pub output_tokens: i64,
|
|
||||||
pub total_tokens: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reasoning_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_read_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_write_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub speed: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub raw: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct RunEvent {
|
pub struct RunEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason};
|
use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason};
|
||||||
|
|
||||||
use super::{RunNoticeLevel, TokenUsage};
|
use super::{BilledTokenCounts, RunNoticeLevel};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RunCreatedProps {
|
pub struct RunCreatedProps {
|
||||||
|
|
@ -81,13 +81,13 @@ pub struct RunCompletedProps {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<StatusReason>,
|
pub reason: Option<StatusReason>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub total_cost: Option<f64>,
|
pub total_usd_micros: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub final_git_commit_sha: Option<String>,
|
pub final_git_commit_sha: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub final_patch: Option<String>,
|
pub final_patch: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub usage: Option<TokenUsage>,
|
pub billing: Option<BilledTokenCounts>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{FailureDetail, Outcome, StageStatus, StageUsage};
|
use crate::{BilledModelUsage, FailureDetail, Outcome, StageStatus};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct StageStartedProps {
|
pub struct StageStartedProps {
|
||||||
|
|
@ -23,7 +23,7 @@ pub struct StageCompletedProps {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub suggested_next_ids: Vec<String>,
|
pub suggested_next_ids: Vec<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub usage: Option<StageUsage>,
|
pub billing: Option<BilledModelUsage>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub failure: Option<FailureDetail>,
|
pub failure: Option<FailureDetail>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -82,7 +82,7 @@ pub struct PromptCompletedProps {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub usage: Option<StageUsage>,
|
pub billing: Option<BilledModelUsage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -96,7 +96,7 @@ pub struct CheckpointCompletedProps {
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub context_values: BTreeMap<String, Value>,
|
pub context_values: BTreeMap<String, Value>,
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub node_outcomes: BTreeMap<String, Outcome<Option<StageUsage>>>,
|
pub node_outcomes: BTreeMap<String, Outcome<Option<BilledModelUsage>>>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub next_node_id: Option<String>,
|
pub next_node_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct StageUsage {
|
|
||||||
pub model: String,
|
|
||||||
pub input_tokens: i64,
|
|
||||||
pub output_tokens: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_read_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_write_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reasoning_tokens: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub speed: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cost: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,9 @@ use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
|
||||||
use ::fabro_types::run_event as fabro_types;
|
use ::fabro_types::run_event as fabro_types;
|
||||||
use ::fabro_types::{RunControlAction, RunEvent, RunId, StageStatus, StatusReason};
|
use ::fabro_types::{
|
||||||
|
BilledTokenCounts, RunControlAction, RunEvent, RunId, StageStatus, StatusReason,
|
||||||
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use fabro_store::{EventPayload, RunDatabase};
|
use fabro_store::{EventPayload, RunDatabase};
|
||||||
|
|
@ -17,9 +19,9 @@ use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::outcome::{FailureDetail, Outcome, StageUsage};
|
use crate::outcome::{BilledModelUsage, FailureDetail, Outcome};
|
||||||
use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback};
|
use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback};
|
||||||
use fabro_llm::types::Usage as LlmUsage;
|
use fabro_llm::types::TokenCounts as LlmTokenCounts;
|
||||||
use fabro_util::redact::redact_json_value;
|
use fabro_util::redact::redact_json_value;
|
||||||
|
|
||||||
pub use fabro_types::{EventBody, RunNoticeLevel};
|
pub use fabro_types::{EventBody, RunNoticeLevel};
|
||||||
|
|
@ -104,13 +106,13 @@ pub enum Event {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
reason: Option<StatusReason>,
|
reason: Option<StatusReason>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
total_cost: Option<f64>,
|
total_usd_micros: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
final_git_commit_sha: Option<String>,
|
final_git_commit_sha: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
final_patch: Option<String>,
|
final_patch: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
usage: Option<LlmUsage>,
|
billing: Option<BilledTokenCounts>,
|
||||||
},
|
},
|
||||||
WorkflowRunFailed {
|
WorkflowRunFailed {
|
||||||
error: FabroError,
|
error: FabroError,
|
||||||
|
|
@ -141,7 +143,7 @@ pub enum Event {
|
||||||
status: String,
|
status: String,
|
||||||
preferred_label: Option<String>,
|
preferred_label: Option<String>,
|
||||||
suggested_next_ids: Vec<String>,
|
suggested_next_ids: Vec<String>,
|
||||||
usage: Option<StageUsage>,
|
billing: Option<BilledModelUsage>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
failure: Option<FailureDetail>,
|
failure: Option<FailureDetail>,
|
||||||
notes: Option<String>,
|
notes: Option<String>,
|
||||||
|
|
@ -315,7 +317,7 @@ pub enum Event {
|
||||||
model: String,
|
model: String,
|
||||||
provider: String,
|
provider: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
usage: Option<StageUsage>,
|
billing: Option<BilledModelUsage>,
|
||||||
},
|
},
|
||||||
/// Forwarded from an agent session, tagged with the workflow stage.
|
/// Forwarded from an agent session, tagged with the workflow stage.
|
||||||
Agent {
|
Agent {
|
||||||
|
|
@ -1222,16 +1224,15 @@ fn default_node_label(node_id: Option<&String>, node_label: Option<String>) -> O
|
||||||
node_label.or_else(|| node_id.cloned())
|
node_label.or_else(|| node_id.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_usage_from_llm(usage: &LlmUsage) -> fabro_types::TokenUsage {
|
fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts {
|
||||||
fabro_types::TokenUsage {
|
BilledTokenCounts {
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens,
|
||||||
output_tokens: usage.output_tokens,
|
output_tokens: usage.output_tokens,
|
||||||
total_tokens: usage.total_tokens,
|
total_tokens: usage.total_tokens(),
|
||||||
reasoning_tokens: usage.reasoning_tokens,
|
reasoning_tokens: usage.reasoning_tokens,
|
||||||
cache_read_tokens: usage.cache_read_tokens,
|
cache_read_tokens: usage.cache_read_tokens,
|
||||||
cache_write_tokens: usage.cache_write_tokens,
|
cache_write_tokens: usage.cache_write_tokens,
|
||||||
speed: usage.speed.clone(),
|
total_usd_micros: None,
|
||||||
raw: usage.raw.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1437,19 +1438,19 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
||||||
artifact_count,
|
artifact_count,
|
||||||
status,
|
status,
|
||||||
reason,
|
reason,
|
||||||
total_cost,
|
total_usd_micros,
|
||||||
final_git_commit_sha,
|
final_git_commit_sha,
|
||||||
final_patch,
|
final_patch,
|
||||||
usage,
|
billing,
|
||||||
} => EventBody::RunCompleted(fabro_types::RunCompletedProps {
|
} => EventBody::RunCompleted(fabro_types::RunCompletedProps {
|
||||||
duration_ms: *duration_ms,
|
duration_ms: *duration_ms,
|
||||||
artifact_count: *artifact_count,
|
artifact_count: *artifact_count,
|
||||||
status: status.clone(),
|
status: status.clone(),
|
||||||
reason: *reason,
|
reason: *reason,
|
||||||
total_cost: *total_cost,
|
total_usd_micros: *total_usd_micros,
|
||||||
final_git_commit_sha: final_git_commit_sha.clone(),
|
final_git_commit_sha: final_git_commit_sha.clone(),
|
||||||
final_patch: final_patch.clone(),
|
final_patch: final_patch.clone(),
|
||||||
usage: usage.as_ref().map(token_usage_from_llm),
|
billing: billing.clone(),
|
||||||
}),
|
}),
|
||||||
Event::WorkflowRunFailed {
|
Event::WorkflowRunFailed {
|
||||||
error,
|
error,
|
||||||
|
|
@ -1489,7 +1490,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
||||||
status,
|
status,
|
||||||
preferred_label,
|
preferred_label,
|
||||||
suggested_next_ids,
|
suggested_next_ids,
|
||||||
usage,
|
billing,
|
||||||
failure,
|
failure,
|
||||||
notes,
|
notes,
|
||||||
files_touched,
|
files_touched,
|
||||||
|
|
@ -1509,7 +1510,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
||||||
status: stage_status_from_string(status),
|
status: stage_status_from_string(status),
|
||||||
preferred_label: preferred_label.clone(),
|
preferred_label: preferred_label.clone(),
|
||||||
suggested_next_ids: suggested_next_ids.clone(),
|
suggested_next_ids: suggested_next_ids.clone(),
|
||||||
usage: usage.clone(),
|
billing: billing.clone(),
|
||||||
failure: failure.clone(),
|
failure: failure.clone(),
|
||||||
notes: notes.clone(),
|
notes: notes.clone(),
|
||||||
files_touched: files_touched.clone(),
|
files_touched: files_touched.clone(),
|
||||||
|
|
@ -1716,13 +1717,13 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
||||||
response,
|
response,
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
usage,
|
billing,
|
||||||
..
|
..
|
||||||
} => EventBody::PromptCompleted(fabro_types::PromptCompletedProps {
|
} => EventBody::PromptCompleted(fabro_types::PromptCompletedProps {
|
||||||
response: response.clone(),
|
response: response.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
usage: usage.clone(),
|
billing: billing.clone(),
|
||||||
}),
|
}),
|
||||||
Event::Agent { visit, event, .. } => match event {
|
Event::Agent { visit, event, .. } => match event {
|
||||||
AgentEvent::SessionStarted { provider, model } => {
|
AgentEvent::SessionStarted { provider, model } => {
|
||||||
|
|
@ -1752,7 +1753,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
||||||
} => EventBody::AgentMessage(fabro_types::AgentMessageProps {
|
} => EventBody::AgentMessage(fabro_types::AgentMessageProps {
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
usage: token_usage_from_llm(usage),
|
billing: billed_token_counts_from_llm(usage),
|
||||||
tool_call_count: *tool_call_count,
|
tool_call_count: *tool_call_count,
|
||||||
visit: *visit,
|
visit: *visit,
|
||||||
}),
|
}),
|
||||||
|
|
@ -2690,7 +2691,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
@ -2728,7 +2729,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,7 @@ mod tests {
|
||||||
response: "world".into(),
|
response: "world".into(),
|
||||||
model: "gpt-5.4".into(),
|
model: "gpt-5.4".into(),
|
||||||
provider: "openai".into(),
|
provider: "openai".into(),
|
||||||
usage: None,
|
billing: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -453,7 +453,7 @@ mod tests {
|
||||||
status: "success".into(),
|
status: "success".into(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use fabro_core::graph::{EdgeSelection as CoreEdgeSelection, EdgeSpec, Graph, Nod
|
||||||
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
|
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
|
||||||
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::outcome::{Outcome, StageUsage};
|
use crate::outcome::{BilledModelUsage, Outcome};
|
||||||
|
|
||||||
// ---- WorkflowNode ----
|
// ---- WorkflowNode ----
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ impl WorkflowGraph {
|
||||||
impl Graph for WorkflowGraph {
|
impl Graph for WorkflowGraph {
|
||||||
type Node = WorkflowNode;
|
type Node = WorkflowNode;
|
||||||
type Edge = WorkflowEdge;
|
type Edge = WorkflowEdge;
|
||||||
type Meta = Option<StageUsage>;
|
type Meta = Option<BilledModelUsage>;
|
||||||
|
|
||||||
fn get_node(&self, id: &str) -> Option<Self::Node> {
|
fn get_node(&self, id: &str) -> Option<Self::Node> {
|
||||||
self.0
|
self.0
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::context::{Context, WorkflowContext};
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::event::{Emitter, Event};
|
use crate::event::{Emitter, Event};
|
||||||
use crate::outcome::{
|
use crate::outcome::{
|
||||||
FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus, StageUsage,
|
BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus,
|
||||||
};
|
};
|
||||||
use crate::run_dir::visit_from_context;
|
use crate::run_dir::visit_from_context;
|
||||||
use crate::transforms::variable_expansion::expand_vars;
|
use crate::transforms::variable_expansion::expand_vars;
|
||||||
|
|
@ -24,7 +24,7 @@ use super::{EngineServices, Handler};
|
||||||
pub enum CodergenResult {
|
pub enum CodergenResult {
|
||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
usage: Option<StageUsage>,
|
usage: Option<BilledModelUsage>,
|
||||||
files_touched: Vec<String>,
|
files_touched: Vec<String>,
|
||||||
last_file_touched: Option<String>,
|
last_file_touched: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -321,7 +321,7 @@ impl Handler for AgentHandler {
|
||||||
|
|
||||||
let response_model = stage_usage
|
let response_model = stage_usage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|usage| usage.model.clone())
|
.map(|usage| usage.model_id().to_string())
|
||||||
.or_else(|| node.model().map(String::from))
|
.or_else(|| node.model().map(String::from))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let response_provider = node
|
let response_provider = node
|
||||||
|
|
@ -334,7 +334,7 @@ impl Handler for AgentHandler {
|
||||||
response: response_text.clone(),
|
response: response_text.clone(),
|
||||||
model: response_model,
|
model: response_model,
|
||||||
provider: response_provider,
|
provider: response_provider,
|
||||||
usage: stage_usage.clone(),
|
billing: stage_usage.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build and write status
|
// Build and write status
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ async fn llm_evaluate(
|
||||||
response: response_text.clone(),
|
response: response_text.clone(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
provider: String::new(),
|
provider: String::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
});
|
});
|
||||||
Ok(Candidate {
|
Ok(Candidate {
|
||||||
id: best_id,
|
id: best_id,
|
||||||
|
|
@ -288,7 +288,7 @@ async fn llm_evaluate(
|
||||||
response: text.clone(),
|
response: text.clone(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
provider: String::new(),
|
provider: String::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The LLM responded with text; try to find a matching candidate ID
|
// The LLM responded with text; try to find a matching candidate ID
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use fabro_agent::{
|
||||||
subagent::{SessionFactory, SubAgentManager},
|
subagent::{SessionFactory, SubAgentManager},
|
||||||
};
|
};
|
||||||
use fabro_llm::client::Client;
|
use fabro_llm::client::Client;
|
||||||
use fabro_llm::types::{Message, Request, Usage};
|
use fabro_llm::types::{Message, Request, TokenCounts};
|
||||||
use fabro_mcp::config::McpServerSettings;
|
use fabro_mcp::config::McpServerSettings;
|
||||||
use fabro_model::FallbackTarget;
|
use fabro_model::FallbackTarget;
|
||||||
use fabro_model::Provider;
|
use fabro_model::Provider;
|
||||||
|
|
@ -20,8 +20,7 @@ use crate::context::keys::Fidelity;
|
||||||
use crate::context::{Context, WorkflowContext};
|
use crate::context::{Context, WorkflowContext};
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::event::{Emitter, Event};
|
use crate::event::{Emitter, Event};
|
||||||
use crate::outcome::StageUsage;
|
use crate::outcome::billed_model_usage_from_llm;
|
||||||
use crate::outcome::compute_stage_cost;
|
|
||||||
use crate::run_dir::visit_from_context;
|
use crate::run_dir::visit_from_context;
|
||||||
use fabro_graphviz::graph::Node;
|
use fabro_graphviz::graph::Node;
|
||||||
|
|
||||||
|
|
@ -320,7 +319,7 @@ impl CodergenBackend for AgentApiBackend {
|
||||||
|
|
||||||
let default_provider = self.provider.as_str().to_string();
|
let default_provider = self.provider.as_str().to_string();
|
||||||
|
|
||||||
let (response, actual_model, _actual_provider) = match result {
|
let (response, actual_model, actual_provider) = match result {
|
||||||
Ok(resp) => (
|
Ok(resp) => (
|
||||||
resp,
|
resp,
|
||||||
request.model.clone(),
|
request.model.clone(),
|
||||||
|
|
@ -384,17 +383,13 @@ impl CodergenBackend for AgentApiBackend {
|
||||||
Err(sdk_err) => return Err(FabroError::Llm(sdk_err)),
|
Err(sdk_err) => return Err(FabroError::Llm(sdk_err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stage_usage = StageUsage {
|
let actual_provider = actual_provider.parse::<Provider>().unwrap_or(self.provider);
|
||||||
model: actual_model,
|
let stage_usage = billed_model_usage_from_llm(
|
||||||
input_tokens: response.usage.input_tokens,
|
&actual_model,
|
||||||
output_tokens: response.usage.output_tokens,
|
actual_provider,
|
||||||
cache_read_tokens: response.usage.cache_read_tokens,
|
node.speed(),
|
||||||
cache_write_tokens: response.usage.cache_write_tokens,
|
&response.usage,
|
||||||
reasoning_tokens: response.usage.reasoning_tokens,
|
);
|
||||||
speed: response.usage.speed.clone(),
|
|
||||||
cost: None,
|
|
||||||
};
|
|
||||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
|
||||||
|
|
||||||
Ok(CodergenResult::Text {
|
Ok(CodergenResult::Text {
|
||||||
text: response.text(),
|
text: response.text(),
|
||||||
|
|
@ -569,24 +564,19 @@ impl CodergenBackend for AgentApiBackend {
|
||||||
result?;
|
result?;
|
||||||
|
|
||||||
// Aggregate token usage only from new turns (prevents double-counting on reuse).
|
// Aggregate token usage only from new turns (prevents double-counting on reuse).
|
||||||
let mut total_usage = Usage::default();
|
let mut total_usage = TokenCounts::default();
|
||||||
for turn in &session.history().turns()[turns_before..] {
|
for turn in &session.history().turns()[turns_before..] {
|
||||||
if let Turn::Assistant { usage, .. } = turn {
|
if let Turn::Assistant { usage, .. } = turn {
|
||||||
total_usage = total_usage + *usage.clone();
|
total_usage = total_usage + *usage.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stage_usage = StageUsage {
|
let stage_usage = billed_model_usage_from_llm(
|
||||||
model: actual_model.clone(),
|
&actual_model,
|
||||||
input_tokens: total_usage.input_tokens,
|
_actual_provider,
|
||||||
output_tokens: total_usage.output_tokens,
|
node.speed(),
|
||||||
cache_read_tokens: total_usage.cache_read_tokens,
|
&total_usage,
|
||||||
cache_write_tokens: total_usage.cache_write_tokens,
|
);
|
||||||
reasoning_tokens: total_usage.reasoning_tokens,
|
|
||||||
speed: total_usage.speed.clone(),
|
|
||||||
cost: None,
|
|
||||||
};
|
|
||||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
|
||||||
|
|
||||||
// Extract last assistant response from the session history.
|
// Extract last assistant response from the session history.
|
||||||
let response = session
|
let response = session
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ use super::super::agent::{CodergenBackend, CodergenResult};
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::event::{Emitter, Event};
|
use crate::event::{Emitter, Event};
|
||||||
use crate::outcome::StageUsage;
|
use crate::outcome::billed_model_usage_from_llm;
|
||||||
use crate::outcome::compute_stage_cost;
|
|
||||||
use crate::run_dir::visit_from_context;
|
use crate::run_dir::visit_from_context;
|
||||||
use fabro_graphviz::graph::Node;
|
use fabro_graphviz::graph::Node;
|
||||||
|
use fabro_llm::types::TokenCounts;
|
||||||
|
|
||||||
/// Maps a provider to its corresponding CLI tool metadata.
|
/// Maps a provider to its corresponding CLI tool metadata.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -261,7 +261,7 @@ fn parse_claude_ndjson(output: &str) -> Option<CliResponse> {
|
||||||
/// Parse NDJSON output from Codex CLI (`codex exec --json`).
|
/// Parse NDJSON output from Codex CLI (`codex exec --json`).
|
||||||
///
|
///
|
||||||
/// Codex emits NDJSON lines. Text comes from `item.completed` events where
|
/// Codex emits NDJSON lines. Text comes from `item.completed` events where
|
||||||
/// `item.type == "agent_message"`. Usage comes from the `turn.completed` event.
|
/// `item.type == "agent_message"`. TokenCounts comes from the `turn.completed` event.
|
||||||
fn parse_codex_ndjson(output: &str) -> Option<CliResponse> {
|
fn parse_codex_ndjson(output: &str) -> Option<CliResponse> {
|
||||||
let mut last_message_text = String::new();
|
let mut last_message_text = String::new();
|
||||||
let mut input_tokens: i64 = 0;
|
let mut input_tokens: i64 = 0;
|
||||||
|
|
@ -689,17 +689,16 @@ impl CodergenBackend for AgentCliBackend {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stage_usage = StageUsage {
|
let stage_usage = billed_model_usage_from_llm(
|
||||||
model: model.to_string(),
|
model,
|
||||||
input_tokens: parsed.input_tokens,
|
provider,
|
||||||
output_tokens: parsed.output_tokens,
|
node.speed(),
|
||||||
cache_read_tokens: None,
|
&TokenCounts {
|
||||||
cache_write_tokens: None,
|
input_tokens: parsed.input_tokens,
|
||||||
reasoning_tokens: None,
|
output_tokens: parsed.output_tokens,
|
||||||
speed: None,
|
..TokenCounts::default()
|
||||||
cost: None,
|
},
|
||||||
};
|
);
|
||||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
|
||||||
|
|
||||||
Ok(CodergenResult::Text {
|
Ok(CodergenResult::Text {
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ use std::fmt::Write;
|
||||||
use crate::artifact::{artifact_path, format_artifact_reference};
|
use crate::artifact::{artifact_path, format_artifact_reference};
|
||||||
use crate::context::keys;
|
use crate::context::keys;
|
||||||
use crate::context::{Context, WorkflowContext};
|
use crate::context::{Context, WorkflowContext};
|
||||||
use crate::outcome::Outcome;
|
use crate::outcome::{Outcome, OutcomeExt};
|
||||||
use crate::outcome::OutcomeExt;
|
|
||||||
use fabro_graphviz::graph::{Graph, Node, is_llm_handler_type};
|
use fabro_graphviz::graph::{Graph, Node, is_llm_handler_type};
|
||||||
|
|
||||||
const COMPACT_OUTPUT_MAX_LINES: usize = 25;
|
const COMPACT_OUTPUT_MAX_LINES: usize = 25;
|
||||||
|
|
@ -209,11 +208,13 @@ fn render_compact_stage_details(
|
||||||
h if is_llm_handler_type(h) => {
|
h if is_llm_handler_type(h) => {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
if let Some(usage) = &outcome.usage {
|
if let Some(usage) = &outcome.usage {
|
||||||
let input = format_token_count(usage.input_tokens);
|
let input = format_token_count(usage.tokens().input_tokens);
|
||||||
let output = format_token_count(usage.output_tokens);
|
let output = format_token_count(usage.tokens().billable_output_tokens());
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" - Model: {}, {} tokens in / {} out",
|
" - Model: {}, {} tokens in / {} out",
|
||||||
usage.model, input, output
|
usage.model_id(),
|
||||||
|
input,
|
||||||
|
output
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !outcome.files_touched.is_empty() {
|
if !outcome.files_touched.is_empty() {
|
||||||
|
|
@ -293,11 +294,11 @@ fn render_summary_high_stage_section(
|
||||||
}
|
}
|
||||||
h if is_llm_handler_type(h) => {
|
h if is_llm_handler_type(h) => {
|
||||||
if let Some(usage) = &outcome.usage {
|
if let Some(usage) = &outcome.usage {
|
||||||
lines.push(format!("- Model: {}", usage.model));
|
lines.push(format!("- Model: {}", usage.model_id()));
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"- Tokens: {} in / {} out",
|
"- Tokens: {} in / {} out",
|
||||||
format_token_count(usage.input_tokens),
|
format_token_count(usage.tokens().input_tokens),
|
||||||
format_token_count(usage.output_tokens)
|
format_token_count(usage.tokens().billable_output_tokens())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !outcome.files_touched.is_empty() {
|
if !outcome.files_touched.is_empty() {
|
||||||
|
|
@ -595,7 +596,7 @@ fn build_summary_preamble(
|
||||||
}
|
}
|
||||||
h if is_llm_handler_type(h) => {
|
h if is_llm_handler_type(h) => {
|
||||||
if let Some(usage) = &outcome.usage {
|
if let Some(usage) = &outcome.usage {
|
||||||
parts.push(format!(" - Model: {}", usage.model));
|
parts.push(format!(" - Model: {}", usage.model_id()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -615,8 +616,23 @@ fn build_summary_preamble(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::outcome::StageUsage;
|
use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm};
|
||||||
use fabro_graphviz::graph::AttrValue;
|
use fabro_graphviz::graph::AttrValue;
|
||||||
|
use fabro_llm::types::TokenCounts;
|
||||||
|
use fabro_model::Provider;
|
||||||
|
|
||||||
|
fn stage_usage(model: &str, input_tokens: i64, output_tokens: i64) -> BilledModelUsage {
|
||||||
|
billed_model_usage_from_llm(
|
||||||
|
model,
|
||||||
|
Provider::Anthropic,
|
||||||
|
None,
|
||||||
|
&TokenCounts {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
..TokenCounts::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- truncate mode ---
|
// --- truncate mode ---
|
||||||
|
|
||||||
|
|
@ -920,16 +936,7 @@ mod tests {
|
||||||
let completed_nodes = vec!["report".to_string()];
|
let completed_nodes = vec!["report".to_string()];
|
||||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||||
let mut outcome = Outcome::success();
|
let mut outcome = Outcome::success();
|
||||||
outcome.usage = Some(StageUsage {
|
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1234, 567));
|
||||||
model: "claude-sonnet-4-20250514".to_string(),
|
|
||||||
input_tokens: 1234,
|
|
||||||
output_tokens: 567,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
reasoning_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
cost: None,
|
|
||||||
});
|
|
||||||
outcome.files_touched = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
|
outcome.files_touched = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
|
||||||
node_outcomes.insert("report".to_string(), outcome);
|
node_outcomes.insert("report".to_string(), outcome);
|
||||||
|
|
||||||
|
|
@ -1187,16 +1194,7 @@ mod tests {
|
||||||
let completed_nodes = vec!["report".to_string()];
|
let completed_nodes = vec!["report".to_string()];
|
||||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||||
let mut outcome = Outcome::success();
|
let mut outcome = Outcome::success();
|
||||||
outcome.usage = Some(StageUsage {
|
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1000, 200));
|
||||||
model: "claude-sonnet-4-20250514".to_string(),
|
|
||||||
input_tokens: 1000,
|
|
||||||
output_tokens: 200,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
reasoning_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
cost: None,
|
|
||||||
});
|
|
||||||
node_outcomes.insert("report".to_string(), outcome);
|
node_outcomes.insert("report".to_string(), outcome);
|
||||||
|
|
||||||
let preamble = build_preamble(
|
let preamble = build_preamble(
|
||||||
|
|
@ -1367,16 +1365,7 @@ mod tests {
|
||||||
let completed_nodes = vec!["report".to_string()];
|
let completed_nodes = vec!["report".to_string()];
|
||||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||||
let mut outcome = Outcome::success();
|
let mut outcome = Outcome::success();
|
||||||
outcome.usage = Some(StageUsage {
|
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1500, 300));
|
||||||
model: "claude-sonnet-4-20250514".to_string(),
|
|
||||||
input_tokens: 1500,
|
|
||||||
output_tokens: 300,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
reasoning_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
cost: None,
|
|
||||||
});
|
|
||||||
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
||||||
node_outcomes.insert("report".to_string(), outcome);
|
node_outcomes.insert("report".to_string(), outcome);
|
||||||
|
|
||||||
|
|
@ -1587,16 +1576,7 @@ mod tests {
|
||||||
let completed_nodes = vec!["report".to_string()];
|
let completed_nodes = vec!["report".to_string()];
|
||||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||||
let mut outcome = Outcome::success();
|
let mut outcome = Outcome::success();
|
||||||
outcome.usage = Some(StageUsage {
|
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1500, 300));
|
||||||
model: "claude-sonnet-4-20250514".to_string(),
|
|
||||||
input_tokens: 1500,
|
|
||||||
output_tokens: 300,
|
|
||||||
cache_read_tokens: None,
|
|
||||||
cache_write_tokens: None,
|
|
||||||
reasoning_tokens: None,
|
|
||||||
speed: None,
|
|
||||||
cost: None,
|
|
||||||
});
|
|
||||||
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
||||||
outcome.context_updates.insert(
|
outcome.context_updates.insert(
|
||||||
keys::response_key("report"),
|
keys::response_key("report"),
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ impl Handler for PromptHandler {
|
||||||
|
|
||||||
let response_model = stage_usage
|
let response_model = stage_usage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|usage| usage.model.clone())
|
.map(|usage| usage.model_id().to_string())
|
||||||
.or_else(|| node.model().map(String::from))
|
.or_else(|| node.model().map(String::from))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let response_provider = node
|
let response_provider = node
|
||||||
|
|
@ -145,7 +145,7 @@ impl Handler for PromptHandler {
|
||||||
response: response_text.clone(),
|
response: response_text.clone(),
|
||||||
model: response_model,
|
model: response_model,
|
||||||
provider: response_provider,
|
provider: response_provider,
|
||||||
usage: stage_usage.clone(),
|
billing: stage_usage.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Build and write status
|
// 4. Build and write status
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,9 @@ pub fn build_completed_stages(cp: &records::Checkpoint, run_failed: bool) -> Vec
|
||||||
succeeded,
|
succeeded,
|
||||||
failed,
|
failed,
|
||||||
retries,
|
retries,
|
||||||
cost: outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost),
|
billing_usd_micros: outcome
|
||||||
|
.and_then(|o| o.usage.as_ref())
|
||||||
|
.and_then(|usage| usage.total_usd_micros),
|
||||||
notes: outcome.and_then(|o| o.notes.clone()),
|
notes: outcome.and_then(|o| o.notes.clone()),
|
||||||
failure_reason: outcome.and_then(|o| o.failure_reason().map(String::from)),
|
failure_reason: outcome.and_then(|o| o.failure_reason().map(String::from)),
|
||||||
files_touched: outcome.map(|o| o.files_touched.clone()).unwrap_or_default(),
|
files_touched: outcome.map(|o| o.files_touched.clone()).unwrap_or_default(),
|
||||||
|
|
@ -75,7 +77,7 @@ pub fn build_completed_stages(cp: &records::Checkpoint, run_failed: bool) -> Vec
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
failed: true,
|
failed: true,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
cost: None,
|
billing_usd_micros: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
files_touched: vec![],
|
files_touched: vec![],
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@ use crate::artifact_snapshot::collect_artifacts;
|
||||||
use crate::event::{Emitter, Event, RunNoticeLevel};
|
use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::StageUsage;
|
use crate::outcome::BilledModelUsage;
|
||||||
use fabro_core::error::Result as CoreResult;
|
use fabro_core::error::Result as CoreResult;
|
||||||
use fabro_core::lifecycle::NodeDecision;
|
use fabro_core::lifecycle::NodeDecision;
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
|
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
|
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
|
||||||
pub(crate) struct ArtifactLifecycle {
|
pub(crate) struct ArtifactLifecycle {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ use fabro_core::state::ExecutionState;
|
||||||
|
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::{StageStatus, StageUsage};
|
use crate::outcome::{BilledModelUsage, StageStatus};
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Sub-lifecycle responsible for auto-status override on nodes with `auto_status=true`.
|
/// Sub-lifecycle responsible for auto-status override on nodes with `auto_status=true`.
|
||||||
pub(crate) struct AutoStatusLifecycle;
|
pub(crate) struct AutoStatusLifecycle;
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ use fabro_core::state::ExecutionState;
|
||||||
use crate::error::{FailureCategory, FailureSignature, FailureSignatureExt};
|
use crate::error::{FailureCategory, FailureSignature, FailureSignatureExt};
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::{OutcomeExt, StageStatus, StageUsage};
|
use crate::outcome::{BilledModelUsage, OutcomeExt, StageStatus};
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Sub-lifecycle responsible for tracking failure signatures and tripping the
|
/// Sub-lifecycle responsible for tracking failure signatures and tripping the
|
||||||
/// circuit breaker when deterministic failure cycles are detected.
|
/// circuit breaker when deterministic failure cycles are detected.
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,11 @@ use crate::error::FabroError;
|
||||||
use crate::event::{Emitter, Event};
|
use crate::event::{Emitter, Event};
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::{
|
use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus};
|
||||||
FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage, stage_usage_to_llm,
|
use fabro_types::{BilledTokenCounts, RunId, StatusReason};
|
||||||
};
|
|
||||||
use fabro_types::{RunId, StatusReason};
|
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
type FailureSignatureSnapshot = (
|
type FailureSignatureSnapshot = (
|
||||||
Option<BTreeMap<String, usize>>,
|
Option<BTreeMap<String, usize>>,
|
||||||
Option<BTreeMap<String, usize>>,
|
Option<BTreeMap<String, usize>>,
|
||||||
|
|
@ -139,7 +137,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||||
status: StageStatus::Success.to_string(),
|
status: StageStatus::Success.to_string(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: Vec::new(),
|
suggested_next_ids: Vec::new(),
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: Vec::new(),
|
files_touched: Vec::new(),
|
||||||
|
|
@ -162,7 +160,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||||
&self,
|
&self,
|
||||||
ctx: &AttemptContext<'_, WorkflowGraph>,
|
ctx: &AttemptContext<'_, WorkflowGraph>,
|
||||||
state: &WfRunState,
|
state: &WfRunState,
|
||||||
) -> CoreResult<NodeDecision<Option<StageUsage>>> {
|
) -> CoreResult<NodeDecision<Option<BilledModelUsage>>> {
|
||||||
let gv = ctx.node.inner();
|
let gv = ctx.node.inner();
|
||||||
self.emitter.emit(&Event::StageStarted {
|
self.emitter.emit(&Event::StageStarted {
|
||||||
node_id: gv.id.clone(),
|
node_id: gv.id.clone(),
|
||||||
|
|
@ -245,7 +243,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||||
status: outcome.status.to_string(),
|
status: outcome.status.to_string(),
|
||||||
preferred_label: outcome.preferred_label.clone(),
|
preferred_label: outcome.preferred_label.clone(),
|
||||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||||
usage: outcome.usage.clone(),
|
billing: outcome.usage.clone(),
|
||||||
failure: outcome.failure.clone(),
|
failure: outcome.failure.clone(),
|
||||||
notes: outcome.notes.clone(),
|
notes: outcome.notes.clone(),
|
||||||
files_touched: outcome.files_touched.clone(),
|
files_touched: outcome.files_touched.clone(),
|
||||||
|
|
@ -387,19 +385,31 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||||
let artifact_count = self.captured_artifact_count.load(Ordering::Relaxed);
|
let artifact_count = self.captured_artifact_count.load(Ordering::Relaxed);
|
||||||
let last_sha = self.last_git_sha.lock().unwrap().clone();
|
let last_sha = self.last_git_sha.lock().unwrap().clone();
|
||||||
let final_patch = self.final_patch.lock().unwrap().clone();
|
let final_patch = self.final_patch.lock().unwrap().clone();
|
||||||
let total_cost = {
|
let run_billing_entries = state
|
||||||
let sum: f64 = state
|
|
||||||
.node_outcomes
|
|
||||||
.values()
|
|
||||||
.filter_map(|o| o.usage.as_ref()?.cost)
|
|
||||||
.sum();
|
|
||||||
if sum > 0.0 { Some(sum) } else { None }
|
|
||||||
};
|
|
||||||
let run_usage = state
|
|
||||||
.node_outcomes
|
.node_outcomes
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|o| o.usage.as_ref().map(stage_usage_to_llm))
|
.filter_map(|o| o.usage.clone())
|
||||||
.reduce(|a, b| a + b);
|
.collect::<Vec<_>>();
|
||||||
|
let run_billing = (!run_billing_entries.is_empty())
|
||||||
|
.then(|| BilledTokenCounts::from_billed_usage(&run_billing_entries));
|
||||||
|
let total_usd_micros = run_billing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|billing| billing.total_usd_micros)
|
||||||
|
.or_else(|| {
|
||||||
|
let mut total = 0_i64;
|
||||||
|
let mut has_total = false;
|
||||||
|
for usage in state
|
||||||
|
.node_outcomes
|
||||||
|
.values()
|
||||||
|
.filter_map(|o| o.usage.as_ref())
|
||||||
|
{
|
||||||
|
if let Some(value) = usage.total_usd_micros {
|
||||||
|
total += value;
|
||||||
|
has_total = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
has_total.then_some(total)
|
||||||
|
});
|
||||||
|
|
||||||
if outcome.status == StageStatus::Success || outcome.status == StageStatus::PartialSuccess {
|
if outcome.status == StageStatus::Success || outcome.status == StageStatus::PartialSuccess {
|
||||||
self.emitter.emit(&Event::WorkflowRunCompleted {
|
self.emitter.emit(&Event::WorkflowRunCompleted {
|
||||||
|
|
@ -410,10 +420,10 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||||
StageStatus::PartialSuccess => StatusReason::PartialSuccess,
|
StageStatus::PartialSuccess => StatusReason::PartialSuccess,
|
||||||
_ => StatusReason::Completed,
|
_ => StatusReason::Completed,
|
||||||
}),
|
}),
|
||||||
total_cost,
|
total_usd_micros,
|
||||||
final_git_commit_sha: last_sha,
|
final_git_commit_sha: last_sha,
|
||||||
final_patch,
|
final_patch,
|
||||||
usage: run_usage,
|
billing: run_billing,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let error_msg = outcome
|
let error_msg = outcome
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ use crate::context::keys;
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::handler::llm::preamble::build_preamble;
|
use crate::handler::llm::preamble::build_preamble;
|
||||||
use crate::outcome::StageUsage;
|
use crate::outcome::BilledModelUsage;
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
|
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Graphviz edge captured from edge selection, passed to the next node's before_node
|
/// Graphviz edge captured from edge selection, passed to the next node's before_node
|
||||||
/// for fidelity/thread resolution.
|
/// for fidelity/thread resolution.
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||||
use crate::git::MetadataStore;
|
use crate::git::MetadataStore;
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::{Outcome, StageStatus, StageUsage};
|
use crate::outcome::{BilledModelUsage, Outcome, StageStatus};
|
||||||
use crate::run_dump::RunDump;
|
use crate::run_dump::RunDump;
|
||||||
use crate::run_options::RunOptions;
|
use crate::run_options::RunOptions;
|
||||||
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
|
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
fn build_checkpoint(
|
fn build_checkpoint(
|
||||||
node: &WorkflowNode,
|
node: &WorkflowNode,
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ use fabro_core::state::ExecutionState;
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::hook_context::set_hook_node;
|
use crate::hook_context::set_hook_node;
|
||||||
use crate::outcome::{Outcome, OutcomeExt, StageStatus, StageUsage};
|
use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt, StageStatus};
|
||||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
||||||
use fabro_sandbox::Sandbox;
|
use fabro_sandbox::Sandbox;
|
||||||
use fabro_types::RunId;
|
use fabro_types::RunId;
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
|
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Sub-lifecycle responsible for running workflow hooks.
|
/// Sub-lifecycle responsible for running workflow hooks.
|
||||||
pub(crate) struct HookLifecycle {
|
pub(crate) struct HookLifecycle {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ use crate::error::{FailureSignature, FailureSignatureExt};
|
||||||
use crate::event::Emitter;
|
use crate::event::Emitter;
|
||||||
use crate::graph::WorkflowGraph;
|
use crate::graph::WorkflowGraph;
|
||||||
use crate::graph::WorkflowNode;
|
use crate::graph::WorkflowNode;
|
||||||
use crate::outcome::{Outcome, StageUsage};
|
use crate::outcome::{BilledModelUsage, Outcome};
|
||||||
use crate::run_control::RunControlState;
|
use crate::run_control::RunControlState;
|
||||||
use crate::run_options::RunOptions;
|
use crate::run_options::RunOptions;
|
||||||
use fabro_graphviz::graph::types::Graph as GvGraph;
|
use fabro_graphviz::graph::types::Graph as GvGraph;
|
||||||
|
|
@ -46,9 +46,9 @@ use self::git::{GitCheckpointResult, GitLifecycle};
|
||||||
use self::hook::HookLifecycle;
|
use self::hook::HookLifecycle;
|
||||||
use crate::outcome::OutcomeExt;
|
use crate::outcome::OutcomeExt;
|
||||||
|
|
||||||
type WfRunState = ExecutionState<Option<StageUsage>>;
|
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||||
type WfNodeResult = NodeResult<Option<StageUsage>>;
|
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||||
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
|
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
|
||||||
|
|
||||||
/// Orchestrates all sub-lifecycles with explicit per-callback ordering.
|
/// Orchestrates all sub-lifecycles with explicit per-callback ordering.
|
||||||
/// Implements `RunLifecycle<WorkflowGraph>` by delegating to focused structs.
|
/// Implements `RunLifecycle<WorkflowGraph>` by delegating to focused structs.
|
||||||
|
|
|
||||||
|
|
@ -1105,14 +1105,8 @@ mod tests {
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
stages: vec![],
|
stages: vec![],
|
||||||
total_cost: None,
|
billing: None,
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
};
|
};
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
run_dir.join("conclusion.json"),
|
run_dir.join("conclusion.json"),
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,51 @@
|
||||||
pub use fabro_core::outcome::{FailureCategory, FailureDetail, OutcomeMeta, StageStatus};
|
pub use fabro_core::outcome::{FailureCategory, FailureDetail, OutcomeMeta, StageStatus};
|
||||||
pub use fabro_types::usage::StageUsage;
|
pub use fabro_types::BilledModelUsage;
|
||||||
|
|
||||||
use crate::error::classify_failure_reason;
|
use crate::error::classify_failure_reason;
|
||||||
use fabro_llm::types::Usage as LlmUsage;
|
use fabro_llm::types::TokenCounts as LlmTokenCounts;
|
||||||
|
use fabro_model::{
|
||||||
|
AnthropicBillingFacts, Catalog, ModelBillingFacts, ModelBillingInput, ModelRef, ModelUsage,
|
||||||
|
Provider, Speed, TokenCounts,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn stage_usage_to_llm(u: &StageUsage) -> LlmUsage {
|
pub type Outcome = fabro_core::Outcome<Option<BilledModelUsage>>;
|
||||||
LlmUsage {
|
|
||||||
input_tokens: u.input_tokens,
|
#[must_use]
|
||||||
output_tokens: u.output_tokens,
|
pub fn billed_model_usage_from_llm(
|
||||||
total_tokens: u.input_tokens + u.output_tokens,
|
model_id: &str,
|
||||||
cache_read_tokens: u.cache_read_tokens,
|
provider: Provider,
|
||||||
cache_write_tokens: u.cache_write_tokens,
|
requested_speed: Option<&str>,
|
||||||
reasoning_tokens: u.reasoning_tokens,
|
usage: &LlmTokenCounts,
|
||||||
speed: u.speed.clone(),
|
) -> BilledModelUsage {
|
||||||
raw: None,
|
let speed = parse_speed(requested_speed);
|
||||||
|
let model = ModelRef {
|
||||||
|
provider,
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
speed,
|
||||||
|
};
|
||||||
|
let tokens = token_counts_from_llm_usage(usage);
|
||||||
|
let facts = billing_facts_for_stage_usage(provider, &tokens);
|
||||||
|
let input = ModelBillingInput {
|
||||||
|
usage: ModelUsage {
|
||||||
|
model: model.clone(),
|
||||||
|
tokens,
|
||||||
|
},
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_usd_micros = Catalog::builtin()
|
||||||
|
.get(model_id)
|
||||||
|
.filter(|candidate| candidate.provider == provider)
|
||||||
|
.and_then(|candidate| candidate.pricing_for(speed))
|
||||||
|
.and_then(|pricing| pricing.bill(&input))
|
||||||
|
.map(|amount| amount.0);
|
||||||
|
|
||||||
|
BilledModelUsage {
|
||||||
|
input,
|
||||||
|
total_usd_micros,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Outcome = fabro_core::Outcome<Option<StageUsage>>;
|
|
||||||
|
|
||||||
pub trait OutcomeExt: Sized {
|
pub trait OutcomeExt: Sized {
|
||||||
fn fail_deterministic(reason: impl Into<String>) -> Self;
|
fn fail_deterministic(reason: impl Into<String>) -> Self;
|
||||||
fn fail_classify(reason: impl Into<String>) -> Self;
|
fn fail_classify(reason: impl Into<String>) -> Self;
|
||||||
|
|
@ -68,18 +95,20 @@ impl OutcomeExt for Outcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_signature(mut self, sig: Option<impl Into<String>>) -> Self {
|
fn with_signature(mut self, sig: Option<impl Into<String>>) -> Self {
|
||||||
if let Some(ref mut f) = self.failure {
|
if let Some(ref mut failure) = self.failure {
|
||||||
f.signature = sig.map(Into::into);
|
failure.signature = sig.map(Into::into);
|
||||||
}
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failure_reason(&self) -> Option<&str> {
|
fn failure_reason(&self) -> Option<&str> {
|
||||||
self.failure.as_ref().map(|f| f.message.as_str())
|
self.failure
|
||||||
|
.as_ref()
|
||||||
|
.map(|failure| failure.message.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failure_category(&self) -> Option<FailureCategory> {
|
fn failure_category(&self) -> Option<FailureCategory> {
|
||||||
self.failure.as_ref().map(|f| f.category)
|
self.failure.as_ref().map(|failure| failure.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classified_failure_category(&self) -> Option<FailureCategory> {
|
fn classified_failure_category(&self) -> Option<FailureCategory> {
|
||||||
|
|
@ -92,24 +121,82 @@ impl OutcomeExt for Outcome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
|
|
||||||
let info = fabro_model::Catalog::builtin().get(&usage.model)?;
|
|
||||||
let input_rate = info.costs.input_cost_per_mtok?;
|
|
||||||
let output_rate = info.costs.output_cost_per_mtok?;
|
|
||||||
let multiplier = if usage.speed.as_deref() == Some("fast") {
|
|
||||||
6.0
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
Some(
|
|
||||||
(usage.input_tokens as f64 * input_rate / 1_000_000.0
|
|
||||||
+ usage.output_tokens as f64 * output_rate / 1_000_000.0)
|
|
||||||
* multiplier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn format_cost(cost: f64) -> String {
|
pub fn format_cost(cost: f64) -> String {
|
||||||
format!("${cost:.2}")
|
format!("${cost:.2}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_speed(speed: Option<&str>) -> Option<Speed> {
|
||||||
|
speed.and_then(|value| value.parse::<Speed>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts {
|
||||||
|
usage.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn billing_facts_for_stage_usage(provider: Provider, tokens: &TokenCounts) -> ModelBillingFacts {
|
||||||
|
match provider {
|
||||||
|
Provider::Anthropic => ModelBillingFacts::Anthropic(AnthropicBillingFacts {
|
||||||
|
cache_write_5m_tokens: tokens.cache_write_tokens,
|
||||||
|
cache_write_1h_tokens: 0,
|
||||||
|
}),
|
||||||
|
other => ModelBillingFacts::for_provider(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::billed_model_usage_from_llm;
|
||||||
|
use fabro_llm::types::TokenCounts;
|
||||||
|
use fabro_model::Provider;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn billed_model_usage_from_llm_bills_openai_cached_input_and_reasoning_output() {
|
||||||
|
let usage = TokenCounts {
|
||||||
|
input_tokens: 500_000,
|
||||||
|
output_tokens: 125_000,
|
||||||
|
reasoning_tokens: 25_000,
|
||||||
|
cache_read_tokens: 250_000,
|
||||||
|
..TokenCounts::default()
|
||||||
|
};
|
||||||
|
let billed = billed_model_usage_from_llm("gpt-5.4", Provider::OpenAi, None, &usage);
|
||||||
|
|
||||||
|
assert_eq!(billed.total_usd_micros, Some(3_562_500));
|
||||||
|
assert_eq!(billed.tokens().output_tokens, 125_000);
|
||||||
|
assert_eq!(billed.tokens().reasoning_tokens, 25_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn billed_model_usage_from_llm_bills_anthropic_fast_mode_cache_write_pricing() {
|
||||||
|
let usage = TokenCounts {
|
||||||
|
input_tokens: 100_000,
|
||||||
|
output_tokens: 10_000,
|
||||||
|
reasoning_tokens: 5_000,
|
||||||
|
cache_read_tokens: 20_000,
|
||||||
|
cache_write_tokens: 30_000,
|
||||||
|
};
|
||||||
|
let billed = billed_model_usage_from_llm(
|
||||||
|
"claude-opus-4-6",
|
||||||
|
Provider::Anthropic,
|
||||||
|
Some("fast"),
|
||||||
|
&usage,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(billed.total_usd_micros, Some(6_435_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn billed_model_usage_round_trips_dense_token_counts() {
|
||||||
|
let usage = TokenCounts {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 40,
|
||||||
|
reasoning_tokens: 5,
|
||||||
|
cache_read_tokens: 20,
|
||||||
|
cache_write_tokens: 10,
|
||||||
|
};
|
||||||
|
let billed =
|
||||||
|
billed_model_usage_from_llm("claude-opus-4-6", Provider::Anthropic, None, &usage);
|
||||||
|
|
||||||
|
assert_eq!(billed.tokens().clone(), usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::run_status::{RunStatus, StatusReason};
|
||||||
use crate::sandbox_git::git_push_host;
|
use crate::sandbox_git::git_push_host;
|
||||||
use fabro_hooks::{HookContext, HookEvent, HookRunner};
|
use fabro_hooks::{HookContext, HookEvent, HookRunner};
|
||||||
use fabro_store::RunDatabase;
|
use fabro_store::RunDatabase;
|
||||||
|
use fabro_types::BilledTokenCounts;
|
||||||
|
|
||||||
use super::types::{Concluded, FinalizeOptions, Retroed};
|
use super::types::{Concluded, FinalizeOptions, Retroed};
|
||||||
|
|
||||||
|
|
@ -98,17 +99,10 @@ fn build_conclusion_from_parts(
|
||||||
run_duration_ms: u64,
|
run_duration_ms: u64,
|
||||||
final_git_commit_sha: Option<String>,
|
final_git_commit_sha: Option<String>,
|
||||||
) -> Conclusion {
|
) -> Conclusion {
|
||||||
let mut total_input_tokens: i64 = 0;
|
let (stages, billing, total_retries) = if let Some(cp) = checkpoint {
|
||||||
let mut total_output_tokens: i64 = 0;
|
|
||||||
let mut total_cache_read_tokens: i64 = 0;
|
|
||||||
let mut total_cache_write_tokens: i64 = 0;
|
|
||||||
let mut total_reasoning_tokens: i64 = 0;
|
|
||||||
let mut has_pricing = false;
|
|
||||||
|
|
||||||
let (stages, total_cost, total_retries) = if let Some(cp) = checkpoint {
|
|
||||||
let mut stages = Vec::new();
|
let mut stages = Vec::new();
|
||||||
let mut cost_sum: Option<f64> = None;
|
|
||||||
let mut retries_sum: u32 = 0;
|
let mut retries_sum: u32 = 0;
|
||||||
|
let mut billed_usage = Vec::new();
|
||||||
|
|
||||||
for node_id in &cp.completed_nodes {
|
for node_id in &cp.completed_nodes {
|
||||||
let outcome = cp.node_outcomes.get(node_id);
|
let outcome = cp.node_outcomes.get(node_id);
|
||||||
|
|
@ -120,29 +114,25 @@ fn build_conclusion_from_parts(
|
||||||
.saturating_sub(1);
|
.saturating_sub(1);
|
||||||
retries_sum += retries;
|
retries_sum += retries;
|
||||||
|
|
||||||
let cost = outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost);
|
|
||||||
if let Some(c) = cost {
|
|
||||||
*cost_sum.get_or_insert(0.0) += c;
|
|
||||||
has_pricing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(usage) = outcome.and_then(|o| o.usage.as_ref()) {
|
if let Some(usage) = outcome.and_then(|o| o.usage.as_ref()) {
|
||||||
total_input_tokens += usage.input_tokens;
|
billed_usage.push(usage.clone());
|
||||||
total_output_tokens += usage.output_tokens;
|
|
||||||
total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0);
|
|
||||||
total_cache_write_tokens += usage.cache_write_tokens.unwrap_or(0);
|
|
||||||
total_reasoning_tokens += usage.reasoning_tokens.unwrap_or(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stages.push(StageSummary {
|
stages.push(StageSummary {
|
||||||
stage_id: node_id.clone(),
|
stage_id: node_id.clone(),
|
||||||
stage_label: node_id.clone(),
|
stage_label: node_id.clone(),
|
||||||
duration_ms: stage_durations.get(node_id).copied().unwrap_or(0),
|
duration_ms: stage_durations.get(node_id).copied().unwrap_or(0),
|
||||||
cost,
|
billing_usd_micros: outcome
|
||||||
|
.and_then(|o| o.usage.as_ref())
|
||||||
|
.and_then(|usage| usage.total_usd_micros),
|
||||||
retries,
|
retries,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
(stages, cost_sum, retries_sum)
|
(
|
||||||
|
stages,
|
||||||
|
(!billed_usage.is_empty()).then(|| BilledTokenCounts::from_billed_usage(&billed_usage)),
|
||||||
|
retries_sum,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(vec![], None, 0)
|
(vec![], None, 0)
|
||||||
};
|
};
|
||||||
|
|
@ -154,14 +144,8 @@ fn build_conclusion_from_parts(
|
||||||
failure_reason,
|
failure_reason,
|
||||||
final_git_commit_sha,
|
final_git_commit_sha,
|
||||||
stages,
|
stages,
|
||||||
total_cost,
|
billing,
|
||||||
total_retries,
|
total_retries,
|
||||||
total_input_tokens,
|
|
||||||
total_output_tokens,
|
|
||||||
total_cache_read_tokens,
|
|
||||||
total_cache_write_tokens,
|
|
||||||
total_reasoning_tokens,
|
|
||||||
has_pricing,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,10 @@ fn truncate_pr_body(body: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional cost as `$X.XX` or an en-dash when absent.
|
/// Format an optional cost as `$X.XX` or an en-dash when absent.
|
||||||
fn format_cost(cost: Option<f64>) -> String {
|
fn format_cost(cost_usd_micros: Option<i64>) -> String {
|
||||||
cost.map_or_else(|| "\u{2013}".to_string(), outcome_format_cost)
|
cost_usd_micros
|
||||||
|
.map(|value| value as f64 / 1_000_000.0)
|
||||||
|
.map_or_else(|| "\u{2013}".to_string(), outcome_format_cost)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a duration in milliseconds as a human-readable string.
|
/// Format a duration in milliseconds as a human-readable string.
|
||||||
|
|
@ -113,7 +115,7 @@ fn format_arc_details_section(
|
||||||
|
|
||||||
// Cost table
|
// Cost table
|
||||||
let total_duration = format_duration_ms(conclusion.duration_ms);
|
let total_duration = format_duration_ms(conclusion.duration_ms);
|
||||||
let total_cost_str = format_cost(conclusion.total_cost);
|
let total_cost_str = format_cost(conclusion.billing.as_ref().and_then(|b| b.total_usd_micros));
|
||||||
let stage_count = conclusion.stages.len();
|
let stage_count = conclusion.stages.len();
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"<details>\n<summary>Ran {stage_count} {} in {total_duration} for {total_cost_str}</summary>",
|
"<details>\n<summary>Ran {stage_count} {} in {total_duration} for {total_cost_str}</summary>",
|
||||||
|
|
@ -125,7 +127,7 @@ fn format_arc_details_section(
|
||||||
parts.push("|---|---|---|---|".to_string());
|
parts.push("|---|---|---|---|".to_string());
|
||||||
for stage in &conclusion.stages {
|
for stage in &conclusion.stages {
|
||||||
let dur = format_duration_ms(stage.duration_ms);
|
let dur = format_duration_ms(stage.duration_ms);
|
||||||
let cost = format_cost(stage.cost);
|
let cost = format_cost(stage.billing_usd_micros);
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"| {} | {} | {} | {} |",
|
"| {} | {} | {} | {} |",
|
||||||
stage.stage_label, dur, cost, stage.retries
|
stage.stage_label, dur, cost, stage.retries
|
||||||
|
|
@ -587,12 +589,12 @@ mod tests {
|
||||||
use fabro_llm::error::SdkError;
|
use fabro_llm::error::SdkError;
|
||||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||||
use fabro_llm::set_default_client;
|
use fabro_llm::set_default_client;
|
||||||
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
|
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
|
||||||
use fabro_retro::retro::{
|
use fabro_retro::retro::{
|
||||||
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
|
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
|
||||||
};
|
};
|
||||||
use fabro_store::Database;
|
use fabro_store::Database;
|
||||||
use fabro_types::{RunRecord, Settings, fixtures};
|
use fabro_types::{BilledTokenCounts, RunRecord, Settings, fixtures};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use object_store::memory::InMemory;
|
use object_store::memory::InMemory;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -622,10 +624,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&self.response_text),
|
message: Message::assistant(&self.response_text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -640,10 +641,9 @@ mod tests {
|
||||||
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
|
||||||
Ok(StreamEvent::finish(
|
Ok(StreamEvent::finish(
|
||||||
FinishReason::Stop,
|
FinishReason::Stop,
|
||||||
Usage {
|
TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Response {
|
Response {
|
||||||
|
|
@ -652,10 +652,9 @@ mod tests {
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
message: Message::assistant(&text),
|
message: Message::assistant(&text),
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
usage: Usage {
|
usage: TokenCounts {
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
total_tokens: 30,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
@ -701,32 +700,29 @@ mod tests {
|
||||||
stage_id: "plan".to_string(),
|
stage_id: "plan".to_string(),
|
||||||
stage_label: "plan".to_string(),
|
stage_label: "plan".to_string(),
|
||||||
duration_ms: 45_000,
|
duration_ms: 45_000,
|
||||||
cost: Some(0.12),
|
billing_usd_micros: Some(120_000),
|
||||||
retries: 0,
|
retries: 0,
|
||||||
},
|
},
|
||||||
StageSummary {
|
StageSummary {
|
||||||
stage_id: "implement".to_string(),
|
stage_id: "implement".to_string(),
|
||||||
stage_label: "implement".to_string(),
|
stage_label: "implement".to_string(),
|
||||||
duration_ms: 90_000,
|
duration_ms: 90_000,
|
||||||
cost: Some(0.25),
|
billing_usd_micros: Some(250_000),
|
||||||
retries: 0,
|
retries: 0,
|
||||||
},
|
},
|
||||||
StageSummary {
|
StageSummary {
|
||||||
stage_id: "simplify".to_string(),
|
stage_id: "simplify".to_string(),
|
||||||
stage_label: "simplify".to_string(),
|
stage_label: "simplify".to_string(),
|
||||||
duration_ms: 15_000,
|
duration_ms: 15_000,
|
||||||
cost: Some(0.05),
|
billing_usd_micros: Some(50_000),
|
||||||
retries: 0,
|
retries: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total_cost: Some(0.42),
|
billing: Some(BilledTokenCounts {
|
||||||
|
total_usd_micros: Some(420_000),
|
||||||
|
..BilledTokenCounts::default()
|
||||||
|
}),
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -744,7 +740,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
duration_ms: 45_000,
|
duration_ms: 45_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
cost: Some(0.12),
|
billing_usd_micros: Some(120_000),
|
||||||
notes: None,
|
notes: None,
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
files_touched: vec![],
|
files_touched: vec![],
|
||||||
|
|
@ -755,7 +751,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
duration_ms: 90_000,
|
duration_ms: 90_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
cost: Some(0.25),
|
billing_usd_micros: Some(250_000),
|
||||||
notes: None,
|
notes: None,
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
files_touched: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
|
files_touched: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
|
||||||
|
|
@ -766,7 +762,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
duration_ms: 15_000,
|
duration_ms: 15_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
cost: Some(0.05),
|
billing_usd_micros: Some(50_000),
|
||||||
notes: None,
|
notes: None,
|
||||||
failure_reason: None,
|
failure_reason: None,
|
||||||
files_touched: vec![],
|
files_touched: vec![],
|
||||||
|
|
@ -774,7 +770,7 @@ mod tests {
|
||||||
],
|
],
|
||||||
stats: AggregateStats {
|
stats: AggregateStats {
|
||||||
total_duration_ms: 150_000,
|
total_duration_ms: 150_000,
|
||||||
total_cost: Some(0.42),
|
total_billing_usd_micros: Some(420_000),
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
files_touched: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
|
files_touched: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
|
||||||
stages_completed: 3,
|
stages_completed: 3,
|
||||||
|
|
@ -843,7 +839,7 @@ mod tests {
|
||||||
stages: vec![],
|
stages: vec![],
|
||||||
stats: AggregateStats {
|
stats: AggregateStats {
|
||||||
total_duration_ms: 0,
|
total_duration_ms: 0,
|
||||||
total_cost: None,
|
total_billing_usd_micros: None,
|
||||||
total_retries: 0,
|
total_retries: 0,
|
||||||
files_touched: vec![],
|
files_touched: vec![],
|
||||||
stages_completed: 0,
|
stages_completed: 0,
|
||||||
|
|
@ -880,9 +876,9 @@ mod tests {
|
||||||
fn format_arc_details_no_cost() {
|
fn format_arc_details_no_cost() {
|
||||||
let mut conclusion = make_test_conclusion();
|
let mut conclusion = make_test_conclusion();
|
||||||
for stage in &mut conclusion.stages {
|
for stage in &mut conclusion.stages {
|
||||||
stage.cost = None;
|
stage.billing_usd_micros = None;
|
||||||
}
|
}
|
||||||
conclusion.total_cost = None;
|
conclusion.billing = None;
|
||||||
let section = format_arc_details_section(&conclusion, None, None);
|
let section = format_arc_details_section(&conclusion, None, None);
|
||||||
|
|
||||||
// En-dash for missing costs
|
// En-dash for missing costs
|
||||||
|
|
@ -1198,7 +1194,7 @@ mod tests {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
preferred_label: None,
|
preferred_label: None,
|
||||||
suggested_next_ids: vec![],
|
suggested_next_ids: vec![],
|
||||||
usage: None,
|
billing: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
files_touched: vec![],
|
files_touched: vec![],
|
||||||
|
|
@ -1418,12 +1414,12 @@ mod tests {
|
||||||
artifact_count: 0,
|
artifact_count: 0,
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
reason: None,
|
reason: None,
|
||||||
total_cost: None,
|
total_usd_micros: None,
|
||||||
final_git_commit_sha: None,
|
final_git_commit_sha: None,
|
||||||
final_patch: Some(
|
final_patch: Some(
|
||||||
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
|
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
|
||||||
),
|
),
|
||||||
usage: None,
|
billing: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,16 @@ impl RunInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn total_cost(&self) -> Option<f64> {
|
pub fn total_cost(&self) -> Option<f64> {
|
||||||
self.summary.as_ref().and_then(|summary| summary.total_cost)
|
self.summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|summary| summary.total_usd_micros)
|
||||||
|
.map(|value| value as f64 / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_usd_micros(&self) -> Option<i64> {
|
||||||
|
self.summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|summary| summary.total_usd_micros)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn host_repo_path(&self) -> Option<&str> {
|
pub fn host_repo_path(&self) -> Option<&str> {
|
||||||
|
|
|
||||||
|
|
@ -1048,7 +1048,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command:
|
||||||
);
|
);
|
||||||
if let Some(u) = usage {
|
if let Some(u) = usage {
|
||||||
assert!(
|
assert!(
|
||||||
u.input_tokens > 0,
|
u.tokens().input_tokens > 0,
|
||||||
"{provider}/{model}: input_tokens should be > 0"
|
"{provider}/{model}: input_tokens should be > 0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9237,8 +9237,8 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(text, "I fixed the bug.");
|
assert_eq!(text, "I fixed the bug.");
|
||||||
let usage = usage.expect("should have usage");
|
let usage = usage.expect("should have usage");
|
||||||
assert_eq!(usage.input_tokens, 500);
|
assert_eq!(usage.tokens().input_tokens, 500);
|
||||||
assert_eq!(usage.output_tokens, 200);
|
assert_eq!(usage.tokens().output_tokens, 200);
|
||||||
assert!(files_touched.is_empty(), "no files changed before/after");
|
assert!(files_touched.is_empty(), "no files changed before/after");
|
||||||
}
|
}
|
||||||
CodergenResult::Full(_) => panic!("expected Text result, got Full"),
|
CodergenResult::Full(_) => panic!("expected Text result, got Full"),
|
||||||
|
|
@ -9311,8 +9311,8 @@ async fn cli_backend_run_with_codex_provider() {
|
||||||
CodergenResult::Text { text, usage, .. } => {
|
CodergenResult::Text { text, usage, .. } => {
|
||||||
assert_eq!(text, "Implemented the feature.");
|
assert_eq!(text, "Implemented the feature.");
|
||||||
let usage = usage.expect("should have usage");
|
let usage = usage.expect("should have usage");
|
||||||
assert_eq!(usage.input_tokens, 300);
|
assert_eq!(usage.tokens().input_tokens, 300);
|
||||||
assert_eq!(usage.output_tokens, 150);
|
assert_eq!(usage.tokens().output_tokens, 150);
|
||||||
}
|
}
|
||||||
CodergenResult::Full(_) => panic!("expected Text result"),
|
CodergenResult::Full(_) => panic!("expected Text result"),
|
||||||
}
|
}
|
||||||
|
|
@ -9593,9 +9593,9 @@ async fn cli_backend_run_returns_text_and_usage() {
|
||||||
CodergenResult::Text { text, usage, .. } => {
|
CodergenResult::Text { text, usage, .. } => {
|
||||||
assert_eq!(text, "done");
|
assert_eq!(text, "done");
|
||||||
let usage = usage.expect("CLI backend should report usage");
|
let usage = usage.expect("CLI backend should report usage");
|
||||||
assert_eq!(usage.input_tokens, 10);
|
assert_eq!(usage.tokens().input_tokens, 10);
|
||||||
assert_eq!(usage.output_tokens, 5);
|
assert_eq!(usage.tokens().output_tokens, 5);
|
||||||
assert_eq!(usage.model, "claude-opus-4-6");
|
assert_eq!(usage.model_id(), "claude-opus-4-6");
|
||||||
}
|
}
|
||||||
CodergenResult::Full(_) => panic!("expected Text result"),
|
CodergenResult::Full(_) => panic!("expected Text result"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
api.ts
|
api.ts
|
||||||
|
api/billing-api.ts
|
||||||
api/completions-api.ts
|
api/completions-api.ts
|
||||||
api/discovery-api.ts
|
api/discovery-api.ts
|
||||||
api/human-in-the-loop-api.ts
|
api/human-in-the-loop-api.ts
|
||||||
|
|
@ -11,13 +12,12 @@ api/runs-api.ts
|
||||||
api/secrets-api.ts
|
api/secrets-api.ts
|
||||||
api/settings-api.ts
|
api/settings-api.ts
|
||||||
api/system-api.ts
|
api/system-api.ts
|
||||||
api/usage-api.ts
|
|
||||||
base.ts
|
base.ts
|
||||||
common.ts
|
common.ts
|
||||||
configuration.ts
|
configuration.ts
|
||||||
index.ts
|
index.ts
|
||||||
models/aggregate-usage-totals.ts
|
models/aggregate-billing-totals.ts
|
||||||
models/aggregate-usage.ts
|
models/aggregate-billing.ts
|
||||||
models/api-question-option.ts
|
models/api-question-option.ts
|
||||||
models/api-question.ts
|
models/api-question.ts
|
||||||
models/api-settings.ts
|
models/api-settings.ts
|
||||||
|
|
@ -27,6 +27,9 @@ models/artifact-list-response.ts
|
||||||
models/artifacts-settings.ts
|
models/artifacts-settings.ts
|
||||||
models/assistant-stage-turn.ts
|
models/assistant-stage-turn.ts
|
||||||
models/auth-settings.ts
|
models/auth-settings.ts
|
||||||
|
models/billed-token-counts.ts
|
||||||
|
models/billing-by-model.ts
|
||||||
|
models/billing-stage-ref.ts
|
||||||
models/board-column.ts
|
models/board-column.ts
|
||||||
models/check-run-status.ts
|
models/check-run-status.ts
|
||||||
models/check-run.ts
|
models/check-run.ts
|
||||||
|
|
@ -125,6 +128,9 @@ models/root-response-urls.ts
|
||||||
models/root-response.ts
|
models/root-response.ts
|
||||||
models/run-artifact-entry.ts
|
models/run-artifact-entry.ts
|
||||||
models/run-artifact-list-response.ts
|
models/run-artifact-list-response.ts
|
||||||
|
models/run-billing-stage.ts
|
||||||
|
models/run-billing-totals.ts
|
||||||
|
models/run-billing.ts
|
||||||
models/run-checkpoint.ts
|
models/run-checkpoint.ts
|
||||||
models/run-control-action.ts
|
models/run-control-action.ts
|
||||||
models/run-error.ts
|
models/run-error.ts
|
||||||
|
|
@ -143,7 +149,6 @@ models/run-status-record.ts
|
||||||
models/run-status-response.ts
|
models/run-status-response.ts
|
||||||
models/run-status.ts
|
models/run-status.ts
|
||||||
models/run-timings.ts
|
models/run-timings.ts
|
||||||
models/run-usage.ts
|
|
||||||
models/sandbox-file-entry.ts
|
models/sandbox-file-entry.ts
|
||||||
models/sandbox-file-list-response.ts
|
models/sandbox-file-list-response.ts
|
||||||
models/sandbox-resources.ts
|
models/sandbox-resources.ts
|
||||||
|
|
@ -171,13 +176,8 @@ models/system-info-response.ts
|
||||||
models/system-run-counts.ts
|
models/system-run-counts.ts
|
||||||
models/system-stage-turn.ts
|
models/system-stage-turn.ts
|
||||||
models/tls-settings.ts
|
models/tls-settings.ts
|
||||||
models/token-usage.ts
|
|
||||||
models/tool-stage-turn.ts
|
models/tool-stage-turn.ts
|
||||||
models/tool-use.ts
|
models/tool-use.ts
|
||||||
models/usage-by-model.ts
|
|
||||||
models/usage-stage-ref.ts
|
|
||||||
models/usage-stage.ts
|
|
||||||
models/usage-totals.ts
|
|
||||||
models/user-response.ts
|
models/user-response.ts
|
||||||
models/web-settings.ts
|
models/web-settings.ts
|
||||||
models/webhook-settings.ts
|
models/webhook-settings.ts
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export * from './api/billing-api';
|
||||||
export * from './api/completions-api';
|
export * from './api/completions-api';
|
||||||
export * from './api/discovery-api';
|
export * from './api/discovery-api';
|
||||||
export * from './api/human-in-the-loop-api';
|
export * from './api/human-in-the-loop-api';
|
||||||
|
|
@ -26,5 +27,4 @@ export * from './api/runs-api';
|
||||||
export * from './api/secrets-api';
|
export * from './api/secrets-api';
|
||||||
export * from './api/settings-api';
|
export * from './api/settings-api';
|
||||||
export * from './api/system-api';
|
export * from './api/system-api';
|
||||||
export * from './api/usage-api';
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,20 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
|
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import type { AggregateUsage } from '../models';
|
import type { AggregateBilling } from '../models';
|
||||||
/**
|
/**
|
||||||
* UsageApi - axios parameter creator
|
* BillingApi - axios parameter creator
|
||||||
*/
|
*/
|
||||||
export const UsageApiAxiosParamCreator = function (configuration?: Configuration) {
|
export const BillingApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns aggregate token/cost usage across all completed runs since server start.
|
* Returns aggregate token counts and billed totals across all completed runs since server start.
|
||||||
* @summary Aggregate Usage
|
* @summary Aggregate Billing
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
getAggregateUsage: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
getAggregateBilling: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
const localVarPath = `/api/v1/usage`;
|
const localVarPath = `/api/v1/billing`;
|
||||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
let baseOptions;
|
let baseOptions;
|
||||||
|
|
@ -69,56 +69,56 @@ export const UsageApiAxiosParamCreator = function (configuration?: Configuration
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UsageApi - functional programming interface
|
* BillingApi - functional programming interface
|
||||||
*/
|
*/
|
||||||
export const UsageApiFp = function(configuration?: Configuration) {
|
export const BillingApiFp = function(configuration?: Configuration) {
|
||||||
const localVarAxiosParamCreator = UsageApiAxiosParamCreator(configuration)
|
const localVarAxiosParamCreator = BillingApiAxiosParamCreator(configuration)
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns aggregate token/cost usage across all completed runs since server start.
|
* Returns aggregate token counts and billed totals across all completed runs since server start.
|
||||||
* @summary Aggregate Usage
|
* @summary Aggregate Billing
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
async getAggregateUsage(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AggregateUsage>> {
|
async getAggregateBilling(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AggregateBilling>> {
|
||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAggregateUsage(options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAggregateBilling(options);
|
||||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||||
const localVarOperationServerBasePath = operationServerMap['UsageApi.getAggregateUsage']?.[localVarOperationServerIndex]?.url;
|
const localVarOperationServerBasePath = operationServerMap['BillingApi.getAggregateBilling']?.[localVarOperationServerIndex]?.url;
|
||||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UsageApi - factory interface
|
* BillingApi - factory interface
|
||||||
*/
|
*/
|
||||||
export const UsageApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
export const BillingApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||||
const localVarFp = UsageApiFp(configuration)
|
const localVarFp = BillingApiFp(configuration)
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns aggregate token/cost usage across all completed runs since server start.
|
* Returns aggregate token counts and billed totals across all completed runs since server start.
|
||||||
* @summary Aggregate Usage
|
* @summary Aggregate Billing
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
getAggregateUsage(options?: RawAxiosRequestConfig): AxiosPromise<AggregateUsage> {
|
getAggregateBilling(options?: RawAxiosRequestConfig): AxiosPromise<AggregateBilling> {
|
||||||
return localVarFp.getAggregateUsage(options).then((request) => request(axios, basePath));
|
return localVarFp.getAggregateBilling(options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UsageApi - object-oriented interface
|
* BillingApi - object-oriented interface
|
||||||
*/
|
*/
|
||||||
export class UsageApi extends BaseAPI {
|
export class BillingApi extends BaseAPI {
|
||||||
/**
|
/**
|
||||||
* Returns aggregate token/cost usage across all completed runs since server start.
|
* Returns aggregate token counts and billed totals across all completed runs since server start.
|
||||||
* @summary Aggregate Usage
|
* @summary Aggregate Billing
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
public getAggregateUsage(options?: RawAxiosRequestConfig) {
|
public getAggregateBilling(options?: RawAxiosRequestConfig) {
|
||||||
return UsageApiFp(this.configuration).getAggregateUsage(options).then((request) => request(this.axios, this.basePath));
|
return BillingApiFp(this.configuration).getAggregateBilling(options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,23 +24,23 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import type { ErrorResponse } from '../models';
|
import type { ErrorResponse } from '../models';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import type { RunUsage } from '../models';
|
import type { RunBilling } from '../models';
|
||||||
/**
|
/**
|
||||||
* RunOutputsApi - axios parameter creator
|
* RunOutputsApi - axios parameter creator
|
||||||
*/
|
*/
|
||||||
export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) {
|
export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns token and cost usage broken down by stage and model for a specific run.
|
* Returns token counts and billed totals broken down by stage and model for a specific run.
|
||||||
* @summary Retrieve Run Usage
|
* @summary Retrieve Run Billing
|
||||||
* @param {string} id Unique run identifier (ULID).
|
* @param {string} id Unique run identifier (ULID).
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
retrieveRunUsage: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
retrieveRunBilling: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
// verify required parameter 'id' is not null or undefined
|
// verify required parameter 'id' is not null or undefined
|
||||||
assertParamExists('retrieveRunUsage', 'id', id)
|
assertParamExists('retrieveRunBilling', 'id', id)
|
||||||
const localVarPath = `/api/v1/runs/{id}/usage`
|
const localVarPath = `/api/v1/runs/{id}/billing`
|
||||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
|
@ -81,16 +81,16 @@ export const RunOutputsApiFp = function(configuration?: Configuration) {
|
||||||
const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration)
|
const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration)
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns token and cost usage broken down by stage and model for a specific run.
|
* Returns token counts and billed totals broken down by stage and model for a specific run.
|
||||||
* @summary Retrieve Run Usage
|
* @summary Retrieve Run Billing
|
||||||
* @param {string} id Unique run identifier (ULID).
|
* @param {string} id Unique run identifier (ULID).
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
async retrieveRunUsage(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunUsage>> {
|
async retrieveRunBilling(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunBilling>> {
|
||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunUsage(id, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunBilling(id, options);
|
||||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||||
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.retrieveRunUsage']?.[localVarOperationServerIndex]?.url;
|
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.retrieveRunBilling']?.[localVarOperationServerIndex]?.url;
|
||||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -103,14 +103,14 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
|
||||||
const localVarFp = RunOutputsApiFp(configuration)
|
const localVarFp = RunOutputsApiFp(configuration)
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Returns token and cost usage broken down by stage and model for a specific run.
|
* Returns token counts and billed totals broken down by stage and model for a specific run.
|
||||||
* @summary Retrieve Run Usage
|
* @summary Retrieve Run Billing
|
||||||
* @param {string} id Unique run identifier (ULID).
|
* @param {string} id Unique run identifier (ULID).
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
retrieveRunUsage(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunUsage> {
|
retrieveRunBilling(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunBilling> {
|
||||||
return localVarFp.retrieveRunUsage(id, options).then((request) => request(axios, basePath));
|
return localVarFp.retrieveRunBilling(id, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -120,14 +120,14 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
|
||||||
*/
|
*/
|
||||||
export class RunOutputsApi extends BaseAPI {
|
export class RunOutputsApi extends BaseAPI {
|
||||||
/**
|
/**
|
||||||
* Returns token and cost usage broken down by stage and model for a specific run.
|
* Returns token counts and billed totals broken down by stage and model for a specific run.
|
||||||
* @summary Retrieve Run Usage
|
* @summary Retrieve Run Billing
|
||||||
* @param {string} id Unique run identifier (ULID).
|
* @param {string} id Unique run identifier (ULID).
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
public retrieveRunUsage(id: string, options?: RawAxiosRequestConfig) {
|
public retrieveRunBilling(id: string, options?: RawAxiosRequestConfig) {
|
||||||
return RunOutputsApiFp(this.configuration).retrieveRunUsage(id, options).then((request) => request(this.axios, this.basePath));
|
return RunOutputsApiFp(this.configuration).retrieveRunBilling(id, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate usage totals across all runs.
|
* Aggregate billing totals across all runs.
|
||||||
*/
|
*/
|
||||||
export interface AggregateUsageTotals {
|
export interface AggregateBillingTotals {
|
||||||
/**
|
/**
|
||||||
* Total number of completed runs.
|
* Total number of completed runs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,9 +31,25 @@ export interface AggregateUsageTotals {
|
||||||
*/
|
*/
|
||||||
'output_tokens': number;
|
'output_tokens': number;
|
||||||
/**
|
/**
|
||||||
* Total cost in USD.
|
* Total tokens aggregated across all billing categories.
|
||||||
*/
|
*/
|
||||||
'cost': number;
|
'total_tokens': number;
|
||||||
|
/**
|
||||||
|
* Total reasoning tokens.
|
||||||
|
*/
|
||||||
|
'reasoning_tokens'?: number;
|
||||||
|
/**
|
||||||
|
* Total cache read tokens.
|
||||||
|
*/
|
||||||
|
'cache_read_tokens'?: number;
|
||||||
|
/**
|
||||||
|
* Total cache write tokens.
|
||||||
|
*/
|
||||||
|
'cache_write_tokens'?: number;
|
||||||
|
/**
|
||||||
|
* Total billed USD amount in micros.
|
||||||
|
*/
|
||||||
|
'total_usd_micros'?: number;
|
||||||
/**
|
/**
|
||||||
* Total runtime in seconds.
|
* Total runtime in seconds.
|
||||||
*/
|
*/
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue