Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions config/crds/agentprimitives.authzed.com_agentsessions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2012,8 +2012,11 @@ spec:
properties:
amountMicroUSD:
description: |-
AmountMicroUSD is the session total in micro-USD (1e-6 USD); 0 and
meaningless when PricingKnown is false.
AmountMicroUSD is the session total in micro-USD (1e-6 USD): the sum of
every priced component (ByModel + ByTool). When PricingKnown is false, at
least one served MODEL had no rate, so this is a LOWER BOUND on spend (the
components that could be priced), not the full session cost — it is no
longer necessarily 0, because provider-reported tool cost is always priced.
format: int64
type: integer
asOf:
Expand Down Expand Up @@ -2065,6 +2068,37 @@ spec:
type: object
type: array
x-kubernetes-list-type: atomic
byTool:
description: |-
ByTool breaks out the cost of inner interactive toolkits (e.g. a `claude`
Claude Code sub-run's own provider-reported spend) a session drove. These
are ADDED to AmountMicroUSD alongside ByModel — a session's total is model
spend plus tool spend. Empty when no interactive toolkit reported a cost.
Tool cost is always provider-reported, never table-priced, so a bucket's
PricingKnown is always true.
items:
description: ToolCostBucket is one interactive toolkit's slice
of a session's cost.
properties:
amountMicroUSD:
description: |-
AmountMicroUSD is this tool's provider-reported cost, summed across its
invocations, in micro-USD (1e-6 USD).
format: int64
type: integer
pricingKnown:
description: |-
PricingKnown is true when AmountMicroUSD is a real cost. Tool cost is
always provider-reported, so this is true whenever a cost was reported.
type: boolean
tool:
description: Tool is the outer tool name (e.g. "claude-oauth").
type: string
required:
- tool
type: object
type: array
x-kubernetes-list-type: atomic
currency:
description: Currency is the ISO code the amount is denominated
in ("USD").
Expand All @@ -2074,8 +2108,9 @@ spec:
type: string
pricingKnown:
description: |-
PricingKnown is false when the model had no price; the amount is then 0
and must not be shown as a real cost.
PricingKnown is false when a served model had no price. The amount is then
a lower bound (priced components only), not the full cost. Tool buckets are
always priced, so this tracks model-pricing coverage specifically.
type: boolean
type: object
failureReason:
Expand Down
34 changes: 31 additions & 3 deletions internal/cmd/runner/interactive_event_publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestBuildToolSessionEventPublisher_PublishesKindToolSessionEvent(t *testing
mem := memory.NewLocal(inmem.NewBackend())
scope := memory.Scope{Kind: "session", ID: "default/agent-1"}
onEvent := buildToolSessionEventPublisher(
context.Background(), pub, mem, scope, "off", "default", "agent-1", nil)
context.Background(), pub, mem, scope, "off", "default", "agent-1", nil, nil)
onEvent("tc-abc", "", "", toolkitstream.Event{Type: toolkitstream.EventTextDelta, Text: "hi"})

mu.Lock()
Expand All @@ -60,7 +60,7 @@ func TestBuildToolSessionEventPublisher_CarriesReasonAndOuterTool(t *testing.T)
return nil
}
emit := buildToolSessionEventPublisher(context.Background(), pub,
nil /*mem*/, memory.Scope{} /*scope*/, spiceboxv1alpha1.ToolSessionLogOff /*no persist*/, "ns", "sess", nil)
nil /*mem*/, memory.Scope{} /*scope*/, spiceboxv1alpha1.ToolSessionLogOff /*no persist*/, "ns", "sess", nil, nil)

emit("tc-1", "Have claude write the README", "claude",
toolkitstream.Event{Type: toolkitstream.EventTextDelta, Text: "hi"})
Expand Down Expand Up @@ -91,7 +91,7 @@ func TestBuildToolSessionEventPublisher_AllFieldsRoundTrip(t *testing.T) {
mem := memory.NewLocal(inmem.NewBackend())
scope := memory.Scope{Kind: "session", ID: "default/agent-1"}
onEvent := buildToolSessionEventPublisher(
context.Background(), pub, mem, scope, "off", "default", "agent-1", nil)
context.Background(), pub, mem, scope, "off", "default", "agent-1", nil, nil)

onEvent("tc-1", "", "", toolkitstream.Event{
Type: toolkitstream.EventToolUseStop,
Expand All @@ -110,3 +110,31 @@ func TestBuildToolSessionEventPublisher_AllFieldsRoundTrip(t *testing.T) {
assert.False(t, pl.OK)
assert.Equal(t, "exit code 1", pl.Summary)
}

func TestBuildToolSessionEventPublisher_InvokesOnResultForResultEvent(t *testing.T) {
type call struct {
tool string
cost float64
ok bool
}
var got []call
onResult := func(tool string, cost float64, ok bool) {
got = append(got, call{tool, cost, ok})
}
pub := func(_ context.Context, _ string, _ []byte) error { return nil }

emit := buildToolSessionEventPublisher(context.Background(), pub,
nil, memory.Scope{}, spiceboxv1alpha1.ToolSessionLogOff, "ns", "sess", nil, onResult)

// A non-result event must NOT trigger onResult.
emit("tc-1", "reason", "claude-oauth",
toolkitstream.Event{Type: toolkitstream.EventTextDelta, Text: "hi"})
// A result event MUST trigger it once, carrying tool/cost/ok.
emit("tc-1", "reason", "claude-oauth",
toolkitstream.Event{Type: toolkitstream.EventResult, OK: true, CostUSD: 5.43})

require.Len(t, got, 1, "onResult fires only on EventResult")
assert.Equal(t, "claude-oauth", got[0].tool)
assert.Equal(t, 5.43, got[0].cost)
assert.True(t, got[0].ok)
}
12 changes: 11 additions & 1 deletion internal/cmd/runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2984,7 +2984,8 @@ func run(cfg *config) error {
}
},
OnEvent: buildToolSessionEventPublisher(
rootCtx, pub, memSigned, scope, class.Spec.ToolSessionLog, hooksNS, hooksName, envSigner),
rootCtx, pub, memSigned, scope, class.Spec.ToolSessionLog, hooksNS, hooksName, envSigner,
loop.AddToolCost),
Register: toolSessionReg.register,
}
}
Expand Down Expand Up @@ -3241,6 +3242,7 @@ func buildToolSessionEventPublisher(
logMode string,
ns, name string,
signer *channelevents.EnvelopeSigner,
onResult func(outerTool string, costUSD float64, ok bool),
) func(toolCallRef, reason, outerTool string, ev toolkitstream.Event) {
return func(toolCallRef, reason, outerTool string, ev toolkitstream.Event) {
// NATS -> channelsd -> Slack — unchanged, always runs.
Expand Down Expand Up @@ -3287,6 +3289,14 @@ func buildToolSessionEventPublisher(
"toolCallRef", toolCallRef,
"eventType", string(ev.Type))
}

// Fold this interactive toolkit's own provider-reported cost into the
// session total. Fires on the terminal result event only, and regardless
// of the ToolSessionLog persist gate above — accumulation must not depend
// on logging being on.
if ev.Type == toolkitstream.EventResult && onResult != nil {
onResult(outerTool, ev.CostUSD, ev.OK)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/runner/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func TestBuildToolSessionEventPublisher_Persist(t *testing.T) {
noopPub := func(context.Context, string, []byte) error { return nil }

onEvent := buildToolSessionEventPublisher(
ctx, noopPub, mem, scope, tc.mode, "default", "sess1", nil)
ctx, noopPub, mem, scope, tc.mode, "default", "sess1", nil, nil)
onEvent("ref-1", "", "", toolkitstream.Event{
Type: toolkitstream.EventToolUseStart, ToolName: "Edit", ToolID: "t1",
})
Expand Down
54 changes: 45 additions & 9 deletions pkg/agent/postsession/cost/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,21 @@ func (r *Reporter) Eval(ctx context.Context, in pipeline.Input) pipeline.Decisio
micro = microUSD(e, price)
}

// Interactive toolkit cost (e.g. an inner `claude` sub-run's own billed
// spend) is provider-reported, so it is always "known" and is ADDED to the
// model total. When a served model is unpriced (known=false, micro=0), the
// grand total is still the priced tool component — a lower bound, per the
// EstimatedSessionCost.AmountMicroUSD contract.
toolBuckets, toolTotal := buildToolBuckets(e.ByTool)

cost := v1.EstimatedSessionCost{
AmountMicroUSD: micro,
AmountMicroUSD: micro + toolTotal,
Currency: currency,
Model: e.Model,
PricingKnown: known,
AsOf: r.d.Now(),
ByModel: buckets,
ByTool: toolBuckets,
}
if known && cost.Currency == "" {
cost.Currency = "USD"
Expand Down Expand Up @@ -165,6 +173,22 @@ func (r *Reporter) buildCostBuckets(usages []pipeline.ModelUsage) (buckets []v1.
return buckets, total, allKnown, currency
}

// buildToolBuckets maps each interactive-toolkit usage bucket to a priced
// ToolCostBucket. Tool cost is always provider-reported (already micro-USD and
// "known"), so there is no table lookup here — this is a pure projection plus a
// sum. Returns the buckets in caller order and their total.
func buildToolBuckets(usages []pipeline.ToolUsage) (buckets []v1.ToolCostBucket, total int64) {
if len(usages) == 0 {
return nil, 0
}
buckets = make([]v1.ToolCostBucket, len(usages))
for i, u := range usages {
buckets[i] = v1.ToolCostBucket{Tool: u.Tool, AmountMicroUSD: u.CostMicroUSD, PricingKnown: u.CostReported}
total += u.CostMicroUSD
}
return buckets, total
}

func costNotice(e *pipeline.SessionEndInfo, c v1.EstimatedSessionCost, known bool) *notice.Notice {
if !known {
return notice.New(categories.SessionCost, notice.Args{
Expand All @@ -177,15 +201,27 @@ func costNotice(e *pipeline.SessionEndInfo, c v1.EstimatedSessionCost, known boo
Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceParticipants},
})
}
fields := []channelevents.InteractionField{
{Label: "Tokens", Value: fmt.Sprintf("%s in / %s out",
formatTokens(e.InputTokens), formatTokens(e.OutputTokens))},
{Label: "Cache", Value: fmt.Sprintf("%s read / %s write",
formatTokens(e.CacheReadTokens), formatTokens(e.CacheCreationTokens))},
{Label: "Model", Value: e.Model},
}
// Break out inner interactive-toolkit spend (e.g. a passthrough `claude`
// sub-run) so the total's provenance is visible, not folded silently.
var toolTotal int64
for _, b := range c.ByTool {
toolTotal += b.AmountMicroUSD
}
if toolTotal > 0 {
fields = append(fields, channelevents.InteractionField{
Label: "Sub-agent tools", Value: formatUSD(toolTotal),
})
}
return notice.New(categories.SessionCost, notice.Args{
Lead: fmt.Sprintf("This session cost ~%s", formatUSD(c.AmountMicroUSD)),
Fields: []channelevents.InteractionField{
{Label: "Tokens", Value: fmt.Sprintf("%s in / %s out",
formatTokens(e.InputTokens), formatTokens(e.OutputTokens))},
{Label: "Cache", Value: fmt.Sprintf("%s read / %s write",
formatTokens(e.CacheReadTokens), formatTokens(e.CacheCreationTokens))},
{Label: "Model", Value: e.Model},
},
Lead: fmt.Sprintf("This session cost ~%s", formatUSD(c.AmountMicroUSD)),
Fields: fields,
Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceParticipants},
})
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/agent/postsession/cost/cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,49 @@ func TestEval_ByModel_ReportedPreferredOverEstimated(t *testing.T) {
assert.True(t, stamped.PricingKnown, "every bucket priced -> session PricingKnown")
}

func TestEval_ByTool_AddsToGrandTotalAndItemizes(t *testing.T) {
r, stamped := newWithCapturePricer(multiModelPricer)
in := endInput("completed", 0, 0, 0, 0)
in.End.ByModel = []pipeline.ModelUsage{
{Display: "anthropic/claude-3.5-sonnet", Model: "anthropic/claude-3.5-sonnet",
InputTokens: 1_000_000, OutputTokens: 0}, // 1M in × $3 = $3.00 -> 3_000_000 micro
}
in.End.ByTool = []pipeline.ToolUsage{{Tool: "claude-oauth", CostMicroUSD: 19_020_000, CostReported: true}}
r.Eval(context.Background(), in)

require.Len(t, stamped.ByTool, 1)
assert.Equal(t, "claude-oauth", stamped.ByTool[0].Tool)
assert.Equal(t, int64(19_020_000), stamped.ByTool[0].AmountMicroUSD)
assert.True(t, stamped.ByTool[0].PricingKnown)
assert.Equal(t, int64(3_000_000+19_020_000), stamped.AmountMicroUSD, "grand total = model + tool")
assert.True(t, stamped.PricingKnown, "model priced + tool always priced")
}

func TestEval_NoTool_IdenticalToToday(t *testing.T) {
r, stamped := newWithCapturePricer(multiModelPricer)
in := endInput("completed", 0, 0, 0, 0)
in.End.ByModel = []pipeline.ModelUsage{
{Display: "anthropic/claude-3.5-sonnet", Model: "anthropic/claude-3.5-sonnet", InputTokens: 1_000_000},
}
r.Eval(context.Background(), in)

assert.Nil(t, stamped.ByTool, "no tool -> no byTool")
assert.Equal(t, int64(3_000_000), stamped.AmountMicroUSD, "unchanged from today")
}

func TestEval_UnknownModel_KnownTool_LowerBound(t *testing.T) {
r, stamped := newWithCapturePricer(multiModelPricer) // "mystery-model" unpriced
in := endInput("completed", 0, 0, 0, 0)
in.End.ByModel = []pipeline.ModelUsage{
{Display: "openrouter/mystery/model", Model: "mystery-model", InputTokens: 1_000_000},
}
in.End.ByTool = []pipeline.ToolUsage{{Tool: "claude-oauth", CostMicroUSD: 19_020_000, CostReported: true}}
r.Eval(context.Background(), in)

assert.False(t, stamped.PricingKnown, "an unpriced model makes the total a lower bound")
assert.Equal(t, int64(19_020_000), stamped.AmountMicroUSD, "nonzero: the priced (tool) component")
}

func TestEval_ByModel_UnknownPriceBucket_ZeroAndUnknown(t *testing.T) {
r, stamped := newWithCapturePricer(multiModelPricer)
in := endInput("completed", 0, 0, 0, 0)
Expand Down
6 changes: 6 additions & 0 deletions pkg/agent/runner/loop_deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,12 @@ type Loop struct {
// accumulate into the existing bucket instead of appending a duplicate.
usageByModel []modelUsageBucket
usageByModelIdx map[string]int
// usageByTool is the per-interactive-toolkit running cost accumulation, in
// first-encountered order, keyed by outer tool name in usageByToolIdx. Also
// guarded by usageMu (see above) — addToolCost fires from the same terminal
// bookkeeping path as addUsage/addModelUsage.
usageByTool []toolUsageBucket
usageByToolIdx map[string]int

// Engine is the runner's single authz dependency. Production wiring comes from
// internal/cmd/runner/main.go; tests inject a fake or leave nil (falling back to the
Expand Down
1 change: 1 addition & 0 deletions pkg/agent/runner/loop_failure.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ func (l *Loop) fireSessionEnd(ctx context.Context, reason string) {
CacheCreationTokens: snap.CacheCreationTokens,
CacheReadTokens: snap.CacheReadTokens,
ByModel: l.usageByModelSnapshot(),
ByTool: l.usageByToolSnapshot(),
},
}, host); err != nil {
slog.Default().Info("fireSessionEnd: SessionEnd executor errored (best-effort)",
Expand Down
59 changes: 59 additions & 0 deletions pkg/agent/runner/loop_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,65 @@ func (l *Loop) usageByModelSnapshot() []pipeline.ModelUsage {
return out
}

// toolUsageBucket is one interactive toolkit's running cost accumulation across
// its invocations this session, keyed by outer tool name.
type toolUsageBucket struct {
tool string
costMicroUSD int64
costReported bool
}

// AddToolCost folds one interactive toolkit result event's provider-reported
// cost into the bucket for outerTool. Shares usageMu with addUsage/addModelUsage
// (see usageMu's doc). ok is the toolkit's success flag; v1 accumulates cost
// regardless — a run the provider billed cost money whether or not it succeeded,
// and an unbilled run reports 0 anyway.
//
// Exported (unlike addModelUsage) because it is wired from internal/cmd/runner's
// interactive-tool result callback, which lives in a different package; the
// runner's turn loop feeds addModelUsage internally, so that one stays private.
func (l *Loop) AddToolCost(outerTool string, costUSD float64, ok bool) {
l.usageMu.Lock()
defer l.usageMu.Unlock()
if l.usageByToolIdx == nil {
l.usageByToolIdx = make(map[string]int)
}
i, seen := l.usageByToolIdx[outerTool]
if !seen {
i = len(l.usageByTool)
l.usageByToolIdx[outerTool] = i
l.usageByTool = append(l.usageByTool, toolUsageBucket{tool: outerTool})
}
b := &l.usageByTool[i]
if costUSD < 0 {
// A negative provider-reported tool cost would reduce the session total.
// Treat it as unreported rather than netting it against real charges —
// same guard as addModelUsage.
slog.Default().Info("negative reported tool cost; ignoring",
"tool", outerTool, "costUSD", costUSD)
return
}
// Round once at the USD->micro-USD boundary so accumulation is pure int64.
b.costMicroUSD += int64(math.Round(costUSD * 1e6))
b.costReported = true
_ = ok
}

// usageByToolSnapshot returns the Loop's per-tool cost as pipeline.ToolUsage in
// first-encountered order (nil when unused). Safe for concurrent use.
func (l *Loop) usageByToolSnapshot() []pipeline.ToolUsage {
l.usageMu.Lock()
defer l.usageMu.Unlock()
if len(l.usageByTool) == 0 {
return nil
}
out := make([]pipeline.ToolUsage, len(l.usageByTool))
for i, b := range l.usageByTool {
out[i] = pipeline.ToolUsage{Tool: b.tool, CostMicroUSD: b.costMicroUSD, CostReported: b.costReported}
}
return out
}

// StampEstimatedCost persists the cost estimate via the StatusPatcher. nil-safe:
// a Loop without a Status (tests/kubectl-driven) silently no-ops.
func (l *Loop) StampEstimatedCost(ctx context.Context, c spiceboxv1alpha1.EstimatedSessionCost) error {
Expand Down
Loading
Loading