Describe billing_by_model on the API and the Billing tab

StageProjection.billing_by_model and a BilledModelUsage schema that reuses
fabro's type; the TypeScript client regenerated; the Billing tab's token
tooltip says subagent tokens are included and priced at each subagent's
model; the stage.completed docs describe the rows and the one usage rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-13 07:49:28 -06:00
parent 850d5cca52
commit 36e9259eb7
No known key found for this signature in database
10 changed files with 194 additions and 36 deletions

View file

@ -97,6 +97,9 @@ function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
</Fragment>
))}
</dl>
<p className="border-line text-fg-3 mt-1.5 border-t pt-1">
Includes subagent tokens, priced at each subagent&apos;s model.
</p>
</div>
);
}

View file

@ -422,6 +422,7 @@ Emitted when a workflow node finishes execution.
| `usage.reasoning_tokens` | number? | Reasoning/thinking tokens |
| `usage.speed` | string? | Speed tier |
| `usage.cost` | number? | Estimated cost in USD |
| `billing_by_model` | array? | For an agent stage, the stage's billing split by model: the root session's route and each subagent's own model, a subagent whose model the catalog does not know billed at the root's. Each row has `model`, `tokens`, and `total_usd_micros`, and the rows sum to the stage's billing. Empty for stages without a coding agent and on events written before it existed |
| `error` | string? | Error message (flattened from failure detail) |
| `failure_class` | string? | `"transient_infra"`, `"deterministic"`, `"budget_exhausted"`, `"compilation_loop"`, `"canceled"`, `"structural"` |
| `failure_signature` | string? | Dedup key for repeated failures |
@ -433,6 +434,12 @@ Emitted when a workflow node finishes execution.
| `restart_failure_signatures` | object? | Restart failure signature counts |
| `response` | string? | Full LLM or agent response text when produced by the stage |
| `notes` | string? | Free-text notes |
An agent stage's usage is its whole session tree's: the root session and
every subagent, live in `StageProjection.usage` and here at completion, both
read from the same fold of the stage's agent events. The root is priced at
its route and each subagent at its own model; where the provider reported a
cost, that cost stands.
| `files_touched` | string[] | File paths modified |
| `attempt` | number | Attempt number (1-based) |
| `max_attempts` | number | Maximum attempts allowed |

View file

@ -11227,6 +11227,18 @@ components:
agent_control:
$ref: "#/components/schemas/AgentControlState"
description: Whether the agent is executing normally or waiting for steering after an interrupt.
billing_by_model:
type: array
items:
$ref: "#/components/schemas/BilledModelUsage"
default: []
description: >-
The completed stage's `usage` split by model, as `stage.completed`
reported it: the root session's route and each subagent's own
model, a subagent whose model the catalog does not know billed at
the root's. Sums to `usage`. Empty while the stage runs and for
stages without a coding agent; the billing rollup then bills
`usage` to `model`.
agent:
oneOf:
- $ref: "#/components/schemas/AgentSessionProjection"
@ -13355,6 +13367,26 @@ components:
description: Billed USD amount in micros.
example: 720000
BilledModelUsage:
description: >-
Usage and cost billed to one model: one response, or one model's share
of a stage.
type: object
required:
- model
- tokens
properties:
model:
$ref: "#/components/schemas/BillingModelRef"
tokens:
$ref: "#/components/schemas/CompletionUsage"
total_usd_micros:
type: integer
format: int64
description: >-
Cost for `tokens`, when the provider reported one or the catalog
could price them. Absent means no cost data, not zero.
BillingModelRef:
description: Provider-qualified billing model identity used for cost estimates.
type: object

View file

@ -385,6 +385,7 @@ fn main() {
&[],
),
("StageProjection", "fabro_types::StageProjection", &[]),
("BilledModelUsage", "fabro_types::BilledModelUsage", &[]),
(
"StageInferenceProjection",
"fabro_types::StageInferenceProjection",

View file

@ -39,39 +39,39 @@ pub mod types {
};
pub use fabro_types::{
ActivatedSkill, AgentControlState, AgentEventProps, AgentMcpToolSummary,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
CommandTermination, Conclusion, ContextWindowBreakdownItem, ContextWindowCategory,
ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness,
ContextWindowWarning, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus,
EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature,
GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, IdpIdentity,
IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus,
IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord,
LlmOutputKind, McpServerDraft as CreateMcpServerRequest, McpServerProjection,
McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,
McpTransportView, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits,
ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, PairMessageRecord,
PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget,
PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, ParallelBranchResult,
PendingInterviewRecord, PermissionLevel, Principal, Provider, PullRequest,
PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails,
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget,
ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent,
RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs,
RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime,
RunServerProvenance, RunSessionMetadata, RunSize, RunTarget, SandboxDetails, SandboxInfo,
SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError,
SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings,
SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn,
SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion, StageContextWindow,
StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection,
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
SystemIntegrationsResponse, TodoListProjection, ToolCategory, ToolSource, ToolSummary,
TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath,
WorkflowSettings, WorkflowVersion, WorkflowVersionId,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledModelUsage,
BilledTokenCounts, BlobHash, CommandTermination, Conclusion, ContextWindowBreakdownItem,
ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot,
ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, DiffStats,
DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource,
IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState,
IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption,
InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest,
McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus,
McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts,
ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, PairId,
PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest,
PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId,
ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, Provider,
PullRequest, PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus,
PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason,
PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef,
ReviewTarget, ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance,
RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent,
RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource,
RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan,
RunSandboxRuntime, RunServerProvenance, RunSessionMetadata, RunSize, RunTarget,
SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
SandboxProviderLookupError, SandboxService, SandboxServiceListResponse, SecretMetadata,
SecretType, ServerSettings, SessionDetail, SessionId, SessionStatus, SessionSummary,
SessionTurn, SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion,
StageContextWindow, StageContextWindowUnavailableReason, StageHandler, StageId,
StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState,
StageToolBatchProjection, SubAgentProjection, SubAgentStatus, SystemActorKind,
SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, ToolCategory,
ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal, Variable,
VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, WorkflowVersionId,
};
pub use lithos_llm::catalog::{ModelHandle, ProviderId};
pub use lithos_llm::types::{

View file

@ -4,6 +4,7 @@ use fabro_api::types::{
ActivatedSkill as ApiActivatedSkill, AgentControlState as ApiAgentControlState,
AgentMcpToolSummary as ApiAgentMcpToolSummary,
AgentToolsAvailableProps as ApiAgentToolsAvailableProps,
BilledModelUsage as ApiBilledModelUsage,
ContextWindowBreakdownItem as ApiContextWindowBreakdownItem,
ContextWindowCategory as ApiContextWindowCategory,
ContextWindowCountMethod as ApiContextWindowCountMethod,
@ -23,19 +24,91 @@ use fabro_api::types::{
};
use fabro_types::{
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentToolsAvailableProps,
ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod,
BilledModelUsage, ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod,
ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, LlmOutputKind,
McpServerProjection, McpServerStatus, ParallelBranchId, ParallelBranchResult, PermissionLevel,
SkillActivationSource, SkillSummary, SkillsProjection, StageContextWindow,
McpServerProjection, McpServerStatus, ModelRef, ParallelBranchId, ParallelBranchResult,
PermissionLevel, SkillActivationSource, SkillSummary, SkillsProjection, StageContextWindow,
StageContextWindowUnavailableReason, StageId, StageInferenceProjection, StageProjection,
StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,
ToolCategory, ToolSource, ToolSummary,
};
use lithos_llm::catalog::{ModelId, ProviderId};
use lithos_llm::types::TokenCounts;
use serde_json::json;
#[test]
fn stage_projection_reuses_canonical_type() {
assert_same_type::<ApiStageProjection, StageProjection>();
assert_same_type::<ApiBilledModelUsage, BilledModelUsage>();
}
#[test]
fn billing_by_model_rows_match_openapi_json_shape() {
let row = BilledModelUsage {
model: ModelRef::new(ProviderId::new("openai"), ModelId::new("gpt-5.4")),
tokens: TokenCounts {
input: 107,
output: 51,
..TokenCounts::default()
},
total_usd_micros: Some(321),
};
let value = serde_json::to_value(&row).unwrap();
assert_eq!(
value,
json!({
"model": { "provider": "openai", "model_id": "gpt-5.4" },
"tokens": {
"input": 107,
"output": 51,
"reasoning": 0,
"cache_read": 0,
"cache_write": 0
},
"total_usd_micros": 321
})
);
let api_row: ApiBilledModelUsage = serde_json::from_value(value).unwrap();
assert_eq!(api_row, row);
let mut stage = StageProjection::new(std::num::NonZeroU32::new(1).unwrap());
stage.billing_by_model = vec![row.clone()];
let stage_json = serde_json::to_value(&stage).unwrap();
assert_eq!(
stage_json["billing_by_model"],
json!([serde_json::to_value(&row).unwrap()])
);
let without: StageProjection = serde_json::from_value(json!({
"first_event_seq": 1,
"prompt": null,
"response": null,
"completion": null,
"provider_used": null,
"diff": null,
"script_invocation": null,
"script_timing": null,
"parallel_results": null,
"output": null,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"agent_control": "running",
"state": "running"
}))
.unwrap();
assert!(without.billing_by_model.is_empty());
assert!(
serde_json::to_value(&without)
.unwrap()
.get("billing_by_model")
.is_none(),
"no rows, nothing on the wire"
);
}
#[test]

View file

@ -87,6 +87,7 @@ models/batch-run-lifecycle-request.ts
models/batch-run-lifecycle-response.ts
models/batch-run-lifecycle-result.ts
models/batch-run-lifecycle-summary.ts
models/billed-model-usage.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-model-ref.ts

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BillingModelRef } from './billing-model-ref';
// May contain unused imports in some cases
// @ts-ignore
import type { CompletionUsage } from './completion-usage';
/**
* Usage and cost billed to one model: one response, or one model\'s share of a stage.
*/
export interface BilledModelUsage {
'model': BillingModelRef;
'tokens': CompletionUsage;
/**
* Cost for `tokens`, when the provider reported one or the catalog could price them. Absent means no cost data, not zero.
*/
'total_usd_micros'?: number;
}

View file

@ -58,6 +58,7 @@ export * from './batch-run-lifecycle-request';
export * from './batch-run-lifecycle-response';
export * from './batch-run-lifecycle-result';
export * from './batch-run-lifecycle-summary';
export * from './billed-model-usage';
export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-model-ref';

View file

@ -21,6 +21,9 @@ import type { AgentControlState } from './agent-control-state';
import type { AgentSessionProjection } from './agent-session-projection';
// May contain unused imports in some cases
// @ts-ignore
import type { BilledModelUsage } from './billed-model-usage';
// May contain unused imports in some cases
// @ts-ignore
import type { BilledTokenCounts } from './billed-token-counts';
// May contain unused imports in some cases
// @ts-ignore
@ -145,6 +148,10 @@ export interface StageProjection {
* Whether the agent is executing normally or waiting for steering after an interrupt.
*/
'agent_control': AgentControlState;
/**
* The completed stage\'s `usage` split by model, as `stage.completed` reported it: the root session\'s route and each subagent\'s own model, a subagent whose model the catalog does not know billed at the root\'s. Sums to `usage`. Empty while the stage runs and for stages without a coding agent; the billing rollup then bills `usage` to `model`.
*/
'billing_by_model'?: Array<BilledModelUsage>;
'agent'?: AgentSessionProjection | null;
/**
* Lifecycle state of the stage projection.