diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 648b1796b..70fe0e5b2 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -97,6 +97,9 @@ function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) { ))} +

+ Includes subagent tokens, priced at each subagent's model. +

); } diff --git a/docs/internal/events.md b/docs/internal/events.md index bcab1a858..b419d9396 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -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 | diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index aa06923ed..3757ae924 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index d5c207a1c..72c597aaa 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -385,6 +385,7 @@ fn main() { &[], ), ("StageProjection", "fabro_types::StageProjection", &[]), + ("BilledModelUsage", "fabro_types::BilledModelUsage", &[]), ( "StageInferenceProjection", "fabro_types::StageInferenceProjection", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 485c72e20..c93eff100 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -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::{ diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index 416c27b56..de27a4811 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -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::(); + assert_same_type::(); +} + +#[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] diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 1c312fcf2..a98114497 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/billed-model-usage.ts b/lib/packages/fabro-api-client/src/models/billed-model-usage.ts new file mode 100644 index 000000000..3d4964992 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/billed-model-usage.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 5771fc062..d707783e3 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index ccf1d548f..9834299d1 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -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; 'agent'?: AgentSessionProjection | null; /** * Lifecycle state of the stage projection.