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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Supported runtime surfaces today:
- Asymptote Observe TypeScript SDK instrumentation for cloud applications, starting from OpenTelemetry/OpenLLMetry patterns and `observe()` wrappers.
- Elasticsearch/Filebeat content pack generation for forwarding local Beacon JSONL into customer-managed Elastic deployments or the bundled loopback-only development stack.
- A local-only dashboard served by `beacon endpoint dashboard`, bound to loopback by default and backed by the runtime JSONL log.
- Token usage and runtime-reported cost capture across spans, logs, and metric datapoints, normalized into `gen_ai.usage`, with attribution rollups served by the dashboard token view (`/api/tokens`) and the `beacon token-usage` report command for local and CI logs.
- Token usage and runtime-reported cost capture across spans, logs, and metric datapoints, normalized into `gen_ai.usage`, with attribution rollups served by the dashboard token view (`/api/tokens`) and the `beacon token-usage` report command for local and CI logs. For Cursor, whose hook payloads carry no token counts, the hook adapter normalizes usage fields into `gen_ai.usage` whenever a payload carries them (inert today, forward-compatible), and the explicit, read-only, offline `beacon token-usage sync-cursor` command extracts runtime-recorded per-generation counts from a snapshot of Cursor's local `state.vscdb` into `token.usage` events (`raw.metric_name=cursor.db.token.usage`, deduped against any future hook-channel usage per harness/session); zero-count generations are skipped, never estimated.
- Local threat detection via `beacon scan`, which runs the open Threat Rules format (`spec/threat-rules`; CEL match conditions over the endpoint event schema, with embedded conformance fixtures) over the runtime JSONL. The engine (`pkg/asymptoteobserve/threatrules`) ships in the binary; the rule corpus is external data loaded from a local store (`~/.beacon/endpoint/rules`) managed by `beacon rules`, so the corpus can grow without enlarging the binary. `scan` is read-only and offline; only the explicit, user-initiated `beacon rules pull <url>` reaches the network. A small frozen baseline is embedded so `scan` works before any rules are installed.
- An optional, off-by-default policy seam in the hook path (`cli/beacon-hooks/internal/policy`) governed by the stable contract in `pkg/asymptoteobserve/policycontract`. When `BEACON_POLICY_PROVIDER` names an executable, the `pre-tool` and `permission-request` hooks send it a JSON request describing the imminent tool call and honor an allow/deny response, returning the runtime's native deny shape and `policy.enforcement=enforce` telemetry on a deny. The seam is inert when unset and fail-open on any error (timeout, non-zero exit, malformed output), so the open build ships no enforcement of its own; all audit/enforce decision logic lives in the external provider. `rules/agent-control/agent-permission-bypass-spawn` is the open detect twin that flags the same pattern in `beacon scan`.

Expand Down
3 changes: 2 additions & 1 deletion cli/beacon-hooks/cmd/endpoint_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ func emitHookEvent(logger *logging.Logger, action, category, severity, message s
if isCascadePlatform(platformFlag) {
fields["raw"] = mergeNested(fields["raw"], map[string]interface{}{"cascade": input})
}
if model := getFirstStr(input, "model"); model != "" {
if model := getFirstStr(input, "model", "model_id"); model != "" {
fields["model"] = model
}
applyGenAIUsageFields(fields, input)
cwd := resolveCwd(input, platformFlag)
if cwd != "" {
fields["session"] = mergeNested(fields["session"], map[string]interface{}{"working_directory": cwd})
Expand Down
152 changes: 152 additions & 0 deletions cli/beacon-hooks/cmd/genai_usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cmd

import (
"encoding/json"
"strconv"
)

// usageContainerKeys are payload keys that may hold a nested token-usage
// object. Runtimes have not converged on one shape, so the extractor accepts
// the common container spellings before falling back to bare top-level fields.
var usageContainerKeys = []string{"tokens", "token_usage", "tokenUsage", "usage", "token_count", "tokenCount"}

// usageAliases maps each canonical gen_ai.usage field to the payload keys it
// may arrive under. Only runtime-reported values pass through; totals are
// derived data and are deliberately not read.
var (
usageInputAliases = []string{"input_tokens", "inputTokens", "prompt_tokens", "promptTokens"}
usageOutputAliases = []string{"output_tokens", "outputTokens", "completion_tokens", "completionTokens"}
usageCacheReadAliases = []string{"cache_read_input_tokens", "cacheReadInputTokens", "cache_read_tokens", "cacheReadTokens", "cached_input_tokens", "cachedInputTokens"}
usageCacheCreationAliases = []string{"cache_creation_input_tokens", "cacheCreationInputTokens", "cache_write_tokens", "cacheWriteTokens"}
usageReasoningAliases = []string{"reasoning_output_tokens", "reasoningOutputTokens", "reasoning_tokens", "reasoningTokens"}
usageCostAliases = []string{"cost_usd", "costUsd", "cost"}
)

// extractGenAIUsage returns a canonical gen_ai.usage object (OTel GenAI
// semconv JSON names) from a hook payload, or nil when the payload carries no
// usage. No hook runtime reports usage in every payload, so absence is the
// normal case; token counts are never estimated and cost is never computed
// locally.
func extractGenAIUsage(input map[string]interface{}) map[string]interface{} {
containers := usageContainers(input)
if len(containers) == 0 {
return nil
}
usage := map[string]interface{}{}
if v, ok := firstUsageInt64(containers, usageInputAliases...); ok {
usage["input_tokens"] = v
}
if v, ok := firstUsageInt64(containers, usageOutputAliases...); ok {
usage["output_tokens"] = v
}
if v, ok := firstUsageInt64(containers, usageCacheReadAliases...); ok {
usage["cache_read"] = map[string]interface{}{"input_tokens": v}
}
if v, ok := firstUsageInt64(containers, usageCacheCreationAliases...); ok {
usage["cache_creation"] = map[string]interface{}{"input_tokens": v}
}
if v, ok := firstUsageInt64(containers, usageReasoningAliases...); ok {
usage["reasoning"] = map[string]interface{}{"output_tokens": v}
}
if v, ok := firstUsageFloat(containers, usageCostAliases...); ok {
usage["cost_usd"] = v
}
if len(usage) == 0 {
return nil
}
return usage
}

// applyGenAIUsageFields merges extracted usage into fields["gen_ai"].usage so
// it composes with the gen_ai.tool block some emitters already set.
func applyGenAIUsageFields(fields, input map[string]interface{}) {
usage := extractGenAIUsage(input)
if usage == nil {
return
}
fields["gen_ai"] = mergeNested(fields["gen_ai"], map[string]interface{}{"usage": usage})
}

// usageContainers returns the candidate objects usage fields may live in, in
// priority order: nested containers first, then the payload itself for bare
// top-level fields.
func usageContainers(input map[string]interface{}) []map[string]interface{} {
var containers []map[string]interface{}
for _, key := range usageContainerKeys {
if nested, ok := input[key].(map[string]interface{}); ok && len(nested) > 0 {
containers = append(containers, nested)
}
}
if input != nil {
containers = append(containers, input)
}
return containers
}

// firstUsageInt64 resolves an alias chain across candidate containers,
// returning the first non-negative integer value found.
func firstUsageInt64(containers []map[string]interface{}, keys ...string) (int64, bool) {
for _, container := range containers {
for _, key := range keys {
value, ok := container[key]
if !ok {
continue
}
if n, ok := usageInt64(value); ok && n >= 0 {
return n, true
}
}
}
return 0, false
}

// firstUsageFloat resolves an alias chain across candidate containers,
// returning the first non-negative numeric value found. The payload itself
// (last container) only resolves explicit cost_usd/costUsd keys; a bare
// top-level "cost" is too likely to mean something other than model spend.
func firstUsageFloat(containers []map[string]interface{}, keys ...string) (float64, bool) {
for i, container := range containers {
topLevel := i == len(containers)-1
for _, key := range keys {
if topLevel && key == "cost" {
continue
}
value, ok := container[key]
if !ok {
continue
}
if f, ok := usageFloat(value); ok && f >= 0 {
return f, true
}
}
}
return 0, false
}

func usageInt64(value interface{}) (int64, bool) {
if f, ok := usageFloat(value); ok {
return int64(f), true
}
return 0, false
}

func usageFloat(value interface{}) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case json.Number:
f, err := v.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(v, 64)
return f, err == nil
default:
return 0, false
}
}
Loading
Loading