Skip to content
Merged
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
16 changes: 12 additions & 4 deletions .claude/skills/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ threaded into context, stamped on every audit event:
- `X-Invocation-Caller` — upstream caller identifier

**Response headers** (FWS-3): `X-Forge-Tokens-In`, `X-Forge-Tokens-Out`,
`X-Forge-Duration-Ms`, `X-Forge-Model`, `X-Forge-Provider`.
`X-Forge-Duration-Ms`, `X-Forge-Model`, `X-Forge-Provider`. `X-Forge-Tokens-In`
bills from the TRUE input — summed `total_input_tokens` incl. Anthropic cache
read/creation (#431), guarded to never fall below the uncached delta — so a
cache-heavy stage can't slip past an orchestrator cost ceiling-check.

**Agent Card** carries `name`, `description`, `url`, `version`,
`protocolVersion: "0.3.0"`, `defaultInputModes` /
Expand Down Expand Up @@ -279,7 +282,12 @@ Credentials read from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSI
Token usage and request IDs are captured per provider at the call site
and folded into the `llm_call` audit event (FWS-3) and into the
per-invocation `LLMUsageAccumulator` so the response headers + the
final `invocation_complete` event carry totals.
final `invocation_complete` event carry totals. Under Anthropic prompt
caching the provider's `input_tokens` is only the uncached delta, so the
parser also captures `cache_read_input_tokens` / `cache_creation_input_tokens`
and every layer carries a summed `total_input_tokens` (= delta + cache
read + creation) as the bill-from figure (#431). OpenAI is unaffected —
its `prompt_tokens` already folds in cached input.

**Read**: `docs/core-concepts/runtime-engine.md`, `forge-core/llm/`.

Expand Down Expand Up @@ -1163,7 +1171,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use
| `AuditToolExec` | `tool_exec` | Tool execution `phase: start` / `phase: end`; carries `tool`, `args_size`, `result_size`, `duration_ms` |
| `AuditEgressAllowed` | `egress_allowed` | Outbound request allowed (with domain, mode, source) |
| `AuditEgressBlocked` | `egress_blocked` | Outbound request blocked |
| `AuditLLMCall` | `llm_call` | LLM provider call complete; `model`, `provider`, `input_tokens`, `output_tokens`, `duration_ms`, `request_id` |
| `AuditLLMCall` | `llm_call` | LLM provider call complete; `model`, `provider`, `input_tokens`, `output_tokens`, `total_input_tokens` (always; = input + cache read + creation — bill from this), `cache_read_input_tokens` / `cache_creation_input_tokens` (Anthropic caching only, omitempty), `duration_ms`, `request_id`. Under caching `input_tokens` is only the uncached delta (#431); `tokens_unavailable` keys off total input so a cache-read-only turn isn't misflagged as free |
| `AuditLLMCallCancelled` | `llm_call_cancelled` | Streaming call aborted mid-flight; partial usage counts |
| `AuditGuardrail` | `guardrail_check` | Mask / block / warn decision. Fields: `gate` (`input` / `context` / `tool_call` / `output` / `stream` — from library `Result.Gate`), `decision` (`masked` / `warned` / `blocked`), `guardrail`, `category`, `violation_count`, optional `tool`. Opt-in `evidence` (redacted + truncated triggering text) via `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true`. |
| `AuditScheduleFire` | `schedule_fire` | Cron task triggered |
Expand All @@ -1183,7 +1191,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use
| `context_compressed` | `context_compressed` | Context compression shrank content; `seam` (`tool_output` / `request`), `tool`, `tokens_before` / `tokens_after` / `saved_tokens` + running totals (tokenizer estimates) |
| `context_expanded` | `context_expanded` | Model retrieved offloaded content via `context_expand`; `hash`, `hit`, `bytes`, producing `tool`, mined `candidates` (≤5, for fleet-wide learning aggregation) + running totals |
| `context_pattern_suggested` | `context_pattern_suggested` | Learning loop surfaced a keep_patterns candidate (3+ expansions); `pattern`, `expansions`, `tools` |
| `AuditInvocationComplete` | `invocation_complete` | A2A invocation closed; `duration_ms`, `input_tokens_total`, `output_tokens_total`, `llm_call_count`, `model`, `provider` (FWS-3); with compression enabled also `compression_saved_tokens_total` (realized wire savings, compounds per history resend), `compression_event_saved_tokens`, `compression_count`, `expansion_count` |
| `AuditInvocationComplete` | `invocation_complete` | A2A invocation closed; `duration_ms`, `input_tokens_total`, `output_tokens_total`, `total_input_tokens_total` (bill-from sum incl. Anthropic cache read/creation, #431), `llm_call_count`, `model`, `provider` (FWS-3); with caching also `cache_read_input_tokens_total` / `cache_creation_input_tokens_total`; with compression enabled also `compression_saved_tokens_total` (realized wire savings, compounds per history resend), `compression_event_saved_tokens`, `compression_count`, `expansion_count` |
| `AuditInvocationCancelled` | `invocation_cancelled` | A2A invocation cancelled via `tasks/cancel`; classified `reason` + partial token totals (FWS-4) |
| `AuditTaskAdmissionDenied` | `task_admission_denied` | Inbound `tasks/send` denied by the platform admission middleware (#201; opt-in via `FORGE_ADMISSION_URL` + `FORGE_PLATFORM_TOKEN`); `reason`, `scope`, `window`, `reset_at`, `cached`. Caller sees HTTP 402 Payment Required. |
| `AuditPolicyLoaded` | `policy_loaded` | One per non-empty policy layer at startup; `layer`, `source`, per-list size counters (FWS-5/6) |
Expand Down
7 changes: 6 additions & 1 deletion docs/core-concepts/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,15 @@ The runner registers `AfterLLMCall` hooks that emit structured audit events for
|-------|-------------|
| `provider` | LLM provider name |
| `model` | Model identifier |
| `input_tokens` | Prompt token count |
| `input_tokens` | Prompt token count. Under Anthropic prompt caching this is the **uncached delta only** — see `total_input_tokens` |
| `output_tokens` | Completion token count |
| `cache_read_input_tokens` | Anthropic prompt-cache hit — cached-prefix tokens read this call (omitted when zero / non-Anthropic) |
| `cache_creation_input_tokens` | Anthropic prompt-cache write — tokens spent seeding the cache (omitted when zero / non-Anthropic) |
| `total_input_tokens` | `input_tokens` + cache read + creation — the true input consumption; **always present** (bill from this) |
| `organization_id` | OpenAI Organization ID (when set) |

For the full prompt-caching rationale and the `tokens_unavailable` interaction, see [Token usage](../security/audit-logging.md#token-usage-and-execution-duration).

These events are logged via `slog` at Info level and can be consumed by external log aggregators for cost tracking and compliance.

## Progress Tracking
Expand Down
19 changes: 15 additions & 4 deletions docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr
| `llm_call` | LLM API call completed (with `input_tokens`, `output_tokens`, `model`, `provider`, `duration_ms`, `request_id`, and `fields.url` — the actual endpoint the request hit, e.g. a Kong base URL + `/v1/messages`; recorded even when payload capture is off since the URL is header-authed metadata, not payload). Any `user:pass@` userinfo in the base URL is **stripped** from the recorded `fields.url` so an inline-credential base URL doesn't leak into the audit stream (#358). See [Token usage and duration](#token-usage-and-execution-duration). |
| `llm_call_cancelled` | Streaming LLM call cancelled mid-flight; carries partial token counts captured up to cancellation. |
| `llm_call_failed` | An LLM API call failed (transport error or non-2xx) on the request path (#361). Carries `provider` / `model` / `duration_ms` and `fields.error` (the failure reason) — `fields.error` is **always** secret-scrubbed and length-capped regardless of the payload-capture toggle (see [What gets scrubbed](#what-gets-scrubbed)). Lets operators alert on provider/gateway outages without enabling payload capture. |
| `invocation_complete` | A2A invocation finished (auth → dispatch → engine → response). Carries `duration_ms` (wall-clock) plus aggregated `input_tokens_total` / `output_tokens_total` / `llm_call_count` / `model` / `provider`. When [context compression](../core-concepts/context-compression.md) is enabled it also carries `compression_saved_tokens_total` — REALIZED savings: tokens this invocation's LLM calls did not send because compression markers rode in place of originals, compounding on every resend of compressed history (this is the number that matches the provider bill) — plus `compression_event_saved_tokens` (the one-time per-compression deltas, matching the sum of this invocation's `context_compressed` events), `compression_count`, and `expansion_count` when nonzero. Accumulated per invocation by correlation ID so concurrent tasks never cross-contaminate. |
| `invocation_complete` | A2A invocation finished (auth → dispatch → engine → response). Carries `duration_ms` (wall-clock) plus aggregated `input_tokens_total` / `output_tokens_total` / `total_input_tokens_total` (the bill-from sum incl. Anthropic cache read/creation — see [Token usage](#token-usage-and-execution-duration)) / `llm_call_count` / `model` / `provider`. When prompt caching was active it also carries `cache_read_input_tokens_total` / `cache_creation_input_tokens_total`. When [context compression](../core-concepts/context-compression.md) is enabled it also carries `compression_saved_tokens_total` — REALIZED savings: tokens this invocation's LLM calls did not send because compression markers rode in place of originals, compounding on every resend of compressed history (this is the number that matches the provider bill) — plus `compression_event_saved_tokens` (the one-time per-compression deltas, matching the sum of this invocation's `context_compressed` events), `compression_count`, and `expansion_count` when nonzero. Accumulated per invocation by correlation ID so concurrent tasks never cross-contaminate. |
| `invocation_cancelled` | A2A invocation cancelled mid-flight via `tasks/cancel` (or internal cancellation like parent ctx deadline). Carries `fields.reason` (one of `workflow_failure` / `cost_limit_exceeded` / `timeout` / `external_signal`), `duration_ms` up to cancellation, and any partial token totals consumed before the signal. See [Cancellation](#cancellation). |
| `task_admission_denied` | A new inbound `tasks/send` was rejected by the platform admission middleware (issue #201; opt-in via `FORGE_ADMISSION_URL` + `FORGE_PLATFORM_TOKEN`). Carries `fields.reason` (platform-defined: `cost_limit_exceeded`, `billing_overdue`, …), `fields.scope` (`agent` / `workspace` / `org`), `fields.window` (`hourly` / `daily` / `monthly` / `billing_cycle`), `fields.reset_at` (RFC 3339), and `fields.cached` (`true` when served from the 5s per-agent cache). Caller observes HTTP 402 Payment Required with `Retry-After`. Since admission sits between auth and dispatch and emits via `EmitFromContext`, it carries the ingress-minted `correlation_id` (#278) — so admission denials group with the `auth_verify` of the same request in per-invocation views. See [Platform Admission Hook](admission.md). |
| `guardrail_check` | Guardrail mask / block / warn decision. Carries `fields.gate` (`input` / `context` / `tool_call` / `output` / `stream` — sourced from the library `Result.Gate`), `fields.decision` (`masked` / `warned` / `blocked`), `fields.guardrail` + `fields.category` from the triggering violation, and `fields.violation_count`. `fields.tool` is present on `tool_call` and on `output` events for tool return text. With `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true` operators also opt into `fields.evidence` carrying the redacted + truncated triggering text. **Platform command denial (#238):** when a call matches a platform-policy `denied_command_patterns` entry, this event fires with `fields.source: "platform"`, `fields.guardrail: "platform_command_deny"`, `fields.pattern`, `fields.layer` (first-denying layer), `fields.policy_source` (file path), and the operator `fields.message` — the operator-authored, org-wide command control from [Platform Policy — Runtime command denial](platform-policy.md#runtime-command-denial). See [Guardrails — Audit Events](guardrails.md#audit-events). |
Expand Down Expand Up @@ -132,6 +132,8 @@ See [Tenancy stamping reference](tenancy.md) for the precedence rules and the ag

Every `llm_call` audit event carries the normalized token counts the provider returned in its response metadata, plus the wall-clock time spent in the provider call. Field naming aligns with [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (`gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`) so audit consumers can correlate Forge audit events with OTel traces without a translation table.

**Prompt caching and `total_input_tokens`.** When Anthropic prompt caching is active (Forge sets `cache_control` breakpoints on the tools + system prefix), the provider's `input_tokens` is only the **uncached delta** — the bulk of the prompt is billed separately as `cache_read_input_tokens` (a cache hit, ~10% rate) and `cache_creation_input_tokens` (the one-time write that seeds the cache). Reading `input_tokens` alone therefore undercounts real input by orders of magnitude on cache-heavy runs. To make correct usage the default, every `llm_call` also carries **`total_input_tokens` = `input_tokens` + `cache_read_input_tokens` + `cache_creation_input_tokens`** — always present, even when caching is off (where it equals `input_tokens`). Bill from `total_input_tokens`. OpenAI is unaffected: its `prompt_tokens` already folds cached input into the total, so the cache fields stay absent.

```json
{
"ts": "2026-06-04T15:21:09Z",
Expand All @@ -142,16 +144,22 @@ Every `llm_call` audit event carries the normalized token counts the provider re
"provider": "anthropic",
"input_tokens": 1240,
"output_tokens": 387,
"cache_read_input_tokens": 18004,
"cache_creation_input_tokens": 512,
"total_input_tokens": 19756,
"duration_ms": 2150,
"request_id": "msg_01H8…"
}
```

| Field | Source | Notes |
|---|---|---|
| `input_tokens` | Provider response usage | Maps to `gen_ai.usage.input_tokens` |
| `input_tokens` | Provider response usage | Maps to `gen_ai.usage.input_tokens`. Under prompt caching this is the **uncached delta only** — bill from `total_input_tokens` instead |
| `output_tokens` | Provider response usage | Maps to `gen_ai.usage.output_tokens` |
| `tokens_unavailable` | Audit emitter | `true` when both counts are zero — some self-hosted Ollama setups don't return usage; billing consumers must distinguish "not measured" from "zero tokens used" |
| `cache_read_input_tokens` | Provider response usage | Anthropic prompt-cache hit — cached-prefix tokens read this call. Omitted when zero / non-Anthropic |
| `cache_creation_input_tokens` | Provider response usage | Anthropic prompt-cache write — tokens spent seeding the cache. Omitted when zero / non-Anthropic |
| `total_input_tokens` | Audit emitter | `input_tokens` + `cache_read_input_tokens` + `cache_creation_input_tokens` — the true input consumption. **Always present** (equals `input_tokens` when caching is off). This is the bill-from field |
| `tokens_unavailable` | Audit emitter | `true` when `total_input_tokens` **and** `output_tokens` are both zero — some self-hosted Ollama setups don't return usage; billing consumers must distinguish "not measured" from "zero tokens used". A cache-read-only turn (`input_tokens` 0 but `cache_read_input_tokens` > 0) is **not** flagged — it consumed real input |
| `model` | Runtime model config | The model identifier the executor was configured with |
| `provider` | Runtime model config | One of `anthropic`, `openai`, `ollama`, `custom` |
| `duration_ms` | Captured at call site | Wall-clock time spent in `client.Chat`, in milliseconds |
Expand All @@ -165,7 +173,7 @@ A2A response headers carry the same per-invocation totals inline so an orchestra

| Header | Value |
|---|---|
| `X-Forge-Tokens-In` | Sum of `input_tokens` across all LLM calls in the invocation |
| `X-Forge-Tokens-In` | Sum of `total_input_tokens` across all LLM calls in the invocation — the true input incl. Anthropic cache read/creation (#431), so an orchestrator's cost ceiling-check can't be fooled by a cache-heavy stage. Never reports below the summed uncached `input_tokens` delta |
| `X-Forge-Tokens-Out` | Sum of `output_tokens` across all LLM calls in the invocation |
| `X-Forge-Duration-Ms` | Wall-clock invocation duration (auth → dispatch → engine → response) |
| `X-Forge-Model` | Most-recently-used model |
Expand Down Expand Up @@ -193,6 +201,7 @@ Cancellation latency is bounded by the time for the current LLM call or tool cal
"state": "canceled",
"input_tokens_total": 940,
"output_tokens_total": 215,
"total_input_tokens_total": 18744,
"llm_call_count": 2,
"model": "claude-sonnet-4-6",
"provider": "anthropic"
Expand Down Expand Up @@ -520,6 +529,8 @@ Every emitted event carries:
| `workflow_id` / `workflow_execution_id` / `stage_id` / `step_id` / `invocation_caller` | string | optional | Populated when the request carried `X-Workflow-*` headers (FWS-2). `workflow_id` is the workflow definition (stable across runs); `workflow_execution_id` is the per-run instance (FORGE-2 / #185 split). |
| `model` / `provider` | string | optional | LLM call attribution (FWS-3) |
| `input_tokens` / `output_tokens` / `tokens_unavailable` | int / bool | optional | LLM call usage (FWS-3) |
| `total_input_tokens` | int | optional | True input = `input_tokens` + cache read + cache creation; the bill-from field. Present on every LLM call (#431) |
| `cache_read_input_tokens` / `cache_creation_input_tokens` | int | Anthropic caching only | Prompt-cache read (hit) / creation (write) token counts; omitted when zero (#431) |
| `duration_ms` | int64 | optional | Wall-clock duration (FWS-3) |
| `request_id` | string | optional | Provider-specific call identifier (FWS-3) |
| `trace_id` / `span_id` | string | tracing-on only | W3C-format lowercase hex (32/16 chars) of the OTel span active at emit time. Pivots audit row ↔ trace tree. See [trace cross-link](#trace-cross-link-otel-v1-105). |
Expand Down
14 changes: 13 additions & 1 deletion forge-cli/runtime/forge_usage_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@ func applyForgeUsageHeaders(h http.Header, snap coreruntime.LLMUsageSnapshot) {
h.Set(HeaderForgeDurationMs, strconv.FormatInt(snap.InvocationDuration.Milliseconds(), 10))
return
}
h.Set(HeaderForgeTokensIn, strconv.Itoa(snap.InputTokens))
// Bill-from the TRUE input: uncached delta + Anthropic cache read +
// cache creation (issue #431). Under prompt caching snap.InputTokens
// is only the uncached delta, so an orchestrator ceiling-checking
// against X-Forge-Tokens-In would wildly under-count and let a
// cache-heavy stage sail past a cost cap. The max() guard keeps the
// header from ever reporting BELOW the uncached delta if a caller
// hands us a snapshot with TotalInputTokens unpopulated (mirrors
// security-next#36's `total_input_tokens ?? input_tokens` fallback).
tokensIn := snap.TotalInputTokens
if snap.InputTokens > tokensIn {
tokensIn = snap.InputTokens
}
h.Set(HeaderForgeTokensIn, strconv.Itoa(tokensIn))
h.Set(HeaderForgeTokensOut, strconv.Itoa(snap.OutputTokens))
h.Set(HeaderForgeDurationMs, strconv.FormatInt(snap.InvocationDuration.Milliseconds(), 10))
if snap.PrimaryModel != "" {
Expand Down
Loading
Loading